console, hostfloor: console reports are created on S3; executables hashed by streaming (Opus I-3, I-5)
os.IsNotExist does not unwrap S3Store's wrapped not-found, so the console's status report was never created; errors.Is does. Executable digests stream through sha256 instead of reading whole binaries under the update unit's 256M limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+62
−10
internal/console/hostreports.go+12 −6internal/console/hostreports_put_test.go+35 −0internal/hostfloor/disk.go+1 −2internal/hostfloor/floor.go+11 −0internal/hostfloor/image_linux.go+3 −2
CI passed — run details
diff --git a/internal/console/hostreports.go b/internal/console/hostreports.go| index b56aaf512..10ca77591 100644 |
| --- a/internal/console/hostreports.go |
| +++ b/internal/console/hostreports.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| @@ -65,12 +66,7 @@ func (s *Server) StartHostReports(ctx context.Context) { |
| if err == nil { |
| key := "status/consoles/" + s.cfg.HostConsoleID + ".json" |
| raw, _ := json.Marshal(report) |
| - _, etag, readErr := s.cfg.Releases.Get(ctx, key) |
| - if readErr != nil && !os.IsNotExist(readErr) { |
| - err = readErr |
| - } else { |
| - _, err = s.cfg.Releases.Put(ctx, key, raw, etag) |
| - } |
| + err = putHostReport(ctx, s.cfg.Releases, key, raw) |
| } |
| if err == nil && len(s.cfg.Key) != 0 { |
| d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} |
| @@ -90,3 +86,13 @@ func (s *Server) StartHostReports(ctx context.Context) { |
| } |
| } |
| } |
| + |
| +// putHostReport replaces the console's report, creating it when absent. |
| +func putHostReport(ctx context.Context, store release.Store, key string, raw []byte) error { |
| + _, etag, err := store.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { // S3Store wraps it |
| + return err |
| + } |
| + _, err = store.Put(ctx, key, raw, etag) |
| + return err |
| +} |
diff --git a/internal/console/hostreports_put_test.go b/internal/console/hostreports_put_test.go| new file mode 100644 |
| index 000000000..a21386d8f |
| --- /dev/null |
| +++ b/internal/console/hostreports_put_test.go |
| @@ -0,0 +1,35 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// s3ShapedStore wraps Get's errors the way S3Store does. |
| +type s3ShapedStore struct{ release.Store } |
| + |
| +func (s s3ShapedStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + b, etag, err := s.Store.Get(ctx, key) |
| + if err != nil { |
| + return nil, "", fmt.Errorf("s3store: %s: %w", key, err) |
| + } |
| + return b, etag, nil |
| +} |
| + |
| +func TestPutHostReportCreatesThroughAWrappedNotFound(t *testing.T) { |
| + ctx := t.Context() |
| + store := s3ShapedStore{release.NewDirStore(t.TempDir())} |
| + const key = "status/consoles/console-2.json" |
| + for _, body := range []string{`{"n":1}`, `{"n":2}`} { |
| + if err := putHostReport(ctx, store, key, []byte(body)); err != nil { |
| + t.Fatalf("put %s: %v", body, err) |
| + } |
| + got, _, err := store.Get(ctx, key) |
| + if err != nil || string(got) != body { |
| + t.Fatalf("stored %q, %v; want %s", got, err, body) |
| + } |
| + } |
| +} |
diff --git a/internal/hostfloor/disk.go b/internal/hostfloor/disk.go| index 233286c22..acc0bfab6 100644 |
| --- a/internal/hostfloor/disk.go |
| +++ b/internal/hostfloor/disk.go |
| @@ -107,11 +107,10 @@ func (d Disk) VerifyFile(f *os.File, platform string, roles ...string) (string, |
| if _, err = f.Seek(0, io.SeekStart); err != nil { |
| return "", err |
| } |
| - raw, err := io.ReadAll(io.LimitReader(f, 512<<20)) |
| + hash, err := DigestReader(io.LimitReader(f, 512<<20)) |
| if err != nil { |
| return "", err |
| } |
| - hash := Digest(raw) |
| entries, err := os.ReadDir(filepath.Join(d.Dir, "identities")) |
| if err != nil { |
| return "", err |
diff --git a/internal/hostfloor/floor.go b/internal/hostfloor/floor.go| index 7fa001489..2d9d99bb7 100644 |
| --- a/internal/hostfloor/floor.go |
| +++ b/internal/hostfloor/floor.go |
| @@ -8,6 +8,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "github.com/carlosframework/platform/internal/release" |
| + "io" |
| ) |
| const Key = "control/moves-protocol.json" |
| @@ -40,6 +41,16 @@ type Verified struct { |
| func Digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } |
| +// DigestReader is Digest over a stream, so an executable is hashed without |
| +// holding it in memory (the update unit runs under MemoryMax=256M). |
| +func DigestReader(r io.Reader) (string, error) { |
| + h := sha256.New() |
| + if _, err := io.Copy(h, r); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(h.Sum(nil)), nil |
| +} |
| + |
| func Verify(pub ed25519.PublicKey, bucket string, raw, auth []byte) (Verified, error) { |
| var f Floor |
| var a Authorization |
diff --git a/internal/hostfloor/image_linux.go b/internal/hostfloor/image_linux.go| index c5687f37b..b048ac82e 100644 |
| --- a/internal/hostfloor/image_linux.go |
| +++ b/internal/hostfloor/image_linux.go |
| @@ -79,9 +79,10 @@ func ExecutingHash() (string, error) { |
| return "", err |
| } |
| defer f.Close() |
| - raw, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", f.Fd())) |
| + image, err := os.Open(fmt.Sprintf("/proc/self/fd/%d", f.Fd())) |
| if err != nil { |
| return "", err |
| } |
| - return Digest(raw), nil |
| + defer image.Close() |
| + return DigestReader(image) |
| } |