WIP claim Plan 14 track E checkpoint 19 (not for merge): E2 reservations connected to restaging and the staged-version sweep
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+611
−11
internal/host/agent.go+3 −2internal/host/plan14_generation_consumers_test.go+161 −0internal/host/rootgenerationpins.go+48 −0internal/host/versionsweep.go+22 −3internal/provision/testdata/plan14-root-reads-e.tsv+17 −4internal/release/adopt.go+10 −2internal/release/plan14_generation_pins_test.go+212 −0internal/release/rootgenerationpins.go+138 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 348.500s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.086s
ok github.com/carlosframework/platform/cmd/moveprobe 92.294s
ok github.com/carlosframework/platform/cmd/zddprobe 0.713s
ok github.com/carlosframework/platform/internal/activator 233.317s
ok github.com/carlosframework/platform/internal/agentsock 0.032s
ok github.com/carlosframework/platform/internal/alerting 0.013s
ok github.com/carlosframework/platform/internal/analytics 0.026s
ok github.com/carlosframework/platform/internal/appconfig 2.312s
ok github.com/carlosframework/platform/internal/apperr 0.081s
ok github.com/carlosframework/platform/internal/applog 0.135s
ok github.com/carlosframework/platform/internal/awsinv 0.011s
ok github.com/carlosframework/platform/internal/boxchoice 0.010s
ok github.com/carlosframework/platform/internal/boxid 0.874s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.013s
ok github.com/carlosframework/platform/internal/caps 0.014s
ok github.com/carlosframework/platform/internal/certwarm 0.006s
ok github.com/carlosframework/platform/internal/cgroup 0.701s
ok github.com/carlosframework/platform/internal/changelog 0.307s
ok github.com/carlosframework/platform/internal/clamd 0.062s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 367.840s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.307s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.098s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.936s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.121s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.153s
ok github.com/carlosframework/platform/internal/host 130.892s
ok github.com/carlosframework/platform/internal/hostactors 0.327s
ok github.com/carlosframework/platform/internal/hostbox 2.096s
ok github.com/carlosframework/platform/internal/hostclaim 0.488s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.864s
ok github.com/carlosframework/platform/internal/hostfence 0.031s
ok github.com/carlosframework/platform/internal/hostfiles 10.565s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 1.010s
ok github.com/carlosframework/platform/internal/hostops 1.408s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.016s
ok github.com/carlosframework/platform/internal/hostrelocate 0.451s
ok github.com/carlosframework/platform/internal/hostresolve 0.150s
ok github.com/carlosframework/platform/internal/hostwrite 0.016s
ok github.com/carlosframework/platform/internal/infraview 0.015s
ok github.com/carlosframework/platform/internal/instancectl 1.813s
ok github.com/carlosframework/platform/internal/instanceexec 2.742s
ok github.com/carlosframework/platform/internal/instancefs 0.021s
ok github.com/carlosframework/platform/internal/instancekey 0.021s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.415s
ok github.com/carlosframework/platform/internal/lease 9.444s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.021s
ok github.com/carlosframework/platform/internal/ledger 0.010s
ok github.com/carlosframework/platform/internal/mail 0.166s
ok github.com/carlosframework/platform/internal/maintenance 0.080s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.537s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.067s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.027s
ok github.com/carlosframework/platform/internal/moves/archive 0.751s
ok github.com/carlosframework/platform/internal/objstore 0.158s
ok github.com/carlosframework/platform/internal/ops 0.157s
ok github.com/carlosframework/platform/internal/park 0.189s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 13.421s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.107s
ok github.com/carlosframework/platform/internal/relay 7.217s
ok github.com/carlosframework/platform/internal/release 20.633s
ok github.com/carlosframework/platform/internal/rollout 3.767s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.009s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.003s
ok github.com/carlosframework/platform/internal/schedule 2.306s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.450s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.846s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.157s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.003s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.533s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 06:28:40 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE5182154F2498, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.021s
minio contract: PASS
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 6ba845156..52ce55f5b 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -4373,8 +4373,9 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| // minute per loop, and walking the version store that often to free a |
| // day's worth of shipping is work for no gain. -config-only and |
| // -restart-only are narrow triggers that must do their one job. |
| + generationPins := productionRootGenerationPins() |
| if passLockErr == nil && !*stageOnly && !*configOnly && !*restartOnly { |
| - sweepStagedVersionsPass(reg) |
| + sweepStagedVersionsPass(reg, generationPins) |
| } |
| ctx := context.Background() |
| @@ -4538,7 +4539,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni |
| linkRootHistory(history, restartChecks, release.OptDir(), os.Getenv) |
| } |
| recovery := productionNamedUnitRecovery(store, pub, release.OptDir(), claims.Writes) |
| - passOpts := []release.PassOption{release.WithRootHistory(history), recovery.passOption(reg, restartChecks, os.Getenv), release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartAttemptSettled(restartChecks.finish), |
| + passOpts := []release.PassOption{release.WithRootGenerationPins(generationPins), release.WithRootHistory(history), recovery.passOption(reg, restartChecks, os.Getenv), release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartAttemptSettled(restartChecks.finish), |
| release.WithRunningVersion(restartChecks.runningVersion), |
| release.WithSelectedRestarts( |
| rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false), |
diff --git a/internal/host/plan14_generation_consumers_test.go b/internal/host/plan14_generation_consumers_test.go| new file mode 100644 |
| index 000000000..ab490fb85 |
| --- /dev/null |
| +++ b/internal/host/plan14_generation_consumers_test.go |
| @@ -0,0 +1,161 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func TestPlan14GenerationDeletionCannotUnpin(t *testing.T) { |
| + for _, attack := range []string{"delete", "empty", "rename", "missing-completeness", "deleted-executable", "renamed-parent", "registry-deleted", "wrong-root", "sync-failure"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, disk, _ := generationLaunchFixture(t) |
| + permit, err := f.root.generations.reserve(t.Context(), f.root, f.peer, f.req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b := permit.reservation.Binding |
| + opt := f.root.generations.optDir |
| + stageSweepVersion(t, opt, "bap", "v2", "other", 48*time.Hour) |
| + stageSweepVersion(t, opt, "bap", "v3", "pending", 48*time.Hour) |
| + pending := b |
| + pending.Attempt, pending.Invocation = "pending-v3", "pending-v3" |
| + pending.Binding.Account, pending.Binding.App, pending.Version = "bap", "pending", "v3" |
| + pending.Path = release.VersionPath(opt, "bap", "v3", "pending") |
| + pending.Tree = filepath.Dir(pending.Path) |
| + for path, dst := range map[string]*rootstate.FileIdentity{pending.Path: &pending.Executable, pending.Tree: &pending.TreeIdentity} { |
| + file, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + *dst, err = rootstate.Identity(file) |
| + file.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := permit.ledger.Reserve(pending); err != nil { |
| + t.Fatal(err) |
| + } |
| + when := time.Now().Add(-48 * time.Hour) |
| + if err := os.Chtimes(b.Path, when, when); err != nil { |
| + t.Fatal(err) |
| + } |
| + opened, err := os.Open(b.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer opened.Close() |
| + before, err := opened.Stat() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := t.TempDir() |
| + path := release.GenerationsPath(dir) |
| + if err := release.WriteGenerations(path, release.Generations{Paths: map[string][]string{b.Binding.Host: {b.Path}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + source := generationPinSource{open: func() (rootstate.Store, error) { return disk, nil }, verify: func(context.Context, []release.GenerationReservation) error { return nil }} |
| + switch attack { |
| + case "empty": |
| + if err := release.WriteGenerations(path, release.Generations{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + default: |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(release.GenerationsSeqPath(path)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + switch attack { |
| + case "missing-completeness": |
| + source.verify = nil |
| + case "deleted-executable": |
| + if err := os.Remove(b.Path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "renamed-parent": |
| + if err := os.Rename(b.Tree, b.Tree+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "wrong-root": |
| + opt = t.TempDir() |
| + stageSweepVersion(t, opt, "bab", "v1", "shop", 48*time.Hour) |
| + case "sync-failure": |
| + disk.fail = 1 |
| + } |
| + // No registry row or live link carries the root reservation. |
| + sweepStagedVersions(fakeLister{}, opt, path, 6*time.Hour, time.Now(), source) |
| + if attack == "renamed-parent" { |
| + if _, err := os.Stat(b.Tree + ".old"); err != nil { |
| + t.Fatalf("renamed reserved tree removed: %v", err) |
| + } |
| + } else if !staged(t, opt, "v1") { |
| + t.Fatal("sweep removed reserved version tree") |
| + } |
| + if attack != "wrong-root" && !staged(t, opt, "v3") { |
| + t.Fatal("pending V3 lost its independent pin") |
| + } |
| + after, err := opened.Stat() |
| + if err != nil || !os.SameFile(before, after) { |
| + t.Fatalf("held inode changed: %v", err) |
| + } |
| + if attack == "delete" || attack == "empty" || attack == "rename" || attack == "registry-deleted" { |
| + if staged(t, opt, "v2") { |
| + t.Fatal("complete fixture could not sweep unrelated version") |
| + } |
| + } else if attack != "wrong-root" && !staged(t, opt, "v2") { |
| + t.Fatal("incomplete snapshot permitted any deletion") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationConsumerMode(t *testing.T) { |
| + for _, legacy := range []bool{true, false} { |
| + calls := 0 |
| + source := generationPinSource{legacy: func() (bool, error) { return legacy, nil }, open: func() (rootstate.Store, error) { calls++; return nil, os.ErrPermission }} |
| + _, err := source.StagedPins(t.Context(), t.TempDir()) |
| + if legacy && (err != nil || calls != 0) { |
| + t.Fatalf("legacy read root generations: %v calls %d", err, calls) |
| + } |
| + if !legacy && (err == nil || calls != 1) { |
| + t.Fatalf("protected fallback: %v calls %d", err, calls) |
| + } |
| + } |
| + source := generationPinSource{legacy: func() (bool, error) { return true, os.ErrPermission }} |
| + if _, err := source.StagedPins(t.Context(), t.TempDir()); err == nil { |
| + t.Fatal("census error chose legacy") |
| + } |
| +} |
| + |
| +func TestPlan14GenerationProductionModeGate(t *testing.T) { |
| + for _, flag := range []string{"", "1"} { |
| + t.Run(flag, func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + source := productionRootGenerationPins().(generationPinSource) |
| + if source.legacy != nil { |
| + source.legacy = func() (bool, error) { return true, nil } |
| + } |
| + calls := 0 |
| + source.open = func() (rootstate.Store, error) { calls++; return nil, os.ErrPermission } |
| + opt := t.TempDir() |
| + stageSweepVersion(t, opt, "bab", "v1", "shop", 48*time.Hour) |
| + sweepStagedVersions(fakeLister{}, opt, noGenerations(t), 6*time.Hour, time.Now(), source) |
| + if calls != 0 || staged(t, opt, "v1") { |
| + t.Fatalf("no-census sweep changed: opens=%d survives=%v", calls, staged(t, opt, "v1")) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/rootgenerationpins.go b/internal/host/rootgenerationpins.go| new file mode 100644 |
| index 000000000..5dd3d58e2 |
| --- /dev/null |
| +++ b/internal/host/rootgenerationpins.go |
| @@ -0,0 +1,48 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type generationPinSource struct { |
| + legacy func() (bool, error) |
| + open func() (rootstate.Store, error) |
| + verify func(context.Context, []release.GenerationReservation) error |
| +} |
| + |
| +func productionRootGenerationPins() release.PinSource { |
| + s := generationPinSource{legacy: legacyRootRestart, open: func() (rootstate.Store, error) { |
| + return rootstate.Open(release.RootGenerationsDir, false, false) |
| + }} |
| + // Until root reconciles installation, launch and kernel inventory, Verify |
| + // stays absent and destructive writes on census boxes remain unavailable. |
| + return s |
| +} |
| + |
| +func (s generationPinSource) StagedPins(ctx context.Context, optDir string) (release.StagedPaths, error) { |
| + if s.legacy != nil { |
| + legacy, err := s.legacy() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if legacy { |
| + return release.StagedPaths{}, nil |
| + } |
| + } |
| + if s.open == nil { |
| + return nil, os.ErrPermission |
| + } |
| + store, err := s.open() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if store == nil { |
| + return nil, os.ErrPermission |
| + } |
| + defer store.Close() |
| + return (release.RootGenerationPins{Ledger: &release.RootGenerations{Store: store}, Verify: s.verify}).StagedPins(ctx, optDir) |
| +} |
diff --git a/internal/host/versionsweep.go b/internal/host/versionsweep.go| index a64771128..9dd3eeedf 100644 |
| --- a/internal/host/versionsweep.go |
| +++ b/internal/host/versionsweep.go |
| @@ -1,8 +1,11 @@ |
| package host |
| import ( |
| + "context" |
| "log" |
| "os" |
| + "path/filepath" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -46,7 +49,7 @@ type routeLister interface { |
| // returns: an unreadable live dir, an unlistable registry or an incomplete |
| // generations file means the pin set is incomplete, and an incomplete pin set is how a running app loses its |
| // binary. Freeing disk an hour later is cheap; deleting a live version is not. |
| -func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Duration, now time.Time) { |
| +func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Duration, now time.Time, sources ...release.PinSource) { |
| pinned, err := release.LivePins(optDir) |
| if err != nil { |
| log.Printf("adopt: staged-version sweep skipped: %v", err) |
| @@ -99,6 +102,22 @@ func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Dur |
| for v := range gens { |
| pinned[v] = true |
| } |
| + for _, source := range sources { |
| + paths, err := source.StagedPins(context.Background(), optDir) |
| + if err != nil { |
| + log.Printf("adopt: staged-version sweep skipped: generations: %v", err) |
| + return |
| + } |
| + for path := range paths { |
| + rel, err := filepath.Rel(filepath.Join(optDir, "versions"), path) |
| + version, _, _ := strings.Cut(rel, string(filepath.Separator)) |
| + if err != nil || !filepath.IsLocal(rel) || !release.ValidVersion(version) { |
| + log.Printf("adopt: staged-version sweep skipped: generations: %v", os.ErrPermission) |
| + return |
| + } |
| + pinned[version] = true |
| + } |
| + } |
| res, err := release.SweepStaged(optDir, pinned, grace, now) |
| if err != nil { |
| log.Printf("adopt: staged-version sweep: %v", err) |
| @@ -121,8 +140,8 @@ func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Dur |
| // sweepStagedVersionsPass is the RunAdopt entry point: the grace from the |
| // environment, the clock from the wall. |
| -func sweepStagedVersionsPass(reg routeLister) { |
| - sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now()) |
| +func sweepStagedVersionsPass(reg routeLister, sources ...release.PinSource) { |
| + sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now(), sources...) |
| } |
| // settingsPins is every build the rows' update settings and build-state |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index b49cfde76..068f646fb 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -1,5 +1,5 @@ |
| # Track E integration after merge3. Earlier baselines, overlays and legacy fingerprints are immutable. |
| -internal/host/agent.go runAdoptUnderRootRelease authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. whole:Route@10700,whole:Route@18080,whole:Route@18116,whole:Route@8002 9fc9698cee7c2b88e3d38203a17e7a41512e800fa172ce5b19272e75c2c83ca5 |
| +internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode. whole:Route@10766,whole:Route@18194,whole:Route@18230,whole:Route@8068 71b09c1fdcab8e9dbb9c6f255cfa72acbf8a17f4ab2d959d8d2b7d7d0afe3ca9 |
| internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 E1 selects the checked private history, canonical named-unit provider and root mint inventory under release exclusion with claims off or on; reconstruction and restart consumption remain incomplete. file:Open@191 9cdd0a958f5924c9c8d8c5613fd665f2c96a194cd11322adb6f0050fb183b541 |
| internal/host/namedrecovery.go namedUnitRecovery.repair authority E1,J1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@2642,registry:Channel@1814,whole:Route@1060,whole:Route@1822 566bc61eafb118d9f6fba29228c372b18c8729549ed9a0bccf32c61b360eb9c3 |
| internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 E13: signed recovery allows unknown history but preserves usable refusals, including retained originals; verified later promotion clears only D1. Health recording is independent of program recording; minting requires a census. Fixtures: TestPlan14RecoveryUnknownHistory, TestPlan14RecoveryKnownRefusal, TestPlan14RecoveryOriginalRefusalWithUnknownHistory, TestPlan14RootHistoryVerifiedRetryBeforeAttempt, TestPlan14ProgramFailureStillRecordsHealth, TestPlan14RootHistoryMintCensus. file:Open@5504,registry:Channel@2558,registry:Unit@216,registry:Unit@233,registry:Version@3618,registry:Version@3724,whole:Route@1478,whole:Route@202,whole:Route@2560,whole:Route@3544 dabec20a8ea93c7d72b6d6c3a30a1ae7c99ffcee4de4c0416fba699734739b92 |
| @@ -27,7 +27,7 @@ internal/host/rootrollback.go loadRollback authority B3,J1,E1 Signed named-unit |
| internal/host/rootrollback.go settleRollbackAttempt authority B3,E1 Independent signed recovery authority and retained attempt outcomes. Fixtures: TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14ActiveRecoveryProcessCrash. 0a3e03771e9205e546e1b91371f8b17afba93525a5009afaf15736ca1c7bfa80 |
| internal/host/rootrollback_resume.go recoverRootRestarts authority B3 Recover every attempt independently, skip settled writes and remove leftover capabilities. file:Read@819 0eb3221e553183869f907660c74bae544d48da57f1e5592b92400377f5769a0e |
| internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@1419,registry:Account@2125,registry:App@1447,registry:App@2153,registry:Unit@1225,whole:Route@1227,whole:Route@1421,whole:Route@1449,whole:Route@2127,whole:Route@2155 f69e185f75b5758b439be86809c2a83dd300e2e1235879c4860b70cfb5cb39ba |
| -internal/release/adopt.go PassFilter authority B3,E3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. E7 applies merge ruling 4: unbound no-census routes use the legacy pass and restart; ordinary candidates make no binding store call. Fixtures: TestR2UnguardedERecoveryUpdate, TestR2UnguardedERecoveryIncidentRecipe, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix. registry:App@26180,registry:App@28637,registry:App@29129,registry:App@29366,registry:App@30773,registry:App@5975,registry:App@9000,registry:App@9141,registry:Hibernate@29012,registry:Host@1055,registry:Host@2746,registry:Host@2813,registry:Host@2909,registry:Host@29182,registry:Host@2944,registry:Host@30098,registry:Host@30826,registry:Host@690,registry:UpdateSettings@8878,whole:Route@1090,whole:Route@1251,whole:Route@2360,whole:Route@2384,whole:Route@2415,whole:Route@2472,whole:Route@2558,whole:Route@2580,whole:Route@26175,whole:Route@2680,whole:Route@28632,whole:Route@28890,whole:Route@28912,whole:Route@28959,whole:Route@28977,whole:Route@28987,whole:Route@29030,whole:Route@29051,whole:Route@29064,whole:Route@29124,whole:Route@29150,whole:Route@29224,whole:Route@29263,whole:Route@29279,whole:Route@29307,whole:Route@29361,whole:Route@29387,whole:Route@30140,whole:Route@30194,whole:Route@30210,whole:Route@30717,whole:Route@30768,whole:Route@30794,whole:Route@30868,whole:Route@30903,whole:Route@30919,whole:Route@3665,whole:Route@4312,whole:Route@579,whole:Route@5897,whole:Route@5919,whole:Route@5970,whole:Route@5996,whole:Route@617,whole:Route@675,whole:Route@793,whole:Route@817,whole:Route@8829,whole:Route@8943,whole:Route@8988,whole:Route@9136 49427f5f398cf7df2b0ac2f63bfacfee3d3c630a253b176493ad87e0f7a49f00 |
| +internal/release/adopt.go PassFilter authority B3,E3,J1,E1,E2 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. E7 applies merge ruling 4: unbound no-census routes use the legacy pass and restart; ordinary candidates make no binding store call. Fixtures: TestR2UnguardedERecoveryUpdate, TestR2UnguardedERecoveryIncidentRecipe, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix. E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode. registry:App@26278,registry:App@28735,registry:App@29227,registry:App@29464,registry:App@30871,registry:App@6073,registry:App@9098,registry:App@9239,registry:Hibernate@29110,registry:Host@1055,registry:Host@2746,registry:Host@2813,registry:Host@2909,registry:Host@29280,registry:Host@2944,registry:Host@30196,registry:Host@30924,registry:Host@690,registry:UpdateSettings@8976,whole:Route@1090,whole:Route@1251,whole:Route@2360,whole:Route@2384,whole:Route@2415,whole:Route@2472,whole:Route@2558,whole:Route@2580,whole:Route@26273,whole:Route@2680,whole:Route@28730,whole:Route@28988,whole:Route@29010,whole:Route@29057,whole:Route@29075,whole:Route@29085,whole:Route@29128,whole:Route@29149,whole:Route@29162,whole:Route@29222,whole:Route@29248,whole:Route@29322,whole:Route@29361,whole:Route@29377,whole:Route@29405,whole:Route@29459,whole:Route@29485,whole:Route@30238,whole:Route@30292,whole:Route@30308,whole:Route@30815,whole:Route@30866,whole:Route@30892,whole:Route@30966,whole:Route@31001,whole:Route@31017,whole:Route@3763,whole:Route@4410,whole:Route@579,whole:Route@5995,whole:Route@6017,whole:Route@6068,whole:Route@6094,whole:Route@617,whole:Route@675,whole:Route@793,whole:Route@817,whole:Route@8927,whole:Route@9041,whole:Route@9086,whole:Route@9234 fd45e6991e6b3d6695b4b7880e7202f83e87024c04df3d5382d5fd04600ad02d |
| internal/release/adopt.go adoptHost authority B3,E1,E2,E3,E4,J1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. registry:App@10895,registry:App@11055,registry:App@12084,registry:App@13010,registry:App@14592,registry:App@19751,registry:App@25227,registry:App@3114,registry:App@32986,registry:App@6717,registry:App@8018,registry:App@8212,registry:Hibernate@33847,registry:Host@10775,registry:Host@10808,registry:Host@10908,registry:Host@11068,registry:Host@11170,registry:Host@11202,registry:Host@11267,registry:Host@11717,registry:Host@11850,registry:Host@12297,registry:Host@12406,registry:Host@12608,registry:Host@13100,registry:Host@13886,registry:Host@14364,registry:Host@14578,registry:Host@14920,registry:Host@15510,registry:Host@19241,registry:Host@19349,registry:Host@19719,registry:Host@20539,registry:Host@20697,registry:Host@2075,registry:Host@21159,registry:Host@21211,registry:Host@21358,registry:Host@21445,registry:Host@22551,registry:Host@2264,registry:Host@22795,registry:Host@2345,registry:Host@23455,registry:Host@23840,registry:Host@23981,registry:Host@25195,registry:Host@25322,registry:Host@26664,registry:Host@27314,registry:Host@30547,registry:Host@31165,registry:Host@3127,registry:Host@31490,registry:Host@31564,registry:Host@31811,registry:Host@31962,registry:Host@33158,registry:Host@33325,registry:Host@33432,registry:Host@33660,registry:Host@33803,registry:Host@34548,registry:Host@34674,registry:Host@34762,registry:Host@4428,registry:Host@4542,registry:Host@5505,registry:Host@5610,registry:Host@6829,registry:Host@6877,registry:Host@6994,registry:Host@7026,registry:Host@7904,registry:Host@8072,registry:Host@8198,registry:Host@8317,registry:Host@8795,registry:Host@9182,registry:Host@9408,registry:Host@9504,registry:Host@9595,registry:Kind@552,registry:Unit@31710,registry:Unit@577,whole:Route@10032,whole:Route@10890,whole:Route@11050,whole:Route@12074,whole:Route@13000,whole:Route@14560,whole:Route@15125,whole:Route@15486,whole:Route@16709,whole:Route@17012,whole:Route@17168,whole:Route@19498,whole:Route@19642,whole:Route@19741,whole:Route@2176,whole:Route@22280,whole:Route@22679,whole:Route@25217,whole:Route@30118,whole:Route@3109,whole:Route@33134,whole:Route@3554,whole:Route@4306,whole:Route@6712 5a5aba93df8af6fa4cda8fc5db5fc7ef9c0ef9f05fd400555eb68b5b06679dd7 |
| internal/release/rollback.go writeGroupRefusals authority E1 E1 group refusal writer persists root denial before its diagnostic projection; pending attempt remains restrictive. registry:App@801,whole:Route@783,whole:Route@882,whole:Route@888,whole:Route@952 301386c9d57f9f13db3c31a6cf3a26a44c7ca53ff8d98d910c10a7e8530b8795 |
| internal/release/rootbake.go RootHistory.LoadBake authority E1 E1 strict root bake and app completeness revisions; backward clocks refuse and visible records are durably recommitted. file:Read@261 bd188ea6f2e76d277e1274043bf0f2a447391b1dc540aa400b36ad2573ccd84e |
| @@ -54,8 +54,8 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S |
| internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a |
| internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab |
| internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7 |
| -@coverage internal/host aa5107fd5f62f16db48f9f788c22c2df3279b5ba0740b8d3978bd1593ff42f92 |
| -@coverage internal/release b3659b929285d525c77f4e298fc1eb588d696b37f16c5dd3a77e231012bc29ce |
| +@coverage internal/host 255d2b64782c34c73f12965a1738eefcef8387ea769670d475efcd802821905e |
| +@coverage internal/release 07038b66266e63a9be3ce85b954d4955223ce280e1c2e3f93ae508ae8ffeb1dc |
| @coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05 |
| internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154 |
| internal/host/legacyrestart.go restartEffectFailed authority B3 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. b68bb65884a14fe0cf78a65f56ded4ea41584bba99af9195827d1ea191b340a9 |
| @@ -211,3 +211,16 @@ internal/release/rootgenerations.go type GenerationProcess authority E2 E18: cla |
| internal/release/rootgenerations.go type GenerationReservation authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 4d1b20a5f99117b6bc2e16c6a60033496f99796c47e682ab19bbddbdb6cc35e4 |
| internal/release/rootgenerations.go type RootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 240860014efc91f51ca7e3e920ffb5cfa98076340dc0c9e9972c35051e5eca84 |
| internal/release/rootgenerations.go validGeneration authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 7fa482be0515f5b94acd6fe5e3072b2a45e89c043961e30ab764d0f719b9a7cd |
| +internal/host/rootgenerationpins.go generationPinSource.StagedPins authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. 8484f0fc50fc55644ec8af81a3d89b6338cdbfb125ba40cd5cedf63a2b41a852 |
| +internal/host/rootgenerationpins.go productionRootGenerationPins authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. file:Open@158 fa0ccb940d808f9443e19aa4837cd36e9278add4210ee4b021b471b3bfb9dd04 |
| +internal/host/rootgenerationpins.go type generationPinSource authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. ccc17aa1e2436da6ae8518c3e2bce2d73a8b2a181d0de8bf33733ea49d12a974 |
| +internal/host/versionsweep.go sweepStagedVersions authority E2,J3,J4 root-route E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode. registry:GoodVersion@898,registry:GoodVersion@930,registry:PrevVersion@711,registry:PrevVersion@743,registry:Version@656,registry:Version@684,whole:Route@1231,whole:Route@435 e7fc68ed8c5ff2dc8a98daeb16c08f4d497b8016a3c459b6e4158721ba109843 |
| +internal/host/versionsweep.go sweepStagedVersionsPass authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. efeae77306262f47556aeccb382409933995f80c52f989e469e088e714e7d888 |
| +internal/release/adopt.go stageFrom authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. 2d40e6c3ad116e9ab3a97530f8d83404440c7095d5f2868044c2bd22f0190562 |
| +internal/release/adopt.go type passConfig authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. 1726cb5cee66d8aa0b04c44ad758b05a6aec239bb6a963ec2cfec13431ab765c |
| +internal/release/adopt.go type stageCheck authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. b031290fdcac7eb495da71ffbe395b151504078d237ff279fe5fc2143e6b0f34 |
| +internal/release/rootgenerationpins.go RootGenerationPins.StagedPins authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. file:Read@486,file:Read@842 3d76e660f2a5302f01d31f608b9d145e2b891794cde97eee586f9a30afb2d688 |
| +internal/release/rootgenerationpins.go WithRootGenerationPins authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. befc123585ff625901fd8b0516b2fa73b51968db21a28fa0d64c417382d54f79 |
| +internal/release/rootgenerationpins.go generationFileIdentity authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. file:Lstat@96,file:Open@222 20a8f6c7c1c20fc2007dd89f2244b7797faede8d43f2206cd534a8033183b6ff |
| +internal/release/rootgenerationpins.go passConfig.generationWriteCheck authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. file:Lstat@537 f32259e8d00c8a196c9326fdaec2de5727211bd61117a2e1620339f68791e6ee |
| +internal/release/rootgenerationpins.go type RootGenerationPins authority E2 E19: both destructive readers use the root reservation snapshot under existing release exclusion. Exact artifact and containing-tree identities are retained; missing completeness refuses destruction. Production installation/launch/kernel completeness and verified retirement remain unfinished. Fixtures: TestPlan14GenerationDeletionCannotUnpin, TestPlan14GenerationSnapshotCompleteness, TestPlan14GenerationSnapshotPermitsUnreservedRestage, TestPlan14GenerationSnapshotPinsContainingTree, TestPlan14GenerationSnapshotEmptyIsUnknown, TestPlan14GenerationConsumerMode, TestPlan14GenerationProductionModeGate, TestPlan14GenerationSnapshotPermitsFreshTree. c680202be840bd6e420cc0c9ae192f6f73d259498e564a1c4dbaf413e3e944a1 |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 5f167e8f0..06ce5a821 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -104,7 +104,8 @@ type passConfig struct { |
| // pins is WithPinSource's provider: staged copies that something beyond |
| // the registry rows, the live links and the generations file still |
| // refers to. nil adds nothing. |
| - pins PinSource |
| + pins PinSource |
| + generationPins PinSource |
| // stage is what ensureStagedChecked needs to judge a staged copy it is |
| // about to reuse (spec §4.2). PassFilter sets it once per pass. |
| @@ -546,7 +547,8 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // release, so a healthy pass never pays for it, and it reads fresh then, |
| // so a host this pass has already repointed counts. |
| cfg.stage = stageCheck{ |
| - verified: map[string]string{}, |
| + beforeWrite: func(path string) error { return cfg.generationWriteCheck(ctx, optDir, path) }, |
| + verified: map[string]string{}, |
| refs: func() (StagedPaths, error) { |
| rows, err := reg.List() |
| if err != nil { |
| @@ -2064,6 +2066,7 @@ func capabilityPlan(rt registry.Route, m *Manifest, canRestart bool) (caps.Plan, |
| // stageCheck is what ensureStagedChecked needs to judge a staged copy it is |
| // about to reuse (spec §4.2). |
| type stageCheck struct { |
| + beforeWrite func(string) error |
| // refs reports the staged copies something on this box still refers to. |
| // nil, or an error, means unknown, and unknown never permits a restage. |
| refs func() (StagedPaths, error) |
| @@ -2232,6 +2235,11 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| } |
| // Streamed to disk and hashed on the way (#269): a build read whole |
| // outgrew the stage pass's memory cap. |
| + if chk.beforeWrite != nil { |
| + if err := chk.beforeWrite(path); err != nil { |
| + return nil, err |
| + } |
| + } |
| err := stageArtifactFrom(optDir, acct, ver, app, art.SHA256, func() (io.ReadCloser, error) { |
| rc, _, err := OpenQualified(ctx, s, ArtifactKey(acct, app, ver, art.File)) |
| return rc, err |
diff --git a/internal/release/plan14_generation_pins_test.go b/internal/release/plan14_generation_pins_test.go| new file mode 100644 |
| index 000000000..85ee9c0e6 |
| --- /dev/null |
| +++ b/internal/release/plan14_generation_pins_test.go |
| @@ -0,0 +1,212 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type generationListingStore struct{ *healthyRetryStore } |
| + |
| +func (s generationListingStore) Names() ([]string, error) { |
| + entries, err := os.ReadDir(s.dir) |
| + var names []string |
| + for _, e := range entries { |
| + names = append(names, e.Name()) |
| + } |
| + return names, err |
| +} |
| + |
| +func generationPinFixture(t *testing.T, opt, account, app, version string) (*RootGenerations, GenerationReservation) { |
| + t.Helper() |
| + g, s, b := generationFixture(t) |
| + g.Store = generationListingStore{s} |
| + b.Binding.Account, b.Binding.App, b.Version = account, app, version |
| + b.Path = VersionPath(opt, account, version, app) |
| + b.Tree = filepath.Dir(b.Path) |
| + for path, dst := range map[string]*rootstate.FileIdentity{b.Path: &b.Executable, b.Tree: &b.TreeIdentity} { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + *dst, err = rootstate.Identity(f) |
| + f.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + r, err := g.Reserve(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return g, r |
| +} |
| + |
| +func TestPlan14GenerationDeletionCannotUnpin(t *testing.T) { |
| + for _, attack := range []string{"delete", "empty", "rename", "missing-completeness", "deleted-executable", "renamed-parent", "changed-inode"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f := newReuseFixture(t) |
| + g, r := generationPinFixture(t, f.opt, "bab", "myapp", "v2") |
| + dir := t.TempDir() |
| + path := GenerationsPath(dir) |
| + if err := WriteGenerations(path, Generations{Paths: map[string][]string{"erased.example": {r.Binding.Path}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := os.Stat(r.Binding.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opened, err := os.Open(r.Binding.Path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer opened.Close() |
| + source := RootGenerationPins{Ledger: g, Verify: func(context.Context, []GenerationReservation) error { return nil }} |
| + switch attack { |
| + case "empty": |
| + if err := WriteGenerations(path, Generations{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + default: |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(GenerationsSeqPath(path)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + switch attack { |
| + case "missing-completeness": |
| + source.Verify = nil |
| + case "deleted-executable": |
| + if err := os.Remove(r.Binding.Path); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "renamed-parent": |
| + if err := os.Rename(r.Binding.Tree, r.Binding.Tree+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "changed-inode": |
| + if err := os.Rename(r.Binding.Path, r.Binding.Path+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(r.Binding.Path, []byte("replacement"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.pass(t, BakeDir(dir), WithRootGenerationPins(source)) |
| + got := make([]byte, len("old v2")) |
| + if _, err := opened.ReadAt(got, 0); err != nil || string(got) != "old v2" { |
| + t.Fatalf("reserved bytes changed: %q %v", got, err) |
| + } |
| + if attack == "deleted-executable" { |
| + if _, err := os.Stat(r.Binding.Path); !os.IsNotExist(err) { |
| + t.Fatalf("missing pinned path restaged: %v", err) |
| + } |
| + } else if attack != "changed-inode" && attack != "renamed-parent" { |
| + after, err := os.Stat(r.Binding.Path) |
| + if err != nil || !os.SameFile(before, after) { |
| + t.Fatalf("reserved inode replaced: %v", err) |
| + } |
| + } |
| + if attack == "renamed-parent" { |
| + after, err := os.Stat(filepath.Join(r.Binding.Tree+".old", filepath.Base(r.Binding.Path))) |
| + if err != nil || !os.SameFile(before, after) { |
| + t.Fatalf("renamed reservation destroyed: %v", err) |
| + } |
| + return |
| + } |
| + if v, _ := LiveVersion(f.opt, "h1.example.com"); v != "v1" { |
| + t.Fatalf("refused staging adopted %s", v) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationSnapshotCompleteness(t *testing.T) { |
| + f := newReuseFixture(t) |
| + g, r := generationPinFixture(t, f.opt, "bab", "myapp", "v2") |
| + source := RootGenerationPins{Ledger: g} |
| + if _, err := source.StagedPins(t.Context(), f.opt); err == nil { |
| + t.Fatal("missing completeness granted pins") |
| + } |
| + called := false |
| + source.Verify = func(_ context.Context, records []GenerationReservation) error { |
| + called = true |
| + if len(records) != 1 || records[0] != r { |
| + t.Fatalf("wrong root inventory: %+v", records) |
| + } |
| + return nil |
| + } |
| + pins, err := source.StagedPins(t.Context(), f.opt) |
| + if err != nil || !called || !pins.Has(r.Binding.Path) { |
| + t.Fatalf("snapshot: %v %v", pins, err) |
| + } |
| + s := g.Store.(generationListingStore) |
| + s.fail = 1 |
| + if _, err := source.StagedPins(t.Context(), f.opt); err == nil { |
| + t.Fatal("snapshot ignored failed directory sync") |
| + } |
| + if _, err := source.StagedPins(t.Context(), f.opt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(s.dir, "foreign.json"), []byte(`{}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := source.StagedPins(t.Context(), f.opt); err == nil { |
| + t.Fatal("malformed inventory granted snapshot") |
| + } |
| +} |
| + |
| +func TestPlan14GenerationSnapshotPermitsUnreservedRestage(t *testing.T) { |
| + f := newReuseFixture(t) |
| + g, _ := generationPinFixture(t, f.opt, "bab", "myapp", "v1") |
| + source := RootGenerationPins{Ledger: g, Verify: func(context.Context, []GenerationReservation) error { return nil }} |
| + f.pass(t, WithRootGenerationPins(source)) |
| + if got := f.stagedV2(t); got != "new v2" { |
| + t.Fatalf("unreserved copy not restaged: %q", got) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationSnapshotPinsContainingTree(t *testing.T) { |
| + f := newReuseFixture(t) |
| + if err := StageArtifact(f.opt, "bab", "v2", "sibling", []byte("sibling")); err != nil { |
| + t.Fatal(err) |
| + } |
| + g, _ := generationPinFixture(t, f.opt, "bab", "sibling", "v2") |
| + source := RootGenerationPins{Ledger: g, Verify: func(context.Context, []GenerationReservation) error { return nil }} |
| + f.pass(t, WithRootGenerationPins(source)) |
| + if got := f.stagedV2(t); got != "old v2" { |
| + t.Fatalf("reserved containing tree changed: %q", got) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationSnapshotEmptyIsUnknown(t *testing.T) { |
| + g, _, _ := generationFixture(t) |
| + s := g.Store.(*healthyRetryStore) |
| + g.Store = generationListingStore{s} |
| + if _, err := (RootGenerationPins{Ledger: g}).StagedPins(t.Context(), t.TempDir()); err == nil { |
| + t.Fatal("empty reservation directory established completeness") |
| + } |
| +} |
| + |
| +func TestPlan14GenerationSnapshotPermitsFreshTree(t *testing.T) { |
| + f := newReuseFixture(t) |
| + tree := filepath.Join(f.opt, "versions", "v2") |
| + if err := os.RemoveAll(tree); err != nil { |
| + t.Fatal(err) |
| + } |
| + reads := 0 |
| + source := PinSourceFunc(func(context.Context, string) (StagedPaths, error) { reads++; return nil, os.ErrPermission }) |
| + f.pass(t, WithRootGenerationPins(source)) |
| + if got := f.stagedV2(t); got != "new v2" || reads != 0 { |
| + t.Fatalf("fresh signed build blocked: %q reads=%d", got, reads) |
| + } |
| +} |
diff --git a/internal/release/rootgenerationpins.go b/internal/release/rootgenerationpins.go| new file mode 100644 |
| index 000000000..7f1635e44 |
| --- /dev/null |
| +++ b/internal/release/rootgenerationpins.go |
| @@ -0,0 +1,138 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +// RootGenerationPins is read under release exclusion, held through destruction. |
| +// Verify must reconcile the records with root installation/launch inventory and |
| +// kernel references. An empty directory alone never establishes completeness. |
| +type RootGenerationPins struct { |
| + Ledger *RootGenerations |
| + Verify func(context.Context, []GenerationReservation) error |
| +} |
| + |
| +func (s RootGenerationPins) StagedPins(ctx context.Context, optDir string) (StagedPaths, error) { |
| + if s.Ledger == nil || s.Ledger.Store == nil || !filepath.IsAbs(optDir) { |
| + return nil, os.ErrPermission |
| + } |
| + names, err := s.Ledger.Store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + sort.Strings(names) |
| + records := make([]GenerationReservation, 0, len(names)) |
| + pins := StagedPaths{} |
| + for _, name := range names { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + raw, err := s.Ledger.Store.Read(name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r GenerationReservation |
| + if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validGeneration(r) || generationKey(r.Binding) != name { |
| + return nil, os.ErrPermission |
| + } |
| + // Read retries the directory sync before this record can inform a grant. |
| + r, err = s.Ledger.Read(r.Binding) |
| + if err != nil { |
| + return nil, err |
| + } |
| + b := r.Binding |
| + if b.Path != VersionPath(optDir, b.Binding.Account, b.Version, b.Binding.App) { |
| + return nil, os.ErrPermission |
| + } |
| + for path, expected := range map[string]rootstate.FileIdentity{b.Path: b.Executable, b.Tree: b.TreeIdentity} { |
| + if err := generationFileIdentity(path, expected); err != nil { |
| + return nil, err |
| + } |
| + } |
| + pins.add(b.Path) |
| + pins.add(b.Tree) |
| + records = append(records, r) |
| + } |
| + if s.Verify == nil { |
| + return nil, os.ErrPermission |
| + } |
| + if err := s.Verify(ctx, records); err != nil { |
| + return nil, err |
| + } |
| + return pins, nil |
| +} |
| + |
| +func generationFileIdentity(path string, expected rootstate.FileIdentity) error { |
| + info, err := os.Lstat(path) |
| + if err != nil { |
| + return err |
| + } |
| + if info.Mode()&os.ModeSymlink != 0 { |
| + return os.ErrPermission |
| + } |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + actual, err := rootstate.Identity(f) |
| + if err != nil { |
| + return err |
| + } |
| + if actual != expected { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +// WithRootGenerationPins protects new writes as well as replacement of a |
| +// differing artifact: a removed executable can still be held by a generation. |
| +func WithRootGenerationPins(source PinSource) PassOption { |
| + return func(c *passConfig) { c.generationPins = source } |
| +} |
| + |
| +func (c passConfig) generationWriteCheck(ctx context.Context, optDir, path string) error { |
| + if c.generationPins == nil { |
| + return nil |
| + } |
| + // Creating a wholly absent version tree cannot destroy an existing pin. |
| + // Keep fresh signed recovery available while pin completeness is unknown. |
| + rel, err := filepath.Rel(filepath.Join(optDir, "versions"), path) |
| + version, _, ok := strings.Cut(rel, string(filepath.Separator)) |
| + if err != nil || !filepath.IsLocal(rel) || !ok || !ValidVersion(version) { |
| + return os.ErrPermission |
| + } |
| + if _, err := os.Lstat(filepath.Join(optDir, "versions", version)); errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } else if err != nil { |
| + return err |
| + } |
| + pins, err := c.generationPins.StagedPins(ctx, optDir) |
| + if err != nil { |
| + return err |
| + } |
| + pinned, err := pins.Refers(path) |
| + if err != nil { |
| + return err |
| + } |
| + if pinned { |
| + return ErrVersionReused |
| + } |
| + for parent := filepath.Dir(path); parent != optDir && parent != filepath.Dir(parent); parent = filepath.Dir(parent) { |
| + pinned, err := pins.Refers(parent) |
| + if err != nil { |
| + return err |
| + } |
| + if pinned { |
| + return ErrVersionReused |
| + } |
| + } |
| + return nil |
| +} |