carlos / platform Public

claim-step6-artifacts fix round 1: bounded ELF pre-check, sums bound to the tag, --out reserved first

Astra's review blocked the merge on four findings; controller rulings:

- F1 (fix): release.ArtifactBuild runs release.CheckELFBounds before
  buildinfo.Read. ELF64 little-endian EM_AARCH64 only, at most 256 sections
  and 64 segments, every table and range inside the file, no overlaps, no
  SHF_COMPRESSED section. Crafted inputs (compressed section-name table,
  oversized shnum) are refused through publish-identity with no write. An
  unstripped build is refused (compressed DWARF); the E2E images are now
  built -s -w, as releases are.
- F2 (accepted trial gap): ArtifactBuild reads the recorded -X flag, not the
  linked value; owed before production cohorts, trusted build attestation
  tied to the artefact digest. Its test now builds a real program with a
  version.Build variable.
- F3 (fix): the signed sums must list carlos_<X.Y.Z>_amd64.deb and
  carlos_<X.Y.Z>_arm64.deb for exactly the tag, and no other carlos deb.
  v0.28.1's real-shaped sums under v0.28.2 are refused with no write.
- F4 (fix): claims run reserves --out (exclusive create, 0600, held open)
  before the phase can write to the bucket, writes and reads back through
  that descriptor, removes it on an early failure only while it is still its
  own file, and keeps it after any write attempt. Two competing invocations:
  the loser publishes nothing.

Census: new overlay plan14-root-reads-claim-step6-artifacts-fix1.tsv and a
9B8 entry for putAttempts.Put, both PROPOSED.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6640de7ffb43f8306984907296182cfa29a69bdb parent c38a4ca
15 files changed, +907 −142
  • docs/superpowers/notes/census-proposal-claim-step6-artifacts.md +18 −0
  • internal/claimactivation/artifacts.go +32 −1
  • internal/claimactivation/artifacts_elf_test.go +107 −0
  • internal/claimactivation/artifacts_test.go +51 −9
  • internal/host/activation_artifacts_linux.go +143 −36
  • internal/host/activation_artifacts_linux_test.go +241 −38
  • internal/host/activation_command_linux.go +12 −11
  • internal/host/activation_console_rootrunner_linux_test.go +12 −7
  • internal/provision/claim_step6_artifacts_census_test.go +18 −0
  • internal/provision/plan14_root_reads_test.go +7 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv +12 −0
  • internal/release/artifactbuild_test.go +140 −34
  • internal/release/elfbounds.go +96 −0
  • internal/release/program.go +17 −5
diff --git a/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md b/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md
index cda4d7bfa..8113c88a6 100644
--- a/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md
+++ b/docs/superpowers/notes/census-proposal-claim-step6-artifacts.md
@@ -59,3 +59,21 @@ No new bucket writer, so no 9B8 entry is added. The test passes unchanged. The o
- Root runner (`20261018T070118-3fa2a1`, PASS):
- `TestRootActivationConsoleRuntimePayload` produces the identity through publish-identity over the production S3 store, the policy through the real binary's sign-policy and `--out`, and the console copies through the real binary's install-console. There is no hand-published identity and no hand-made console copy. guard-arm, guard-exec and the console then start under the guard drop-in as the service user, from the protected copy's bytes, and the floor publish-floor writes is the policy's.
- `TestRootActivationConsoleInstall` covers the installer as real root.
+
+## Fix round 1 (PROPOSED, `rev3=PROPOSED-claim-step6-artifacts-fix1`)
+
+Astra's review blocked the merge on four findings. The controller ruled F1, F3 and F4 FIX, and accepted F2 as a trial gap. The rows above stay as ruled. The changes are in a second overlay, `internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv`, loaded after the first, and they await a ruling.
+
+- **F1, bounded ELF parsing.** `release.ArtifactBuild` runs `release.CheckELFBounds` (new, `internal/release/elfbounds.go`) before `buildinfo.Read`. The check reads only fixed-offset header fields and has no read site. It accepts only ELF64 little-endian EM_AARCH64, with at most 256 sections and 64 segments. Every table and range must lie inside the file, the tables must not overlap each other or the ELF header, sections with contents must not overlap, and no section may be SHF_COMPRESSED. An unstripped Go binary is refused, because its DWARF is compressed. Release and console builds are stripped (`-s -w`).
+- **F2, recorded flag, not linked value: trial gap.** `ArtifactBuild` reads the recorded `-X` flag. Release binaries are stripped, so there is no symbol table to read the linked value from. The chain rests on the release key. Owed before production cohorts: trusted build attestation tied to the artefact digest.
+- **F3, sums bound to the tag.** `sumsNameRelease` (claimactivation, no read site) requires `carlos_<X.Y.Z>_amd64.deb` and `carlos_<X.Y.Z>_arm64.deb` for exactly the tag's X.Y.Z, and no other `carlos_*.deb`.
+- **F4, `--out` reserved before the phase.** In `internal/host`:
+ - `reserveActivationOutputAt` creates the file exclusively, 0600, and holds it open before the phase can write.
+ - `activationOutput.finish` writes, syncs and reads back through that descriptor.
+ - `activationOutput.abandon` removes the file only while `activationOutput.ours` shows the entry is still that file (same device and inode).
+ - A failure after any write attempt keeps the file and says so.
+ - `writeActivationOutputAt` is gone (`@remove`).
+
+Root-read rows: `ArtifactBuild` and `runSystemClaims` are changed; `reserveActivationOutputAt`, `activationOutput.finish` and `activationOutput.ours` are new, all authority G,J4, executing as the operator CLI, uid 0, on the trusted signing host. The `internal/claimactivation`, `internal/host` and `internal/release` envelopes are re-pinned. The seal stands.
+
+Record-write census (9B8): one new entry, `internal/host/activation_artifacts_linux.go:putAttempts.Put`, proposed `nonrecord` (`internal/provision/claim_step6_artifacts_census_test.go`, digest `0c8bbda8...b848`). It wraps the store claims run hands the two phases, only to record that a write was attempted. Every Put it passes on comes from the runner's checkedStore and its allow-list, and it adds no key, etag or body.
diff --git a/internal/claimactivation/artifacts.go b/internal/claimactivation/artifacts.go
index 3a7134c31..6be8667e6 100644
--- a/internal/claimactivation/artifacts.go
+++ b/internal/claimactivation/artifacts.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"reflect"
+ "strings"
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostfloor"
@@ -116,6 +117,14 @@ func (r Runner) identityRelease(ctx context.Context, d hostactors.Directory, in
if err != nil {
return release.SystemRelease{}, ErrActivation
}
+ // The sums carry no tag of their own: a release's signed list names its
+ // debs carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb, so both
+ // must be listed for exactly this tag, and no deb for another version
+ // may be, or another release's genuine list would pass under this tag
+ // (controller ruling F3).
+ if !sumsNameRelease(sums, tag) {
+ return release.SystemRelease{}, ErrActivation
+ }
router, ok := sums[publicrelease.AssetName(IdentityPlatform)]
if !ok {
return release.SystemRelease{}, ErrActivation
@@ -145,7 +154,29 @@ func (r Runner) identityRelease(ctx context.Context, d hostactors.Directory, in
// the release tag. The console's own floor report (Disk.Running) compares
// its build stamp with the identity's version, and the identity is
// create-only under its tag, so a console stamped otherwise is refused here
-// rather than published.
+// rather than published. The stamp read is the build info's recorded flag,
+// behind release.CheckELFBounds; the linked value is not read (stripped
+// binaries have no symbols). Owed before production cohorts: trusted build
+// attestation tied to the artefact digest (controller ruling F2).
+// sumsNameRelease reports whether a verified checksum list is tag's: it
+// lists carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb for the tag's
+// X.Y.Z, and no other carlos_*.deb.
+func sumsNameRelease(sums map[string]string, tag string) bool {
+ version := strings.TrimPrefix(tag, "v")
+ want := map[string]bool{"carlos_" + version + "_amd64.deb": true, "carlos_" + version + "_arm64.deb": true}
+ for name := range want {
+ if _, ok := sums[name]; !ok {
+ return false
+ }
+ }
+ for name := range sums {
+ if strings.HasPrefix(name, "carlos_") && strings.HasSuffix(name, ".deb") && !want[name] {
+ return false
+ }
+ }
+ return true
+}
+
func (r Runner) consoleArtifact(ctx context.Context, d hostactors.Directory, c ConsoleRelease, tag string) (string, error) {
if !release.ValidAccount(c.Account) || !release.ValidApp(c.App) || !release.ValidChannel(c.Channel) || !release.ValidVersion(c.Version) || c.Version == "dev" {
return "", ErrActivation
diff --git a/internal/claimactivation/artifacts_elf_test.go b/internal/claimactivation/artifacts_elf_test.go
new file mode 100644
index 000000000..1decf69d3
--- /dev/null
+++ b/internal/claimactivation/artifacts_elf_test.go
@@ -0,0 +1,107 @@
+package claimactivation
+
+import (
+ "encoding/binary"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// realConsole builds a real stripped linux-arm64 program at
+// github.com/carlosframework/platform/cmd/console with an
+// internal/version.Build variable stamped tag, the shape a release console
+// has.
+func realConsole(t *testing.T, tag string) []byte {
+ t.Helper()
+ dir := t.TempDir()
+ for name, body := range map[string]string{
+ "go.mod": "module github.com/carlosframework/platform\n\ngo 1.21\n",
+ "internal/version/version.go": "package version\n\nvar Build = \"dev\"\n",
+ "cmd/console/main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/carlosframework/platform/internal/version\"\n)\n\nfunc main() { fmt.Println(version.Build) }\n",
+ } {
+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ out := filepath.Join(dir, "console")
+ cmd := exec.Command("go", "build", "-ldflags", "-s -w -X "+release.BuildStampFlag+"="+tag, "-o", out, "./cmd/console")
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GOWORK=off", "GOPROXY=off")
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatal(string(output), err)
+ }
+ b, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// F1: publish-identity reads a console's build info only after the bounded
+// ELF pre-check, with the production reader (no test seam). A real stripped
+// console publishes; a crafted one, its section-name table flagged
+// compressed or its section count oversized, is refused with no write.
+func TestPublishIdentityBoundsTheConsoleBinary(t *testing.T) {
+ if testing.Short() {
+ t.Skip("builds Go binaries")
+ }
+ console := realConsole(t, "v0.28.2")
+ le := binary.LittleEndian
+ mutate := func(change func(c []byte)) []byte {
+ c := append([]byte(nil), console...)
+ change(c)
+ return c
+ }
+ shoff := le.Uint64(console[40:])
+ shstrndx := uint64(le.Uint16(console[62:]))
+ for name, tc := range map[string]struct {
+ body []byte
+ ok bool
+ }{
+ "real stripped console": {console, true},
+ "compressed section-name table": {mutate(func(c []byte) {
+ h := c[shoff+shstrndx*64:]
+ le.PutUint64(h[8:], le.Uint64(h[8:])|0x800)
+ }), false},
+ "oversized shnum": {mutate(func(c []byte) { le.PutUint16(c[60:], 65000) }), false},
+ "console stamped another tag": {realConsole(t, "v0.28.1"), false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ // The crafted inputs are the pre-check's to refuse; the wrong tag
+ // passes it and is refused on its stamp.
+ if bounded := release.CheckELFBounds(tc.body) == nil; bounded != (tc.ok || name == "console stamped another tag") {
+ t.Fatal("pre-check verdict", bounded)
+ }
+ w := newArtifactsWorld(t)
+ w.r.ConsoleBuild = nil
+ w.ship(t, "eee5555", tc.body, ConsoleProgram)
+ if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "eee5555"); err != nil {
+ t.Fatal(err)
+ }
+ p := w.identityPlan(t, "eee5555")
+ mark := w.mark()
+ result, err := w.r.Run(t.Context(), p, false)
+ if tc.ok {
+ if err != nil || len(result.Output) == 0 {
+ t.Fatal("real console refused", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatal("crafted console accepted")
+ }
+ for _, r := range w.requests()[mark:] {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ t.Fatalf("refusal wrote: %+v", r)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/claimactivation/artifacts_test.go b/internal/claimactivation/artifacts_test.go
index 6fce31c8c..a6349b2e2 100644
--- a/internal/claimactivation/artifacts_test.go
+++ b/internal/claimactivation/artifacts_test.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "sort"
"strings"
"sync"
"testing"
@@ -141,19 +142,33 @@ func (w *artifactsWorld) revision(t *testing.T) uint64 {
return o.Roster.Revision
}
-// sums is a release's SHA256SUMS.txt and its .sig, signed by key.
+// sums is a release's SHA256SUMS.txt and its .sig, signed by key, one line
+// per file in name order, as the release recipe writes it.
func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) {
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
var b strings.Builder
- for _, name := range []string{"carlos-darwin-arm64", "carlos-linux-amd64", "carlos-linux-arm64", "carlos_0.28.2_arm64.deb"} {
- if body, ok := files[name]; ok {
- b.WriteString(sha(body) + " " + name + "\n")
- }
+ for _, name := range names {
+ b.WriteString(sha(files[name]) + " " + name + "\n")
}
return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n"
}
+// releaseFiles is a release's eight signed files, real-shaped: six carlos
+// builds (Windows zipped) and the two debs, for version X.Y.Z.
+func releaseFiles(version string, router []byte) map[string][]byte {
+ files := map[string][]byte{"carlos-linux-arm64": router}
+ for _, name := range []string{"carlos-darwin-amd64", "carlos-darwin-arm64", "carlos-linux-amd64", "carlos-windows-amd64.zip", "carlos-windows-arm64.zip", "carlos_" + version + "_amd64.deb", "carlos_" + version + "_arm64.deb"} {
+ files[name] = []byte(name + " " + version)
+ }
+ return files
+}
+
func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan {
- text, sig := sums(w.sumsKey, map[string][]byte{"carlos-linux-arm64": w.router, "carlos-linux-amd64": []byte("amd64"), "carlos_0.28.2_arm64.deb": []byte("deb")})
+ text, sig := sums(w.sumsKey, releaseFiles("0.28.2", w.router))
return Plan{Phase: "publish-identity", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Identity: &IdentityInput{
ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: text, SumsSignature: sig,
Console: ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: version}}}
@@ -361,16 +376,43 @@ func TestPublishIdentityRefusesUnsignedOrWrongInputs(t *testing.T) {
}{
"sums signed by another key": {mutate: func(w *artifactsWorld, p *Plan) {
_, other, _ := ed25519.GenerateKey(rand.Reader)
- p.Identity.Sums, p.Identity.SumsSignature = sums(other, map[string][]byte{"carlos-linux-arm64": w.router})
+ p.Identity.Sums, p.Identity.SumsSignature = sums(other, releaseFiles("0.28.2", w.router))
}},
"sums changed after signing": {mutate: func(w *artifactsWorld, p *Plan) {
p.Identity.Sums = strings.Replace(p.Identity.Sums, sha(w.router), strings.Repeat("0", 64), 1)
}},
"no linux-arm64 router": {mutate: func(w *artifactsWorld, p *Plan) {
- p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, map[string][]byte{"carlos-linux-amd64": w.router})
+ files := releaseFiles("0.28.2", w.router)
+ delete(files, "carlos-linux-arm64")
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
}},
"console is a release file": {mutate: func(w *artifactsWorld, p *Plan) {
- p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, map[string][]byte{"carlos-linux-arm64": w.router, "carlos-darwin-arm64": w.console})
+ files := releaseFiles("0.28.2", w.router)
+ files["carlos-darwin-arm64"] = w.console
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
+ }},
+ // F3: a genuine, validly signed list for another release is not this
+ // tag's, whatever its router hash.
+ "v0.28.1's real-shaped sums under v0.28.2": {mutate: func(w *artifactsWorld, p *Plan) {
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.1", w.router))
+ }},
+ "amd64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) {
+ files := releaseFiles("0.28.2", w.router)
+ delete(files, "carlos_0.28.2_amd64.deb")
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
+ }},
+ "arm64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) {
+ files := releaseFiles("0.28.2", w.router)
+ delete(files, "carlos_0.28.2_arm64.deb")
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
+ }},
+ "another release's deb beside": {mutate: func(w *artifactsWorld, p *Plan) {
+ files := releaseFiles("0.28.2", w.router)
+ files["carlos_0.28.1_arm64.deb"] = []byte("old deb")
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files)
+ }},
+ "debs for 0.28.20": {mutate: func(w *artifactsWorld, p *Plan) {
+ p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.20", w.router))
}},
"no sums key": {mutate: func(w *artifactsWorld, p *Plan) { w.r.SumsKey = "" }},
"moves protocol zero": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.MovesProtocol = 0 }},
diff --git a/internal/host/activation_artifacts_linux.go b/internal/host/activation_artifacts_linux.go
index 8d832a731..e5aedbe92 100644
--- a/internal/host/activation_artifacts_linux.go
+++ b/internal/host/activation_artifacts_linux.go
@@ -9,88 +9,195 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
+ "fmt"
"io"
"os"
"path/filepath"
"strings"
+ "sync/atomic"
+ "github.com/carlosframework/platform/internal/claimactivation"
"github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/safefile"
"golang.org/x/sys/unix"
)
-// writeActivationOutput writes a claims phase's file (the armed policy or the
-// published identity) to path: absolute and clean, in a root-owned directory
-// no group or other user can write, created exclusively with mode 0600, the
-// exact bytes and nothing else. An existing path is refused, never replaced.
-// The dry run checks the same and writes nothing.
-func writeActivationOutput(path string, raw []byte, dry bool) error {
- if !filepath.IsAbs(path) || filepath.Clean(path) != path || !dry && len(raw) == 0 {
- return errActivation
+// activationOutput is a claims phase's --out file, reserved before the phase
+// can write to the bucket (controller ruling F4): an exclusive create, mode
+// 0600, in a checked root-owned directory, held open. A second invocation
+// naming the same path fails its own reservation and publishes nothing. The
+// phase's bytes are then written through the held descriptor, synced and
+// read back. A dry run only checks the path is free.
+type activationOutput struct {
+ dir *os.File
+ ownDir bool
+ name string
+ path string
+ f *os.File
+ dev, ino uint64
+}
+
+// reserveActivationOutput reserves path: absolute and clean, in a directory
+// activationDir accepts (root-owned, not writable by group or others).
+func reserveActivationOutput(path string, dry bool) (*activationOutput, error) {
+ if !filepath.IsAbs(path) || filepath.Clean(path) != path {
+ return nil, errActivation
}
dir, err := activationDir(filepath.Dir(path), false)
if err != nil {
- return err
+ return nil, err
+ }
+ o, err := reserveActivationOutputAt(dir, filepath.Base(path), dry)
+ if err != nil {
+ dir.Close()
+ return nil, err
}
- defer dir.Close()
- return writeActivationOutputAt(dir, filepath.Base(path), raw, dry)
+ o.ownDir, o.path = true, path
+ return o, nil
}
-// writeActivationOutputAt is writeActivationOutput under an already checked
-// directory.
-func writeActivationOutputAt(dir *os.File, name string, raw []byte, dry bool) error {
+// reserveActivationOutputAt reserves name under an already checked
+// directory. Any existing entry is refused without following it.
+func reserveActivationOutputAt(dir *os.File, name string, dry bool) (*activationOutput, error) {
if name != filepath.Base(name) || name == "." || name == ".." || name == "/" {
- return errActivation
+ return nil, errActivation
}
var st unix.Stat_t
err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
if err == nil {
- return errActivation
+ return nil, errActivation
}
if err != unix.ENOENT {
- return err
+ return nil, err
}
+ o := &activationOutput{dir: dir, name: name, path: name}
if dry {
- return nil
+ return o, nil
}
- f, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ f, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if err != nil {
- return err
+ return nil, err
}
- _, err = f.Write(raw)
+ err = f.Chmod(0o600)
if err == nil {
- err = f.Chmod(0o600)
+ err = unix.Fstat(int(f.Fd()), &st)
}
- if err == nil {
- err = f.Sync()
+ if err == nil && st.Uid != uint32(os.Geteuid()) {
+ err = errActivation
}
- err = errors.Join(err, f.Close())
if err == nil {
err = unix.Fsync(int(dir.Fd()))
}
+ o.f, o.dev, o.ino = f, uint64(st.Dev), st.Ino
if err != nil {
- _ = unix.Unlinkat(int(dir.Fd()), name, 0)
+ o.abandon()
+ return nil, err
+ }
+ return o, nil
+}
+
+// ours reports whether the directory entry is still the file this
+// reservation created.
+func (o *activationOutput) ours() bool {
+ var st unix.Stat_t
+ return unix.Fstatat(int(o.dir.Fd()), o.name, &st, unix.AT_SYMLINK_NOFOLLOW) == nil && uint64(st.Dev) == o.dev && st.Ino == o.ino
+}
+
+// finish writes raw through the held descriptor, syncs it and the
+// directory, and reads it back: the exact bytes, mode 0600, one link, and
+// the entry still this file.
+func (o *activationOutput) finish(raw []byte) error {
+ if o.f == nil {
+ return nil
+ }
+ if len(raw) == 0 {
+ return errActivation
+ }
+ if _, err := o.f.WriteAt(raw, 0); err != nil {
return err
}
- // Read back through the directory: a regular file with one link, owned
- // by this process, mode 0600, holding exactly raw.
- back, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_RDONLY, 0)
- if err != nil {
+ if err := o.f.Sync(); err != nil {
return err
}
- defer back.Close()
- if err = unix.Fstat(int(back.Fd()), &st); err != nil {
+ got := make([]byte, len(raw)+1)
+ n, err := o.f.ReadAt(got, 0)
+ if err != nil && !errors.Is(err, io.EOF) {
return err
}
- got, err := io.ReadAll(io.LimitReader(back, int64(len(raw))+1))
- if err != nil {
+ var st unix.Stat_t
+ if err = unix.Fstat(int(o.f.Fd()), &st); err != nil {
return err
}
- if st.Uid != uint32(os.Geteuid()) || st.Mode&0o7777 != 0o600 || !bytes.Equal(got, raw) {
+ if n != len(raw) || !bytes.Equal(got[:n], raw) || st.Mode&0o7777 != 0o600 || st.Nlink != 1 || !o.ours() {
return errActivation
}
- return nil
+ return unix.Fsync(int(o.dir.Fd()))
+}
+
+// abandon removes the reserved file, only while the entry is still the file
+// this reservation created, then closes it. It is for failures before
+// anything reached the bucket.
+func (o *activationOutput) abandon() {
+ if o.f != nil && o.ours() {
+ if unix.Unlinkat(int(o.dir.Fd()), o.name, 0) == nil {
+ _ = unix.Fsync(int(o.dir.Fd()))
+ }
+ }
+ o.close()
+}
+
+// close releases the reservation and keeps whatever the file holds.
+func (o *activationOutput) close() {
+ if o.f != nil {
+ o.f.Close()
+ o.f = nil
+ }
+ if o.ownDir {
+ o.dir.Close()
+ o.ownDir = false
+ }
+}
+
+// putAttempts wraps a phase's store and records whether any write was
+// attempted. A write that failed may still have committed, so an attempt,
+// not a success, is what keeps the reserved file after a failure.
+type putAttempts struct {
+ release.Store
+ attempted atomic.Bool
+}
+
+func (s *putAttempts) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ s.attempted.Store(true)
+ return s.Store.Put(ctx, key, raw, etag)
+}
+
+func (s *putAttempts) Delete(ctx context.Context, key string) error {
+ s.attempted.Store(true)
+ return s.Store.Delete(ctx, key)
+}
+
+// runProducingPhase runs sign-policy or publish-identity with its output
+// already reserved (out), on a runner whose store is store. On success the
+// output holds the phase's file. On a failure before any bucket write the
+// reservation is removed; after one, the file is kept and the error says
+// so, because the bucket may already hold what the phase published.
+func runProducingPhase(ctx context.Context, r claimactivation.Runner, store *putAttempts, plan claimactivation.Plan, out *activationOutput, dry bool) (claimactivation.Result, error) {
+ r.Directory.Store = store
+ result, err := r.Run(ctx, plan, dry)
+ if err == nil {
+ err = out.finish(result.Output)
+ }
+ if err == nil {
+ out.close()
+ return result, nil
+ }
+ if !dry && store.attempted.Load() {
+ out.close()
+ return result, fmt.Errorf("%w; the phase wrote to the bucket before it failed, so %s was kept", err, out.path)
+ }
+ out.abandon()
+ return result, err
}
// consoleInstallDir is where install-console puts the protected console
diff --git a/internal/host/activation_artifacts_linux_test.go b/internal/host/activation_artifacts_linux_test.go
index 80f94d928..5e4eebc3a 100644
--- a/internal/host/activation_artifacts_linux_test.go
+++ b/internal/host/activation_artifacts_linux_test.go
@@ -4,15 +4,19 @@ package host
import (
"bytes"
+ "context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "errors"
"os"
"path/filepath"
"strings"
+ "sync"
+ "sync/atomic"
"testing"
"github.com/carlosframework/platform/internal/claimactivation"
@@ -24,47 +28,106 @@ import (
func artifactSHA(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
-// artifactPhases runs sign-policy and publish-identity on a fresh deployment
-// and returns their outputs, the deployment key and the router build's hash.
-func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) {
+// artifactWorld is a fresh deployment ready for sign-policy and
+// publish-identity: roster initialized and configured, the console shipped
+// and promoted with pointer digests on.
+type artifactWorld struct {
+ r claimactivation.Runner
+ store release.Store
+ pub ed25519.PublicKey
+ router []byte
+ sums string
+ sig string
+}
+
+func newArtifactWorld(t *testing.T) *artifactWorld {
t.Helper()
ctx := t.Context()
store := release.NewDirStore(t.TempDir())
pub, key, _ := ed25519.GenerateKey(rand.Reader)
sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader)
- r := claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}
- run := func(p claimactivation.Plan) claimactivation.Result {
- t.Helper()
- p.Bucket = "bucket"
- if o, err := r.Directory.Read(ctx); err == nil {
- p.ExpectedRevision = o.Roster.Revision
- }
- result, err := r.Run(ctx, p, false)
- if err != nil {
- t.Fatal(p.Phase, err)
- }
- return result
- }
- run(claimactivation.Plan{Phase: "initialize"})
- run(claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}})
- if err := release.WriteDigestMarker(ctx, store, r.Signer, "bab", "test"); err != nil {
+ w := &artifactWorld{store: store, pub: pub, router: []byte("carlos v0.28.2"),
+ r: claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}}
+ w.run(t, claimactivation.Plan{Phase: "initialize"})
+ w.run(t, claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}})
+ if err := release.WriteDigestMarker(ctx, store, w.r.Signer, "bab", "test"); err != nil {
t.Fatal(err)
}
m := release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}}
if err := release.Ship(ctx, store, "bab", m, map[string][]byte{"linux-arm64": []byte("console build")}); err != nil {
t.Fatal(err)
}
- if err := r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil {
+ if err := w.r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil {
t.Fatal(err)
}
- router = []byte("carlos v0.28.2")
- sums := artifactSHA(router) + " carlos-linux-arm64\n"
- sig := base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums)))
+ w.sums = artifactSHA(w.router) + " carlos-linux-arm64\n" + artifactSHA([]byte("amd64 deb")) + " carlos_0.28.2_amd64.deb\n" + artifactSHA([]byte("arm64 deb")) + " carlos_0.28.2_arm64.deb\n"
+ w.sig = base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(w.sums)))
+ return w
+}
+
+// plan fills in the bucket and the current roster revision.
+func (w *artifactWorld) plan(t *testing.T, p claimactivation.Plan) claimactivation.Plan {
+ t.Helper()
+ p.Bucket = "bucket"
+ if o, err := w.r.Directory.Read(t.Context()); err == nil {
+ p.ExpectedRevision = o.Roster.Revision
+ }
+ return p
+}
+
+func (w *artifactWorld) run(t *testing.T, p claimactivation.Plan) claimactivation.Result {
+ t.Helper()
+ result, err := w.r.Run(t.Context(), w.plan(t, p), false)
+ if err != nil {
+ t.Fatal(p.Phase, err)
+ }
+ return result
+}
+
+func (w *artifactWorld) policyPlan(t *testing.T) claimactivation.Plan {
floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}
- policy = run(claimactivation.Plan{Phase: "sign-policy", Floor: &floor}).Output
- identity = run(claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: sums, SumsSignature: sig,
- Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}).Output
- return policy, identity, pub, router
+ return w.plan(t, claimactivation.Plan{Phase: "sign-policy", Floor: &floor})
+}
+
+func (w *artifactWorld) identityPlan(t *testing.T) claimactivation.Plan {
+ return w.plan(t, claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: w.sums, SumsSignature: w.sig,
+ Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}})
+}
+
+// artifactPhases runs sign-policy and publish-identity on a fresh deployment
+// and returns their outputs, the deployment key and the router build's hash.
+func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) {
+ t.Helper()
+ w := newArtifactWorld(t)
+ policy = w.run(t, w.policyPlan(t)).Output
+ identity = w.run(t, w.identityPlan(t)).Output
+ return policy, identity, w.pub, w.router
+}
+
+// writeOut reserves name under dir and writes raw to it, as claims run
+// --out does.
+func writeOut(dir *os.File, name string, raw []byte, dry bool) error {
+ out, err := reserveActivationOutputAt(dir, name, dry)
+ if err != nil {
+ return err
+ }
+ if err = out.finish(raw); err != nil {
+ out.abandon()
+ return err
+ }
+ out.close()
+ return nil
+}
+
+func openDir(t *testing.T) (*os.File, string) {
+ t.Helper()
+ path := t.TempDir()
+ dir, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { dir.Close() })
+ return dir, path
}
// C4: the phases' files are written byte for byte, 0600 and exclusively,
@@ -73,30 +136,26 @@ func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKe
// through jq.
func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) {
policy, identity, pub, router := artifactPhases(t)
- dirPath := t.TempDir()
- dir, err := os.Open(dirPath)
- if err != nil {
- t.Fatal(err)
- }
- defer dir.Close()
+ dir, dirPath := openDir(t)
+ var err error
for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} {
- if err = writeActivationOutputAt(dir, name, raw, true); err != nil {
+ if err = writeOut(dir, name, raw, true); err != nil {
t.Fatal("dry run", name, err)
}
if _, err = os.Lstat(filepath.Join(dirPath, name)); !os.IsNotExist(err) {
t.Fatal("dry run wrote", name, err)
}
- if err = writeActivationOutputAt(dir, name, raw, false); err != nil {
+ if err = writeOut(dir, name, raw, false); err != nil {
t.Fatal(name, err)
}
st, err := os.Lstat(filepath.Join(dirPath, name))
if err != nil || st.Mode() != 0o600 {
t.Fatal(name, st, err)
}
- if err = writeActivationOutputAt(dir, name, raw, false); err == nil {
+ if err = writeOut(dir, name, raw, false); err == nil {
t.Fatal("replaced an existing", name)
}
- if err = writeActivationOutputAt(dir, name, raw, true); err == nil {
+ if err = writeOut(dir, name, raw, true); err == nil {
t.Fatal("dry run accepted an existing", name)
}
}
@@ -126,7 +185,7 @@ func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) {
}
}
for _, name := range []string{"", ".", "..", "a/b"} {
- if err = writeActivationOutputAt(dir, name, policy, false); err == nil {
+ if err = writeOut(dir, name, policy, false); err == nil {
t.Fatalf("wrote %q", name)
}
}
@@ -193,3 +252,147 @@ func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) {
}
}
}
+
+// gatedStore holds its first Put until release is closed, after telling
+// reached, and counts Puts.
+type gatedStore struct {
+ release.Store
+ reached, release chan struct{}
+ once sync.Once
+ puts atomic.Int32
+}
+
+func (s *gatedStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ s.puts.Add(1)
+ s.once.Do(func() { close(s.reached); <-s.release })
+ return s.Store.Put(ctx, key, raw, etag)
+}
+
+// F4: two invocations naming the same --out. The first has reserved the
+// path and is inside its identity PUT; the second fails its reservation
+// before its phase runs and sends the bucket nothing. The first then
+// finishes and the file is its published identity.
+func TestActivationOutputCompetingInvocations(t *testing.T) {
+ w := newArtifactWorld(t)
+ dir, dirPath := openDir(t)
+ winner := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})}
+ loser := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})}
+ close(loser.release)
+ plan := w.identityPlan(t)
+ invoke := func(store *gatedStore) (claimactivation.Result, *putAttempts, error) {
+ attempts := &putAttempts{Store: store}
+ out, err := reserveActivationOutputAt(dir, "identity.json", false)
+ if err != nil {
+ return claimactivation.Result{}, attempts, err
+ }
+ result, err := runProducingPhase(t.Context(), w.r, attempts, plan, out, false)
+ return result, attempts, err
+ }
+ type outcome struct {
+ result claimactivation.Result
+ err error
+ }
+ done := make(chan outcome)
+ go func() {
+ result, _, err := invoke(winner)
+ done <- outcome{result, err}
+ }()
+ <-winner.reached
+ if _, attempts, err := invoke(loser); err == nil || attempts.attempted.Load() || loser.puts.Load() != 0 {
+ t.Fatal("the loser published or was not refused", err, loser.puts.Load())
+ }
+ close(winner.release)
+ got := <-done
+ if got.err != nil {
+ t.Fatal(got.err)
+ }
+ file, err := os.ReadFile(filepath.Join(dirPath, "identity.json"))
+ stored, _, serr := w.store.Get(t.Context(), "control/system/router/releases/v0.28.2.json")
+ if err != nil || serr != nil || !bytes.Equal(file, stored) || !bytes.Equal(file, got.result.Output) {
+ t.Fatal("winner's file is not the published identity", err, serr)
+ }
+}
+
+// A failure before any bucket write removes the reservation, and only the
+// file this run created: an entry someone put in its place stays.
+func TestActivationOutputAbandonRemovesOnlyItsOwnFile(t *testing.T) {
+ w := newArtifactWorld(t)
+ dir, dirPath := openDir(t)
+ path := filepath.Join(dirPath, "policy.json")
+ stale := w.policyPlan(t)
+ stale.ExpectedRevision--
+ out, err := reserveActivationOutputAt(dir, "policy.json", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ attempts := &putAttempts{Store: w.store}
+ if _, err = runProducingPhase(t.Context(), w.r, attempts, stale, out, false); err == nil || attempts.attempted.Load() {
+ t.Fatal("stale plan accepted", err)
+ }
+ if _, err = os.Lstat(path); !os.IsNotExist(err) {
+ t.Fatal("reservation left behind", err)
+ }
+ out, err = reserveActivationOutputAt(dir, "policy.json", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = os.Rename(path, path+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(path, []byte("someone else's"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err = out.finish([]byte("{}")); err == nil {
+ t.Fatal("finished into a replaced entry")
+ }
+ out.abandon()
+ if b, err := os.ReadFile(path); err != nil || string(b) != "someone else's" {
+ t.Fatal("abandon removed a file it did not create", err)
+ }
+}
+
+// failingReadback commits Puts to key and then fails every Get of key, as
+// when a create reaches the bucket and its readback is lost.
+type failingReadback struct {
+ release.Store
+ key string
+ sent atomic.Bool
+}
+
+func (s *failingReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ tag, err := s.Store.Put(ctx, key, raw, etag)
+ if key == s.key {
+ s.sent.Store(true)
+ }
+ return tag, err
+}
+
+func (s *failingReadback) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key && s.sent.Load() {
+ return nil, "", errors.New("readback lost")
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// A failure after the identity reached the bucket keeps the reserved file
+// and says so.
+func TestActivationOutputKeptAfterPublication(t *testing.T) {
+ w := newArtifactWorld(t)
+ dir, dirPath := openDir(t)
+ out, err := reserveActivationOutputAt(dir, "identity.json", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out.path = filepath.Join(dirPath, "identity.json")
+ key := "control/system/router/releases/v0.28.2.json"
+ _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: &failingReadback{Store: w.store, key: key}}, w.identityPlan(t), out, false)
+ if err == nil || !strings.Contains(err.Error(), "the phase wrote to the bucket before it failed, so "+out.path+" was kept") {
+ t.Fatal("failure after publication", err)
+ }
+ if _, err = os.Lstat(out.path); err != nil {
+ t.Fatal("reservation removed after publication", err)
+ }
+ if _, _, err = w.store.Get(t.Context(), key); err != nil {
+ t.Fatal("identity not published", err)
+ }
+}
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go
index 9dcbd9fd8..dc967f819 100644
--- a/internal/host/activation_command_linux.go
+++ b/internal/host/activation_command_linux.go
@@ -198,24 +198,25 @@ func runSystemClaims(args []string, out io.Writer) error {
if produces != (*outPath != "") {
return errActivation
}
- if produces {
- if err = writeActivationOutput(*outPath, nil, true); err != nil {
- return err
- }
- }
store, err := release.NewS3StoreIn(ctx, plan.Bucket, *region)
if err != nil {
return err
}
- result, err := activationRunner(store, pub, plan.Bucket, key).Run(ctx, plan, *dry)
+ runner := activationRunner(store, pub, plan.Bucket, key)
+ var result claimactivation.Result
+ if produces {
+ // Reserved before the phase can write to the bucket (F4).
+ out, e := reserveActivationOutput(*outPath, *dry)
+ if e != nil {
+ return e
+ }
+ result, err = runProducingPhase(ctx, runner, &putAttempts{Store: store}, plan, out, *dry)
+ } else {
+ result, err = runner.Run(ctx, plan, *dry)
+ }
if err != nil {
return err
}
- if produces && !*dry {
- if err = writeActivationOutput(*outPath, result.Output, false); err != nil {
- return err
- }
- }
return encode(struct {
claimactivation.Result
Out string `json:"out,omitempty"`
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go
index 3b6f2a403..55383db44 100644
--- a/internal/host/activation_console_rootrunner_linux_test.go
+++ b/internal/host/activation_console_rootrunner_linux_test.go
@@ -40,13 +40,15 @@ var activationConsoleImage, activationCarlosImage []byte
// activationImagesBuild is how the two embedded images are built, from the
// worktree root, before `go test -c -tags rootrunner,activationconsole`
-// (controller ruling C5). Both are stamped v1.0.0 because the identity
+// (controller ruling C5). Both are stripped (-s -w), as release builds are:
+// an unstripped Go binary carries compressed DWARF sections, which the
+// bounded ELF pre-check refuses (F1). Both are stamped v1.0.0 because the identity
// names that version: guard-arm compares the carlos build with it, and the
// console's own floor report (Disk.Running) compares the console's build
// with it, so publish-identity refuses a console stamped otherwise.
// checkActivationImages asserts both images match this command.
-const activationImagesBuild = `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-carlos-runner.bin .
-GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-console-runner.bin ./cmd/console`
+const activationImagesBuild = `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-carlos-runner.bin .
+GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-console-runner.bin ./cmd/console`
func checkActivationImages(t *testing.T) {
t.Helper()
@@ -54,8 +56,8 @@ func checkActivationImages(t *testing.T) {
raw []byte
path, ldflag string
}{
- {activationCarlosImage, "github.com/carlosframework/platform", "-X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
- {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
+ {activationCarlosImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
+ {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
} {
info, err := buildinfo.Read(bytes.NewReader(img.raw))
if err != nil {
@@ -201,7 +203,7 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) {
check(err)
sumsPub, sumsKey, _ := ed25519.GenerateKey(nil)
identityRunner := claimactivation.Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: bucket}, Signer: signer, SumsKey: base64.StdEncoding.EncodeToString(sumsPub)}
- sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n"
+ sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n" + hostfloor.Digest([]byte("amd64 deb")) + " carlos_1.0.0_amd64.deb\n" + hostfloor.Digest([]byte("arm64 deb")) + " carlos_1.0.0_arm64.deb\n"
o, err = directory.Read(ctx)
check(err)
identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums,
@@ -214,7 +216,10 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) {
}
published, err := identityRunner.Run(ctx, identityPlan, false)
check(err)
- check(writeActivationOutput(d+"/identity.json", published.Output, false))
+ identityOut, err := reserveActivationOutput(d+"/identity.json", false)
+ check(err)
+ check(identityOut.finish(published.Output))
+ identityOut.close()
planRaw, _ := json.Marshal(identityPlan)
write(d+"/identity-plan.json", planRaw, 0600)
carlos(false, "system", "claims", "run", "--plan", d+"/identity-plan.json", "--signing-key", d+"/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1", "--out", d+"/identity-vendor.json")
diff --git a/internal/provision/claim_step6_artifacts_census_test.go b/internal/provision/claim_step6_artifacts_census_test.go
new file mode 100644
index 000000000..05f6dc95c
--- /dev/null
+++ b/internal/provision/claim_step6_artifacts_census_test.go
@@ -0,0 +1,18 @@
+package provision
+
+// plan14ClaimStep6ArtifactsWriteCensusInventory is the record-write
+// inventory with claims run's putAttempts.Put (fix round 1, F4), PROPOSED
+// for the controller's ruling
+// (docs/superpowers/notes/census-proposal-claim-step6-artifacts.md).
+//
+// putAttempts wraps the store that claims run hands sign-policy and
+// publish-identity, only to record that a write was attempted, so a failed
+// phase keeps its reserved --out file once the bucket may have changed. It
+// adds no key, etag or body: every Put it passes on comes from the runner's
+// checkedStore, whose allow-list admits only the phase's identity key.
+// Hence nonrecord.
+func plan14ClaimStep6ArtifactsWriteCensusInventory() []censusEntry {
+ return append(plan14BoxPublishRecordWriteCensusInventory(), censusEntry{
+ "internal/host/activation_artifacts_linux.go", "putAttempts.Put", "nonrecord", "def:Put,sel:Put", "0c8bbda8e6f648b10ab1ec26bc3155c9c1755e11f4e7133e4dedd32f06c8b848",
+ })
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 1cacfec62..5f6d138c6 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -843,6 +843,13 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay claim-step6-artifacts-fix1 (fix round 1: F1 bounded ELF
+ // pre-check, F3 sums bound to the tag, F4 --out reserved before the
+ // phase), loaded last. PROPOSED for the controller's ruling. It adds no
+ // accepted-legacy row, so the seal stands.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index f66dd1740..6a7232b82 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14BoxPublishRecordWriteCensusInventory()
+ inventory := plan14ClaimStep6ArtifactsWriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv
new file mode 100644
index 000000000..1b499ec60
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix1.tsv
@@ -0,0 +1,12 @@
+# Census overlay claim-step6-artifacts-fix1 (fix round 1 after Astra's review: controller rulings F1, F3 and F4; F2 accepted as a trial gap), loaded LAST, after claim-step6-artifacts. PROPOSED: every row marked rev3=PROPOSED-claim-step6-artifacts-fix1 waits for the controller's ruling; docs/superpowers/notes/census-proposal-claim-step6-artifacts.md, section "Fix round 1".
+# F1 internal/release ArtifactBuild now runs release.CheckELFBounds (new, elfbounds.go: fixed-offset header reads only, no read site) before buildinfo.Read. F3 claimactivation sumsNameRelease (no read site) binds the signed sums to the tag through its two debs. F4 internal/host: claims run reserves --out before the phase runs (reserveActivationOutputAt), writes and reads it back through the held descriptor (activationOutput.finish), and removes it only while the entry is still its own file (activationOutput.ours); writeActivationOutputAt is gone (@remove: its checks moved into reserveActivationOutputAt and activationOutput.finish).
+# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches any changed unit.
+@coverage internal/claimactivation ee00b6e03fe3a0148a2b1e2835f21852f9a3498cec00f34bcbb3895b0519f45e
+@coverage internal/host 8105036aeaefbe18f9236cf798d39cb90c8db6a910e86b6312ce1e2feeec0858
+@coverage internal/release 158c6cc42d90632619f4c8c41b47238c3cbdc2170f58c3a8fb7c7a12d2032783
+@remove internal/host/activation_artifacts_linux.go writeActivationOutputAt
+internal/release/program.go ArtifactBuild authority G,J4 rev3=PROPOSED-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host (publish-identity);basis=release.CheckELFBounds first: ELF64 little-endian EM_AARCH64, at most 256 sections and 64 segments, every table and range inside the file, no overlap, no SHF_COMPRESSED; then build info of bytes already hashed against a signed manifest; one plain -X version.Build flag or nothing; reads the recorded flag, not the linked value (F2 trial gap); no box path calls it file:Read@96 e89aeb9befc84a21f8f43d4c13e03f7246f00bf464813aa326083c9e6a3b1984
+internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0;basis=as census-ruling-claim-step6-artifacts; run reserves --out for sign-policy and publish-identity by exclusive create before the phase can write, and runs the phase on a store that records write attempts file:Read@1602 0d740c974987081d35a3ce3b8cc71d2584ad28f591d47b65c105766598421eef
+internal/host/activation_artifacts_linux.go reserveActivationOutputAt authority G,J4 rev3=PROPOSED-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=checked root-owned parent; refuses any existing entry without following it; exclusive 0600 create held open, owner checked, directory synced; dry-run only checks the path is free file:Fstat@659,file:Fstatat@236 0c47fca349ad833e49f479a448437a66377a18ee3f4952fee17946261abc2235
+internal/host/activation_artifacts_linux.go activationOutput.finish authority G,J4 rev3=PROPOSED-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=writes the phase's exact bytes through the held descriptor, syncs, reads them back through it, requires mode 0600, one link and the entry still its own file, syncs the directory file:Fstat@402,file:ReadAt@293 24d118853d45c9db4e8223db6f6dae810714415bdaf90c9442a7e50f96eebef9
+internal/host/activation_artifacts_linux.go activationOutput.ours authority G,J4 rev3=PROPOSED-claim-step6-artifacts-fix1;identity=explicit operator CLI uid 0 on the trusted signing host;basis=compares the directory entry, not followed, with the held file's device and inode; abandon removes only a match file:Fstatat@69 a3056d2b0d2b2090c723aec8beb7633ed0a3418a078c9721e52e0c37730173da
diff --git a/internal/release/artifactbuild_test.go b/internal/release/artifactbuild_test.go
index 8bdc2d18c..f8629f6dc 100644
--- a/internal/release/artifactbuild_test.go
+++ b/internal/release/artifactbuild_test.go
@@ -1,57 +1,163 @@
package release
import (
+ "encoding/binary"
+ "errors"
"os"
"os/exec"
"path/filepath"
"testing"
)
-// ArtifactBuild reads the version stamp from a real Go binary's recorded
-// -ldflags, for any architecture, without running it.
-func TestArtifactBuildReadsTheVersionStamp(t *testing.T) {
- if testing.Short() {
- t.Skip("builds Go binaries")
- }
+// buildStampedProgram builds, for goarch, a real program in a module named
+// github.com/carlosframework/platform whose internal/version package holds a
+// Build string variable, the shape carlos and cmd/console have, with
+// ldflags as given.
+func buildStampedProgram(t *testing.T, goarch, ldflags string) []byte {
+ t.Helper()
dir := t.TempDir()
- src := filepath.Join(dir, "main.go")
- if err := os.WriteFile(src, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- build := func(ldflags string) []byte {
- t.Helper()
- out := filepath.Join(dir, "bin")
- args := []string{"build", "-o", out}
- if ldflags != "" {
- args = append(args, "-ldflags", ldflags)
- }
- cmd := exec.Command("go", append(args, src)...)
- cmd.Dir = dir
- cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GO111MODULE=off")
- if output, err := cmd.CombinedOutput(); err != nil {
- t.Fatal(string(output), err)
+ for name, body := range map[string]string{
+ "go.mod": "module github.com/carlosframework/platform\n\ngo 1.21\n",
+ "internal/version/version.go": "package version\n\nvar Build = \"dev\"\n",
+ "main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/carlosframework/platform/internal/version\"\n)\n\nfunc main() { fmt.Println(version.Build) }\n",
+ } {
+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
+ t.Fatal(err)
}
- b, err := os.ReadFile(out)
- if err != nil {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
- return b
+ }
+ out := filepath.Join(dir, "bin")
+ args := []string{"build", "-o", out}
+ if ldflags != "" {
+ args = append(args, "-ldflags", ldflags)
+ }
+ cmd := exec.Command("go", append(args, ".")...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+goarch, "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false", "GOWORK=off", "GOPROXY=off")
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatal(string(output), err)
+ }
+ b, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// ArtifactBuild reads the -X version.Build flag a real linux-arm64 build
+// records, for a program that has the version.Build variable it stamps. It
+// reads the recorded flag; it does not establish the linked value
+// (controller ruling F2: owed before production cohorts, trusted build
+// attestation tied to the artefact digest).
+func TestArtifactBuildReadsTheRecordedStampFlag(t *testing.T) {
+ if testing.Short() {
+ t.Skip("builds Go binaries")
}
for ldflags, want := range map[string]string{
- "-s -w -X " + BuildStampFlag + "=v0.28.2": "v0.28.2",
- "-X " + BuildStampFlag + "=26065378": "26065378",
- "": "",
- "-s -w": "",
- "-X " + BuildStampFlag + "=v1 -X " + BuildStampFlag + "=v2": "",
- "-X=" + BuildStampFlag + "=v0.28.2": "",
- "-X github.com/carlosframework/platform/internal/version.BuildX=v0.28.2": "",
- "-X github.com/carlosframework/platform/internal/other.Build=v0.28.2 -s -w": "",
+ "-s -w -X " + BuildStampFlag + "=v0.28.2": "v0.28.2",
+ "-s -w -X " + BuildStampFlag + "=26065378": "26065378",
+ // Unstripped: Go compresses its DWARF sections (SHF_COMPRESSED), which
+ // the bounded pre-check refuses. Release and console builds use -s -w.
+ "-X " + BuildStampFlag + "=v0.28.2": "",
+ "": "",
+ "-s -w": "",
+ "-s -w -X " + BuildStampFlag + "=v1 -X " + BuildStampFlag + "=v2": "",
+ "-s -w -X=" + BuildStampFlag + "=v0.28.2": "",
+ "-s -w -X github.com/carlosframework/platform/internal/version.BuildX=v0.28.2": "",
+ "-X github.com/carlosframework/platform/internal/other.Build=v0.28.2 -s -w": "",
} {
- if got := ArtifactBuild(build(ldflags)); got != want {
+ if got := ArtifactBuild(buildStampedProgram(t, "arm64", ldflags)); got != want {
t.Errorf("ldflags %q: stamp %q, want %q", ldflags, got, want)
}
}
+ // linux-amd64 is outside the bounded pre-check, so it has no stamp.
+ if got := ArtifactBuild(buildStampedProgram(t, "amd64", "-s -w -X "+BuildStampFlag+"=v0.28.2")); got != "" {
+ t.Fatal("stamp read from a non-arm64 binary", got)
+ }
if ArtifactBuild([]byte("not a binary")) != "" {
t.Fatal("stamp from non-binary")
}
}
+
+// elfMutate returns a copy of a real linux-arm64 build with change applied
+// to it, given its section and program header offsets.
+func elfMutate(b []byte, change func(b []byte, shoff, phoff uint64)) []byte {
+ c := append([]byte(nil), b...)
+ change(c, binary.LittleEndian.Uint64(c[40:]), binary.LittleEndian.Uint64(c[32:]))
+ return c
+}
+
+// elfShstrtabCompressed sets SHF_COMPRESSED on the section-name table of a
+// real linux-arm64 build: the shape whose decompression Go's ELF reader
+// performs during parsing, unbounded by the file size.
+func elfShstrtabCompressed(b []byte) []byte {
+ return elfMutate(b, func(c []byte, shoff, _ uint64) {
+ idx := uint64(binary.LittleEndian.Uint16(c[62:]))
+ h := c[shoff+idx*64:]
+ binary.LittleEndian.PutUint64(h[8:], binary.LittleEndian.Uint64(h[8:])|0x800)
+ })
+}
+
+// elfOversizedShnum claims 65000 section headers in a real build.
+func elfOversizedShnum(b []byte) []byte {
+ return elfMutate(b, func(c []byte, _, _ uint64) { binary.LittleEndian.PutUint16(c[60:], 65000) })
+}
+
+func TestCheckELFBoundsRefusesUnboundedMetadata(t *testing.T) {
+ if testing.Short() {
+ t.Skip("builds Go binaries")
+ }
+ real := buildStampedProgram(t, "arm64", "-s -w -X "+BuildStampFlag+"=v0.28.2")
+ if err := CheckELFBounds(real); err != nil {
+ t.Fatal("a real stripped linux-arm64 build refused", err)
+ }
+ le := binary.LittleEndian
+ for name, crafted := range map[string][]byte{
+ "compressed section-name table": elfShstrtabCompressed(real),
+ "oversized shnum": elfOversizedShnum(real),
+ "compressed data section": elfMutate(real, func(c []byte, shoff, _ uint64) {
+ h := c[shoff+3*64:]
+ le.PutUint64(h[8:], le.Uint64(h[8:])|0x800)
+ }),
+ "extended numbering": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[60:], 0) }),
+ "shstrndx extended": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], 0xffff) }),
+ "shstrndx past table": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[62:], le.Uint16(c[60:])) }),
+ "oversized phnum": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[56:], 65000) }),
+ "section table outside": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], uint64(len(c))) }),
+ "section table wraps": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[40:], ^uint64(0)-10) }),
+ "tables overlap": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[40:], phoff) }),
+ "table over the header": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint64(c[32:], 0) }),
+ "wrong header entry size": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[58:], 40) }),
+ "section past the end": elfMutate(real, func(c []byte, shoff, _ uint64) {
+ h := c[shoff+1*64:]
+ le.PutUint32(h[4:], 1)
+ le.PutUint64(h[32:], uint64(len(c))+1)
+ }),
+ "segment past the end": elfMutate(real, func(c []byte, _, phoff uint64) { le.PutUint64(c[phoff+32:], uint64(len(c))+1) }),
+ "overlapping sections": elfMutate(real, func(c []byte, shoff, _ uint64) {
+ a, b := c[shoff+1*64:], c[shoff+2*64:]
+ le.PutUint32(a[4:], 1)
+ le.PutUint32(b[4:], 1)
+ le.PutUint64(b[24:], le.Uint64(a[24:]))
+ if le.Uint64(a[32:]) == 0 || le.Uint64(b[32:]) == 0 {
+ le.PutUint64(a[32:], 16)
+ le.PutUint64(b[32:], 16)
+ }
+ }),
+ "x86-64": elfMutate(real, func(c []byte, _, _ uint64) { le.PutUint16(c[18:], 62) }),
+ "32-bit class": elfMutate(real, func(c []byte, _, _ uint64) { c[4] = 1 }),
+ "big endian": elfMutate(real, func(c []byte, _, _ uint64) { c[5] = 2 }),
+ "not ELF": append([]byte("MZ"), real[2:]...),
+ "truncated": real[:63],
+ "empty": nil,
+ } {
+ if err := CheckELFBounds(crafted); !errors.Is(err, ErrELFBounds) {
+ t.Errorf("%s: %v", name, err)
+ }
+ if ArtifactBuild(crafted) != "" {
+ t.Errorf("%s: stamp read", name)
+ }
+ }
+}
diff --git a/internal/release/elfbounds.go b/internal/release/elfbounds.go
new file mode 100644
index 000000000..9c5648279
--- /dev/null
+++ b/internal/release/elfbounds.go
@@ -0,0 +1,96 @@
+package release
+
+import (
+ "encoding/binary"
+ "errors"
+ "sort"
+)
+
+// ErrELFBounds is every way CheckELFBounds refuses an artefact.
+var ErrELFBounds = errors.New("release: artefact is not a bounded linux-arm64 ELF")
+
+// ELF limits for CheckELFBounds. A Go linux-arm64 binary has a few dozen
+// sections and about ten program headers; these leave ample room and bound
+// the metadata any later parser can be made to walk.
+const (
+ elfMaxSections = 256
+ elfMaxPrograms = 64
+ elfHeaderSize = 64
+ elfPhentSize = 56
+ elfShentSize = 64
+ elfMachAArch64 = 183
+ elfNoBits = 8 // SHT_NOBITS: no file contents
+ elfCompressed = 0x800 // SHF_COMPRESSED
+ elfXIndex = 0xffff
+)
+
+// CheckELFBounds is the bounded pre-check run before debug/buildinfo parses
+// an artefact in a root process (controller ruling F1). It reads only the
+// fixed-size headers, by offset, with every count and offset checked
+// against len(b), and accepts only:
+// - ELF64, little-endian, version 1, EM_AARCH64;
+// - a program header table of at most 64 entries of 56 bytes, and a
+// section header table of 1 to 256 entries of 64 bytes, each inside the
+// file, not overlapping each other or the ELF header, with no extended
+// numbering (e_shnum 0, e_shstrndx 0xffff);
+// - every segment's file range inside the file;
+// - every section's file range inside the file, no section flagged
+// SHF_COMPRESSED (whose decompression is unbounded by the file size),
+// and no two sections with contents overlapping.
+//
+// With no compressed section and every range inside the bounded file, the
+// general ELF reader's allocations are bounded by the artefact's size.
+func CheckELFBounds(b []byte) error {
+ n := uint64(len(b))
+ if n < elfHeaderSize || string(b[:4]) != "\x7fELF" || b[4] != 2 || b[5] != 1 || b[6] != 1 {
+ return ErrELFBounds
+ }
+ le := binary.LittleEndian
+ if le.Uint16(b[18:]) != elfMachAArch64 || le.Uint32(b[20:]) != 1 || le.Uint16(b[52:]) != elfHeaderSize {
+ return ErrELFBounds
+ }
+ phoff, shoff := le.Uint64(b[32:]), le.Uint64(b[40:])
+ phentsize, phnum := uint64(le.Uint16(b[54:])), uint64(le.Uint16(b[56:]))
+ shentsize, shnum := uint64(le.Uint16(b[58:])), uint64(le.Uint16(b[60:]))
+ shstrndx := uint64(le.Uint16(b[62:]))
+ if phnum > elfMaxPrograms || phnum > 0 && phentsize != elfPhentSize || shnum == 0 || shnum > elfMaxSections || shentsize != elfShentSize || shstrndx == elfXIndex || shstrndx >= shnum {
+ return ErrELFBounds
+ }
+ within := func(off, size uint64) bool { return off <= n && size <= n-off }
+ phEnd, shEnd := phoff+phnum*elfPhentSize, shoff+shnum*elfShentSize
+ if phnum > 0 && (!within(phoff, phnum*elfPhentSize) || phoff < elfHeaderSize) {
+ return ErrELFBounds
+ }
+ if !within(shoff, shnum*elfShentSize) || shoff < elfHeaderSize || phnum > 0 && shoff < phEnd && phoff < shEnd {
+ return ErrELFBounds
+ }
+ for i := uint64(0); i < phnum; i++ {
+ h := b[phoff+i*elfPhentSize:]
+ if !within(le.Uint64(h[8:]), le.Uint64(h[32:])) { // p_offset, p_filesz
+ return ErrELFBounds
+ }
+ }
+ type span struct{ off, end uint64 }
+ var spans []span
+ for i := uint64(0); i < shnum; i++ {
+ h := b[shoff+i*elfShentSize:]
+ typ, flags, off, size := le.Uint32(h[4:]), le.Uint64(h[8:]), le.Uint64(h[24:]), le.Uint64(h[32:])
+ if flags&elfCompressed != 0 {
+ return ErrELFBounds
+ }
+ if typ == elfNoBits || size == 0 {
+ continue
+ }
+ if !within(off, size) {
+ return ErrELFBounds
+ }
+ spans = append(spans, span{off, off + size})
+ }
+ sort.Slice(spans, func(i, j int) bool { return spans[i].off < spans[j].off })
+ for i := 1; i < len(spans); i++ {
+ if spans[i].off < spans[i-1].end {
+ return ErrELFBounds
+ }
+ }
+ return nil
+}
diff --git a/internal/release/program.go b/internal/release/program.go
index 251f23f3f..3a0abc570 100644
--- a/internal/release/program.go
+++ b/internal/release/program.go
@@ -36,12 +36,24 @@ func ArtifactProgram(b []byte) string {
// (internal/version.Build). Its value is a release build's tag.
const BuildStampFlag = "github.com/carlosframework/platform/internal/version.Build"
-// ArtifactBuild is the version.Build stamp a Go binary was linked with: the
-// value of the one "-X <BuildStampFlag>=<v>" in the -ldflags its build info
-// records. It is "" when the bytes have no build info, the flag is absent,
-// given more than once, or written in any other form, so a caller that
-// needs a stamp refuses all of those alike.
+// ArtifactBuild is the version.Build stamp a linux-arm64 Go binary's build
+// info records: the value of the one "-X <BuildStampFlag>=<v>" in its
+// recorded -ldflags. It is "" when the bytes fail CheckELFBounds, have no
+// build info, or record the flag absent, more than once or in any other
+// form, so a caller that needs a stamp refuses all of those alike.
+//
+// It reads the RECORDED flag, not the linked value of version.Build: a
+// release binary is stripped (-s -w), so there is no symbol table to read
+// the variable from, and a build's recorded flags could be edited without
+// changing its linked value. Its caller (claims publish-identity) relies on
+// the release key's authentication of the artefact: the signed pointer,
+// manifest digest and sha256 only the operator can produce. Owed before
+// production cohorts: trusted build attestation tied to the artefact digest
+// (controller ruling F2, accepted as a trial gap).
func ArtifactBuild(b []byte) string {
+ if CheckELFBounds(b) != nil {
+ return ""
+ }
bi, err := buildinfo.Read(bytes.NewReader(b))
if err != nil {
return ""