Skip to content

Commit afff50c

Browse files
committed
feat(artifact-cas): verify uploaded content against the declared digest
The Artifact CAS keys stored artifacts by their client-declared SHA256. This change adds end-to-end content-integrity verification to the upload path so the canonical key is only ever written from bytes CAS has confirmed hash to that digest, across all object-store backends (S3, S3 access point, Azure Blob), in addition to the OCI backend's existing layer-digest check. The CAS service streams each upload to a temporary file on a local staging volume, computes its SHA256 while receiving, and verifies it against the declared digest before handing the verified file to the backend. A mismatch is rejected with InvalidArgument and nothing is written to the backend. The upload path is consolidated into a single spill-then-upload flow shared by all backends, replacing the per-backend streaming branch and the StreamingUploader interface. The staging directory is configured through staging_dir and is required: the CAS refuses to start without one, and proves it is writable before serving traffic, so a misconfiguration surfaces at boot rather than as a healthy service that fails every upload. The Helm chart mounts a dedicated emptyDir for it. Each staging file is unlinked as soon as it is created and the upload proceeds through the open descriptor, so the kernel reclaims the space whenever the process exits, including when it is killed mid-upload, and the volume cannot accumulate partial artifacts. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez <javier@chainloop.dev> Chainloop-Trace-Sessions: 5d0fc605-25b0-4bb3-900c-2c2a8fb4922f
1 parent 4c7a81f commit afff50c

27 files changed

Lines changed: 949 additions & 507 deletions

‎app/artifact-cas/cmd/main.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package main
1717

1818
import (
19+
"errors"
1920
"flag"
21+
"fmt"
2022
"os"
2123
"time"
2224

@@ -121,6 +123,12 @@ func main() {
121123

122124
_ = logger.Log(log.LevelInfo, "msg", "starting artifact-cas service", "version", Version)
123125

126+
// Ensure the upload staging directory is configured and writable before we
127+
// accept any traffic: without it no upload can be verified.
128+
if err := prepareStagingDir(&bc); err != nil {
129+
panic(err)
130+
}
131+
124132
flush, err := initSentry(&bc, logger)
125133
defer flush()
126134
if err != nil {
@@ -152,6 +160,44 @@ func newProtoValidator() (protovalidate.Validator, error) {
152160
return protovalidate.New()
153161
}
154162

163+
// prepareStagingDir creates the upload staging directory and proves it is
164+
// writable. It must be the same directory the service is configured with (see
165+
// serviceOpts / conf.staging_dir).
166+
//
167+
// staging_dir is required: uploads are verified by spilling them here first, so
168+
// a missing or unwritable directory means no upload can succeed. Failing at
169+
// startup surfaces that immediately, rather than letting the service report
170+
// healthy and reject every upload. The CAS container runs with a read-only root
171+
// filesystem and /tmp is a read-only secret mount, so the configured directory is
172+
// the only place uploads can be staged.
173+
//
174+
// The directory stays clean on its own: each upload removes its staging file on
175+
// every exit path, and the emptyDir backing it is cleared by Kubernetes when the
176+
// Pod is removed from the node.
177+
func prepareStagingDir(bc *conf.Bootstrap) error {
178+
dir := bc.GetStagingDir()
179+
if dir == "" {
180+
return errors.New("staging_dir is required: it must point at a writable, pod-local directory")
181+
}
182+
183+
if err := os.MkdirAll(dir, 0o700); err != nil {
184+
return fmt.Errorf("creating staging dir %q: %w", dir, err)
185+
}
186+
187+
// MkdirAll succeeds on a directory that already exists but cannot be written
188+
// to, so probe it rather than discovering the problem on the first upload.
189+
probe, err := os.CreateTemp(dir, ".writable-probe-*")
190+
if err != nil {
191+
return fmt.Errorf("staging dir %q is not writable: %w", dir, err)
192+
}
193+
_ = probe.Close()
194+
if err := os.Remove(probe.Name()); err != nil {
195+
return fmt.Errorf("removing staging dir probe file: %w", err)
196+
}
197+
198+
return nil
199+
}
200+
155201
func initSentry(c *conf.Bootstrap, logger log.Logger) (cleanupFunc func(), err error) {
156202
cleanupFunc = func() {
157203
sentry.Flush(2 * time.Second)

‎app/artifact-cas/cmd/main_test.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package main
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
// TestPrepareStagingDir: the staging directory is a hard startup requirement.
28+
// Every way it can be unusable must fail the boot, because a CAS that starts
29+
// without one reports healthy and then rejects every upload.
30+
func TestPrepareStagingDir(t *testing.T) {
31+
testCases := []struct {
32+
name string
33+
// dir builds the staging_dir value for the case; t.TempDir() gives each
34+
// case its own scratch space.
35+
dir func(t *testing.T) string
36+
expectErr string
37+
}{
38+
{
39+
name: "unconfigured is rejected rather than defaulted",
40+
dir: func(*testing.T) string { return "" },
41+
expectErr: "staging_dir is required",
42+
},
43+
{
44+
name: "an existing writable directory is accepted",
45+
dir: func(t *testing.T) string { return t.TempDir() },
46+
},
47+
{
48+
name: "a missing directory is created",
49+
dir: func(t *testing.T) string {
50+
return filepath.Join(t.TempDir(), "nested", "staging")
51+
},
52+
},
53+
{
54+
name: "an existing but unwritable directory is rejected",
55+
dir: func(t *testing.T) string {
56+
dir := filepath.Join(t.TempDir(), "read-only")
57+
require.NoError(t, os.Mkdir(dir, 0o500))
58+
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
59+
return dir
60+
},
61+
expectErr: "not writable",
62+
},
63+
}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
if os.Geteuid() == 0 && tc.expectErr == "not writable" {
68+
t.Skip("root ignores directory permissions")
69+
}
70+
71+
dir := tc.dir(t)
72+
err := prepareStagingDir(&conf.Bootstrap{StagingDir: dir})
73+
74+
if tc.expectErr != "" {
75+
require.ErrorContains(t, err, tc.expectErr)
76+
return
77+
}
78+
79+
require.NoError(t, err)
80+
require.DirExists(t, dir)
81+
82+
// The writability probe must not survive the check.
83+
entries, err := os.ReadDir(dir)
84+
require.NoError(t, err)
85+
require.Empty(t, entries, "the probe file must be removed")
86+
})
87+
}
88+
}

‎app/artifact-cas/cmd/wire.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log.
5151
)
5252
}
5353

54-
func serviceOpts(l log.Logger, audit *service.AuditDispatcher) []service.NewOpt {
54+
func serviceOpts(l log.Logger, audit *service.AuditDispatcher, bc *conf.Bootstrap) []service.NewOpt {
5555
return []service.NewOpt{
5656
service.WithLogger(l),
5757
service.WithAuditDispatcher(audit),
58+
service.WithStagingDir(bc.GetStagingDir()),
5859
}
5960
}
6061

‎app/artifact-cas/cmd/wire_gen.go‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/artifact-cas/configs/config.devel.yaml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ observability:
3939

4040
auth:
4141
public_key_path: ${PUBLIC_KEY_PATH:../../devel/devkeys/cas.pub}
42+
43+
# Local directory where uploads are staged and verified against the declared
44+
# digest before being sent to the backend. Required: there is no fallback.
45+
# In Kubernetes this points at a dedicated per-pod emptyDir.
46+
staging_dir: ${STAGING_DIR:/tmp/chainloop-cas-staging}

‎app/artifact-cas/internal/conf/conf.pb.go‎

Lines changed: 18 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/artifact-cas/internal/conf/conf.proto‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ message Bootstrap {
2828
// Optional NATS server configuration to publish audit events to the
2929
// control-plane-owned stream. When unset, event publishing is disabled.
3030
NatsServer nats_server = 5;
31+
// Local directory where uploads (and, later, downloads) are staged on disk
32+
// and verified against the declared digest before reaching the backend. It
33+
// must be a writable volume; in production a dedicated emptyDir is mounted
34+
// here (NOT tmpfs/RAM, and NOT the /tmp secret mount). When unset the service
35+
// falls back to the OS temp dir, which is only appropriate for local dev.
36+
string staging_dir = 6;
3137

3238
message NatsServer {
3339
// NATS server URI, e.g. "nats://localhost:4222"

‎app/artifact-cas/internal/service/auditor_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func testUploadedEntry() auditor.LogEntry {
7171

7272
const testOrgID = "1089bb36-e27b-428b-8009-d015c8737c54"
7373

74+
// testStoredSecretID is the credentials handle the fake JWT claims carry.
75+
const testStoredSecretID = "secret-id"
76+
7477
func TestAuditDispatcherDispatch(t *testing.T) {
7578
tests := []struct {
7679
name string

0 commit comments

Comments
 (0)