release: stream an object to a file with a running sha
Opener is a side-interface (48 test fakes implement Store; none break), implemented by DirStore, S3Store, prefixStore and the fleet door client. FetchToFile is what a box uses for a platform binary: limit+1 read, sha checked before rename, directory fsync after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
5 files changed,
+287
−0
internal/fleetapi/fleetapi.go+26 −0internal/fleetapi/fleetapi_test.go+27 −0internal/release/fetch.go+70 −0internal/release/fetch_test.go+105 −0internal/release/store.go+59 −0
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go| index 73f1e0402..c21ea153d 100644 |
| --- a/internal/fleetapi/fleetapi.go |
| +++ b/internal/fleetapi/fleetapi.go |
| @@ -154,6 +154,32 @@ func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| } |
| } |
| +// Open streams one object. Unlike Get it does not buffer the body, which |
| +// is what a 25 MiB platform binary on a Pi needs; the 404 mapping is the |
| +// same as Get's so callers can errors.Is(err, os.ErrNotExist). |
| +func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return nil, "", errorf("open %s: %w", key, err) |
| + } |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + return resp.Body, resp.Header.Get("ETag"), nil |
| + case http.StatusNotFound: |
| + resp.Body.Close() |
| + return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| + default: |
| + resp.Body.Close() |
| + return nil, "", errorf("open %s: unexpected status %d", key, resp.StatusCode) |
| + } |
| +} |
| + |
| +var _ release.Opener = (*store)(nil) |
| + |
| // Put mirrors the door's CAS mapping exactly (storedoor.go's |
| // putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only, |
| // the contract's only form of an unconditional-looking write), a non-empty |
diff --git a/internal/fleetapi/fleetapi_test.go b/internal/fleetapi/fleetapi_test.go| index 4956c9b18..b372a666f 100644 |
| --- a/internal/fleetapi/fleetapi_test.go |
| +++ b/internal/fleetapi/fleetapi_test.go |
| @@ -11,10 +11,12 @@ package fleetapi_test |
| // box dialing CARLOS_TUNNEL_URL would produce. |
| import ( |
| + "bytes" |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "errors" |
| + "io" |
| "net" |
| "net/http" |
| "os" |
| @@ -364,3 +366,28 @@ func TestTokenNeverInErrorStrings(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- Open (streaming reads) ------------------------------------------------ |
| + |
| +func TestOpenStreamsThroughTheDoor(t *testing.T) { |
| + d := newTestDoor(t) |
| + body := bytes.Repeat([]byte("x"), 1<<20) |
| + seed(t, d.store, "bootstrap/carlos", string(body)) |
| + c := d.box(t) |
| + op, ok := c.(release.Opener) |
| + if !ok { |
| + t.Fatal("fleetapi store is not an Opener") |
| + } |
| + rc, _, err := op.Open(context.Background(), "bootstrap/carlos") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rc.Close() |
| + got, _ := io.ReadAll(rc) |
| + if !bytes.Equal(got, body) { |
| + t.Fatalf("read %d bytes, want %d", len(got), len(body)) |
| + } |
| + if _, _, err := op.Open(context.Background(), "bootstrap/nothing"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing: %v", err) |
| + } |
| +} |
diff --git a/internal/release/fetch.go b/internal/release/fetch.go| new file mode 100644 |
| index 000000000..4c3d33825 |
| --- /dev/null |
| +++ b/internal/release/fetch.go |
| @@ -0,0 +1,70 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +// ErrTooLarge: the object exceeded the caller's limit. The reader is cut at |
| +// limit+1 bytes, so an object EXACTLY at the limit is told from one over it. |
| +var ErrTooLarge = errors.New("store: object larger than the caller's limit") |
| + |
| +// FetchToFile streams key into dst with a running sha256, never holding the |
| +// object in memory: temp file, fsync, rename, directory fsync. Any failure |
| +// removes the temp and leaves dst untouched. |
| +func FetchToFile(ctx context.Context, s Store, key, dst string, limit int64, wantSHA256 string) (err error) { |
| + rc, _, err := OpenObject(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + defer rc.Close() |
| + tmp := dst + ".tmp" |
| + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { |
| + if err != nil { |
| + f.Close() |
| + os.Remove(tmp) |
| + } |
| + }() |
| + h := sha256.New() |
| + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, limit+1)) |
| + if err != nil { |
| + return fmt.Errorf("fetch %s: %w", key, err) |
| + } |
| + if n > limit { |
| + return fmt.Errorf("fetch %s: %w (limit %d)", key, ErrTooLarge, limit) |
| + } |
| + if got := hex.EncodeToString(h.Sum(nil)); got != wantSHA256 { |
| + return fmt.Errorf("fetch %s: sha256 %s does not match the signed %s", key, got, wantSHA256) |
| + } |
| + if err = f.Sync(); err != nil { |
| + return err |
| + } |
| + if err = f.Close(); err != nil { |
| + return err |
| + } |
| + if err = os.Rename(tmp, dst); err != nil { |
| + return err |
| + } |
| + return syncDir(filepath.Dir(dst)) |
| +} |
| + |
| +// syncDir makes a rename durable: the directory entry is what fsync(file) |
| +// does not cover. |
| +func syncDir(dir string) error { |
| + d, err := os.Open(dir) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + return d.Sync() |
| +} |
diff --git a/internal/release/fetch_test.go b/internal/release/fetch_test.go| new file mode 100644 |
| index 000000000..a4cf3da53 |
| --- /dev/null |
| +++ b/internal/release/fetch_test.go |
| @@ -0,0 +1,105 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func shaOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| + |
| +func TestFetchToFileStreamsAndVerifies(t *testing.T) { |
| + ctx := context.Background() |
| + body := []byte(strings.Repeat("carlos", 10000)) |
| + for name, s := range map[string]Store{ |
| + "dir": NewDirStore(t.TempDir()), |
| + "prefix": NewPrefixStore(NewDirStore(t.TempDir()), "fleets/pi/"), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := s.Put(ctx, "bootstrap/router/v1/carlos-linux-arm64", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + dst := filepath.Join(t.TempDir(), "carlos") |
| + if err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst, int64(len(body)), shaOf(body)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ := os.ReadFile(dst) |
| + if string(got) != string(body) { |
| + t.Fatal("content differs") |
| + } |
| + if _, err := os.Stat(dst + ".tmp"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("temp file left behind") |
| + } |
| + // One byte over the limit is refused, and nothing is left on disk. |
| + dst2 := filepath.Join(t.TempDir(), "carlos") |
| + if err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst2, int64(len(body))-1, shaOf(body)); !errors.Is(err, ErrTooLarge) { |
| + t.Fatalf("over-limit err = %v", err) |
| + } |
| + if _, err := os.Stat(dst2); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("over-limit fetch left a file") |
| + } |
| + // A sha mismatch is refused and leaves nothing. |
| + dst3 := filepath.Join(t.TempDir(), "carlos") |
| + err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst3, int64(len(body)), strings.Repeat("0", 64)) |
| + if err == nil || !strings.Contains(err.Error(), "sha256") { |
| + t.Fatalf("mismatch err = %v", err) |
| + } |
| + if _, err := os.Stat(dst3); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("mismatch fetch left a file") |
| + } |
| + // Missing key is ErrNotExist. |
| + if err := FetchToFile(ctx, s, "bootstrap/router/v9/carlos-linux-arm64", dst3, 10, shaOf(nil)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing err = %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPrefixStoreOpenRebasesTheKey(t *testing.T) { |
| + // prefixStore embeds the Store INTERFACE, so a promoted Open would forward |
| + // the raw key and silently escape the prefix. It must be written out. |
| + ctx := context.Background() |
| + inner := NewDirStore(t.TempDir()) |
| + if _, err := inner.Put(ctx, "fleets/pi/x", []byte("in"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := inner.Put(ctx, "x", []byte("OUT"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rc, _, err := OpenObject(ctx, NewPrefixStore(inner, "fleets/pi/"), "x") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rc.Close() |
| + b := make([]byte, 8) |
| + n, _ := rc.Read(b) |
| + if string(b[:n]) != "in" { |
| + t.Fatalf("read %q through the prefix, want \"in\"", b[:n]) |
| + } |
| +} |
| + |
| +func TestOpenObjectFallsBackToGet(t *testing.T) { |
| + // A Store that is not an Opener still works: OpenObject buffers via Get. |
| + s := getOnly{NewDirStore(t.TempDir())} |
| + if _, err := s.Put(context.Background(), "k", []byte("v"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rc, _, err := OpenObject(context.Background(), s, "k") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b := make([]byte, 4) |
| + n, _ := rc.Read(b) |
| + if string(b[:n]) != "v" { |
| + t.Fatalf("got %q", b[:n]) |
| + } |
| +} |
| + |
| +type getOnly struct{ *DirStore } |
| + |
| +func (getOnly) Open() {} // different signature: getOnly is NOT an Opener |
diff --git a/internal/release/store.go b/internal/release/store.go| index 3cdb23a1c..6f8c03e6f 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -100,6 +100,12 @@ func (p prefixStore) Get(ctx context.Context, key string) ([]byte, string, error |
| return p.Store.Get(ctx, p.full(key)) |
| } |
| +// Open is written out, not promoted: the embedded field is the interface, |
| +// and a promoted Open would forward the UNREBASED key past the prefix. |
| +func (p prefixStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + return OpenObject(ctx, p.Store, p.full(key)) |
| +} |
| + |
| func (p prefixStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| return p.Store.Put(ctx, p.full(key), body, ifMatch) |
| } |
| @@ -188,6 +194,33 @@ var ( |
| _ SizeLister = (*S3Store)(nil) |
| ) |
| +// Opener is the optional streaming read. Store.Get returns the whole object |
| +// in memory, which is fine for a pointer and wrong for a platform binary on |
| +// a box under MemoryHigh=70%. Implemented by every production store; a test |
| +// fake that only has Get still works through OpenObject. The etag may be "" |
| +// (DirStore cannot know it without reading the file). |
| +type Opener interface { |
| + Open(ctx context.Context, key string) (io.ReadCloser, string, error) |
| +} |
| + |
| +var ( |
| + _ Opener = (*DirStore)(nil) |
| + _ Opener = (*S3Store)(nil) |
| + _ Opener = prefixStore{} |
| +) |
| + |
| +// OpenObject streams key from s, buffering through Get when s cannot stream. |
| +func OpenObject(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) { |
| + if o, ok := s.(Opener); ok { |
| + return o.Open(ctx, key) |
| + } |
| + b, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + return io.NopCloser(bytes.NewReader(b)), etag, nil |
| +} |
| + |
| // ---- DirStore --------------------------------------------------------------- |
| // DirStore keeps objects as files under a root directory — the test double |
| @@ -239,6 +272,21 @@ func (d *DirStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| return b, etagOf(b), nil |
| } |
| +func (d *DirStore) Open(_ context.Context, key string) (io.ReadCloser, string, error) { |
| + p, err := d.path(key) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + f, err := os.Open(p) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist) |
| + } |
| + return nil, "", err |
| + } |
| + return f, "", nil |
| +} |
| + |
| func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch string) (string, error) { |
| p, err := d.path(key) |
| if err != nil { |
| @@ -505,6 +553,17 @@ func (s *S3Store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| return b, aws.ToString(out.ETag), nil |
| } |
| +func (s *S3Store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + out, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key}) |
| + if err != nil { |
| + if s3NotFound(err) { |
| + return nil, "", fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist) |
| + } |
| + return nil, "", err |
| + } |
| + return out.Body, aws.ToString(out.ETag), nil |
| +} |
| + |
| func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| in := &awss3.PutObjectInput{ |
| Bucket: &s.Bucket, |