WIP claim Plan 14 track E checkpoint 4 (not for merge): E1 signed reconstruction and recovery
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+1051
−13
internal/host/plan14_pointer_history_test.go+65 −0internal/host/roothistory.go+19 −1internal/provision/plan9_b8_census_test.go+1 −0internal/provision/testdata/plan14-root-reads-merge.tsv+12 −8internal/release/plan14_root_reconstruction_test.go+432 −0internal/release/rootbake.go+3 −0internal/release/roothistory.go+6 −0internal/release/roothistory_app.go+3 −0internal/release/roothistory_delivery.go+51 −0internal/release/roothistory_pass.go+13 −4internal/release/roothistory_reconstruct.go+446 −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 341.871s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.889s
ok github.com/carlosframework/platform/cmd/moveprobe 93.364s
ok github.com/carlosframework/platform/internal/activator 229.405s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.354s
ok github.com/carlosframework/platform/internal/apperr 0.079s
ok github.com/carlosframework/platform/internal/applog 0.127s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.007s
ok github.com/carlosframework/platform/internal/boxid 0.783s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.013s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.190s
ok github.com/carlosframework/platform/internal/changelog 0.235s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 364.632s
? 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.267s
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.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.095s
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 26.523s
? 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.122s
ok github.com/carlosframework/platform/internal/gate 1.655s
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/host 87.190s
ok github.com/carlosframework/platform/internal/hostactors 0.333s
ok github.com/carlosframework/platform/internal/hostbox 2.203s
ok github.com/carlosframework/platform/internal/hostclaim 0.494s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 0.484s
ok github.com/carlosframework/platform/internal/hostfence 0.037s
ok github.com/carlosframework/platform/internal/hostfiles 11.109s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.390s
ok github.com/carlosframework/platform/internal/hostops 1.431s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.005s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.444s
ok github.com/carlosframework/platform/internal/hostresolve 0.142s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.709s
ok github.com/carlosframework/platform/internal/instanceexec 2.767s
ok github.com/carlosframework/platform/internal/instancefs 0.023s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.026s
ok github.com/carlosframework/platform/internal/layout 1.433s
ok github.com/carlosframework/platform/internal/lease 9.491s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.168s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.616s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.064s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.601s
ok github.com/carlosframework/platform/internal/moves/archive 0.749s
ok github.com/carlosframework/platform/internal/objstore 0.159s
ok github.com/carlosframework/platform/internal/ops 0.162s
ok github.com/carlosframework/platform/internal/park 0.199s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 29.408s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.341s
ok github.com/carlosframework/platform/internal/relay 7.208s
ok github.com/carlosframework/platform/internal/release 21.659s
ok github.com/carlosframework/platform/internal/rollout 3.774s
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.003s
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.004s
ok github.com/carlosframework/platform/internal/schedule 2.529s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.326s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.443s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.224s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
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.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
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.551s
=== 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, 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.025s
=== RUN TestT4StorageProbeMinio
2026/10/13 13:17:54 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE1942715D098D, 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.029s
minio contract: PASS
diff --git a/internal/host/plan14_pointer_history_test.go b/internal/host/plan14_pointer_history_test.go| new file mode 100644 |
| index 000000000..9066a325a |
| --- /dev/null |
| +++ b/internal/host/plan14_pointer_history_test.go |
| @@ -0,0 +1,65 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14PointerHistoryRootAndAgentTampering(t *testing.T) { |
| + for _, attack := range []string{"delete", "rename", "empty", "directory"} { |
| + t.Run(attack, func(t *testing.T) { |
| + opt, data := t.TempDir(), t.TempDir() |
| + p := &release.ChannelPointer{App: "console", Channel: "stable", Version: "v1", Digest: release.ManifestDigest([]byte("manifest"))} |
| + if err := pointerRatchet(true, opt, data, func(string) string { return "" }).Admit("bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + root, agent := release.RootRatchetDir(opt), release.EdgeRatchetDir(data) |
| + if err := release.NewDigestRatchet(agent, []string{root}, false).Admit("bab", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + mark := filepath.Join(agent, "bab", "console", "stable.digests") |
| + switch attack { |
| + case "delete": |
| + if err := os.Remove(mark); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(mark, mark+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "empty": |
| + if err := os.WriteFile(mark, nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "directory": |
| + if err := os.Remove(mark); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(mark, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + legacy := &release.ChannelPointer{App: p.App, Channel: p.Channel, Version: p.Version} |
| + for _, reader := range []*release.DigestRatchet{release.NewDigestRatchet(root, []string{agent}, false), release.NewDigestRatchet(agent, []string{root}, false)} { |
| + if err := reader.Admit("bab", legacy); !errors.Is(err, release.ErrPointerWithoutDigests) { |
| + t.Fatalf("tampering admitted digestless pointer: %v", err) |
| + } |
| + } |
| + if err := release.NewDigestRatchet(root, []string{agent}, false).Admit("bab", p); err != nil { |
| + t.Fatalf("signed digest pointer refused: %v", err) |
| + } |
| + }) |
| + } |
| + root, agent := t.TempDir(), t.TempDir() |
| + mark := filepath.Join(agent, "bab", "console", "stable.digests") |
| + if err := os.MkdirAll(mark, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.NewDigestRatchet(root, []string{agent}, false).Admit("bab", &release.ChannelPointer{App: "console", Channel: "stable", Version: "v1"}); !errors.Is(err, release.ErrPointerWithoutDigests) { |
| + t.Fatalf("malformed additional deny failed open: %v", err) |
| + } |
| +} |
diff --git a/internal/host/roothistory.go b/internal/host/roothistory.go| index dfae59e25..fb842c344 100644 |
| --- a/internal/host/roothistory.go |
| +++ b/internal/host/roothistory.go |
| @@ -7,6 +7,7 @@ import ( |
| "encoding/hex" |
| "io" |
| "os" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -19,7 +20,24 @@ func openRootHistory(store release.Store, optDir string) (*release.RootHistoryPa |
| return nil, err |
| } |
| source := productionProofBindingSource(store, systemdUnits{}, optDir) |
| - return boundRootHistory(&release.RootHistory{Store: s}, source.resolve, productionProofObserver(optDir)), nil |
| + h := boundRootHistory(&release.RootHistory{Store: s}, source.resolve, productionProofObserver(optDir)) |
| + h.Reconstruct = func(ctx context.Context, b release.HistoryBinding) error { |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return err |
| + } |
| + return h.History.ReceiveReconstruction(ctx, store, pub, b, time.Now(), func(want release.HistoryBinding) (string, error) { |
| + got, err := source.forHost(ctx, want.Host) |
| + if err != nil { |
| + return "", err |
| + } |
| + if got.Route.Account != want.Account || got.Route.App != want.App { |
| + return "", os.ErrPermission |
| + } |
| + return got.Source, nil |
| + }) |
| + } |
| + return h, nil |
| } |
| func boundRootHistory(h *release.RootHistory, resolve proofBindingResolver, obs proofObserver) *release.RootHistoryPass { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index cf1020b8d..bfa9b8dbd 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -472,6 +472,7 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| + {"internal/release/roothistory_delivery.go", "PublishHistoryReconstruction", "nonrecord", "sel:Put", "c2ff8e966cbe2e9f86bc3ddbd61d1df62a5df501927e2791e11518bb62d200c7"}, |
| {"internal/host/rootrecovery_rebind.go", "type recoveryRebindRequest", "key-site", "key:RecordKey", "b5db3e4dcd4807edea1d053eb1787fbf222be97d3f892e2ab66771457a51dc3d"}, |
| {"internal/host/rootrecovery_rebind.go", "ClaimRootHTTP.rebindRecoveryInventory", "key-site", "key:RecordKey", "70f47ac54692802662b353edb4d89cdb2617fc154dde6f2b698e488b8356baf1"}, |
| {"internal/host/claimrecovery_remote.go", "claimCrossBoxHandover.installRecoveryRemote", "key-site", "key:RecordKey", "d60c3ab46f7b909fa22c648e883e402345d2652a3c54640c3981b13c5c36e168"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv| index a79295b3c..f923e4351 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-merge.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-merge.tsv |
| @@ -251,7 +251,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/console 41714f3779b343460800cb25b31782dc7d5c1e83b2aacf7d8d0a28f39c47bf76 |
| @coverage internal/console/templates 6492b7455fa3315105339cca03edb4372ac0d9bf746c018e13a308cf0b74cc05 |
| @coverage internal/edgesfile 553af147ca4882b8425afddf1ed250ee40a6cac92361d667cb4a690393be5272 |
| -@coverage internal/host 50e42fa6fee6618e66b88faab0bfa1a595a04215a33459c605d81b85cae349b3 |
| +@coverage internal/host e04c1c00d40b2f9bc6083198aafc14077f48859fdd621d257d1978169098809a |
| @coverage internal/hostbox afb08c287eaccb43adde1c488bd122482711ce4e29ecb675c9428f27130c3d93 |
| @coverage internal/hostclaim feaf701db4b49ce92733b9d6c1fa1c0ae8efc6e13439ce201fca1a9e24dcf927 |
| @coverage internal/hostfiles 554285c123758375c13388b2fa42f654819392dac0b027816a713548ec17dcc6 |
| @@ -264,7 +264,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/moves 5218cf9bd31f6347ae49a595a32455f441b1718ecfa0a9559f99036d32f56498 |
| @coverage internal/provision a5c7116ee1a063ecdbe8bd5ea1832370ce66960ab13cc7afd34c0973dd21b457 |
| @coverage internal/registry bb6028d50884545209c11e65131dc3389a0991c9a94a0954965e0df4b82b3232 |
| -@coverage internal/release a3d5d29063ae63c9cb6aa4dbec657ad7ef9953b1bf410d9176afd84982e02273 |
| +@coverage internal/release a864e748f04e50cff98463540c2a1cd8e7922c2eb7d94f6b2f9d27098e6e2c15 |
| @coverage internal/sim/hostlife eaac101ea7970c74fee621e5cd7515ec7e4907bc5874c65eab86666af0f7dc61 |
| @coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976 |
| @coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6 |
| @@ -284,24 +284,24 @@ internal/rootparticipation/ledger.go Ledger.EndUpdate authority D D3 removes onl |
| internal/rootparticipation/ledger.go Ledger.idle authority D D3 enumerates root records and requires settled obligations plus an independently supplied launch/kernel census. file:Read@429 b7b27590041bac6bec3de245b7fe20da7965cb2a9e52725c888f8a83313ff9f3 |
| internal/hostexec/rootclient.go requestIdentity authority D D1 supplies collision-resistant request correlation only; root authenticates peer and authority independently. Production client integration pending. file:Read@73 2acec351fdc4ab671e65b4a9bb5017aeb094fbf78fad8e34d713e48a2846c158 |
| internal/hostexec/rootserving.go RootBroker.servingView authority D D1 reads only completed root census and settled root participation under transaction exclusion, rechecks claim authority and peer identity, and returns no lease or token. Production client selection is pending. file:Read@441 9a57720834b3d7c661854196cf0ed1261c643673b5ba900c38987a162f5de0bb |
| -internal/release/roothistory.go RootHistory.read authority E1 Root per-binding refusal/program completeness and pending attempt state; strict decode and exact durable recommit before authority reads. E1 production integration is selected but incomplete. file:Read@183 7ad6f4711ce92da81c19dfe5d7740bb0058b6689afcde5a89c34b9a2491a82a3 |
| +internal/release/roothistory.go RootHistory.read authority E1 Root per-binding refusal/program completeness and pending attempt state; strict decode and exact durable recommit before authority reads. E1 production integration is selected but incomplete. file:Read@251 51c0dfb65407409d6d3cb85d3e5f50051e0f104e1e72efb2b1225c0202d979f7 |
| internal/host/agent.go runAdoptUnderRootRelease authority C,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. whole:Route@10418,whole:Route@17363,whole:Route@17399,whole:Route@7846 2f5e604ccd70ecaedbc48c8c2549164cd3c445a777812964331dc0c0de723c02 |
| 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 3dbb187bf1456f5343bbaa540d0f5886aa073d3319d0538e0827488e2b202120 |
| internal/host/proofroot.go proofRoot.mint authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. registry:Account@136,registry:App@1338,registry:App@1960,registry:App@2075,registry:App@2279,registry:App@316,registry:App@934,registry:Host@1135,registry:Host@1259,registry:Host@1599,registry:Host@2066,registry:Host@2189,registry:Host@287,registry:Host@469,registry:Host@622,whole:Route@129,whole:Route@144 8c4e2457e74ac917a5be303d74493247190e344585da622e989048fd0e00e61a |
| internal/host/proofroot.go proofRoot.observeAndRecord authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. file:Readlink@108,registry:Account@443,registry:App@468,registry:Host@151,registry:Host@657,whole:Route@186,whole:Route@453,whole:Route@478 88065f1cfe7ad31178ba65c2672d8dd1e25fab26820290368fc063907f7b3b98 |
| internal/host/roothistory.go boundRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@1061,file:Read@1379,registry:Account@613,registry:App@635,registry:Channel@456,registry:Channel@470,registry:Host@590,whole:Route@1469,whole:Route@377,whole:Route@472,whole:Route@592,whole:Route@615,whole:Route@637,whole:Route@846 fe46269101116374044658bbb87c1bd44317525dcadf227d4515b47845238be2 |
| -internal/host/roothistory.go openRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@104 b3be056c0d2ff1eb7f2ff031ff3e201ba8569abb5dff827cb195b4a5a0a4aafd |
| +internal/host/roothistory.go openRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@104,registry:Account@800,registry:App@837,whole:Route@804,whole:Route@841 77b7ee45bd93e0e5d0dfd4ea444cb9df293461875ed0a42f8c177b84c65ed317 |
| internal/host/roothistory.go recordRootProgram authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. whole:Route@151,whole:Route@297,whole:Route@398 fbbc9451c39309090580486d75da6120b02e1ba054157159846bea057ae0c80f |
| internal/release/adopt.go PassFilter authority B3,E3,J1,E1 E1 root history and bake selection, retained denies, signed program consent, durable intent before effects and independently observed settlement; B3 crash linkage remains incomplete. registry:App@24918,registry:App@27375,registry:App@27867,registry:App@28104,registry:App@29511,registry:App@4713,registry:App@7738,registry:App@7879,registry:Hibernate@27750,registry:Host@1484,registry:Host@1551,registry:Host@1647,registry:Host@1682,registry:Host@27920,registry:Host@28836,registry:Host@29564,registry:UpdateSettings@7616,whole:Route@1098,whole:Route@1122,whole:Route@1153,whole:Route@1210,whole:Route@1296,whole:Route@1318,whole:Route@1418,whole:Route@2403,whole:Route@24913,whole:Route@27370,whole:Route@27628,whole:Route@27650,whole:Route@27697,whole:Route@27715,whole:Route@27725,whole:Route@27768,whole:Route@27789,whole:Route@27802,whole:Route@27862,whole:Route@27888,whole:Route@27962,whole:Route@28001,whole:Route@28017,whole:Route@28045,whole:Route@28099,whole:Route@28125,whole:Route@28878,whole:Route@28932,whole:Route@28948,whole:Route@29455,whole:Route@29506,whole:Route@29532,whole:Route@29606,whole:Route@29641,whole:Route@29657,whole:Route@3050,whole:Route@4635,whole:Route@4657,whole:Route@4708,whole:Route@4734,whole:Route@7567,whole:Route@7681,whole:Route@7726,whole:Route@7874 3adae93bfe6c7c12d3c8a78c3a6af528d7f5e3b538ab376b3653ec60f5871e02 |
| internal/release/adopt.go adoptHost authority B3,E1,E2,E3,E4,J1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. registry:App@10601,registry:App@10761,registry:App@11790,registry:App@12514,registry:App@14076,registry:App@19077,registry:App@24341,registry:App@3114,registry:App@31948,registry:App@6423,registry:App@7724,registry:App@7918,registry:Hibernate@32809,registry:Host@10481,registry:Host@10514,registry:Host@10614,registry:Host@10774,registry:Host@10876,registry:Host@10908,registry:Host@10973,registry:Host@11423,registry:Host@11556,registry:Host@11986,registry:Host@12112,registry:Host@12604,registry:Host@13380,registry:Host@13848,registry:Host@14062,registry:Host@14404,registry:Host@14994,registry:Host@18725,registry:Host@18833,registry:Host@19045,registry:Host@19769,registry:Host@19927,registry:Host@20389,registry:Host@20441,registry:Host@20588,registry:Host@20675,registry:Host@2075,registry:Host@21781,registry:Host@22025,registry:Host@2264,registry:Host@22685,registry:Host@23070,registry:Host@23211,registry:Host@2345,registry:Host@24309,registry:Host@24436,registry:Host@25778,registry:Host@26428,registry:Host@29661,registry:Host@30279,registry:Host@30604,registry:Host@30678,registry:Host@30931,registry:Host@3127,registry:Host@32120,registry:Host@32287,registry:Host@32394,registry:Host@32622,registry:Host@32765,registry:Host@33510,registry:Host@33636,registry:Host@33724,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7610,registry:Host@7778,registry:Host@7904,registry:Host@8023,registry:Host@8501,registry:Host@8888,registry:Host@9114,registry:Host@9210,registry:Host@9301,registry:Kind@552,registry:Unit@577,whole:Route@10596,whole:Route@10756,whole:Route@11780,whole:Route@12504,whole:Route@14044,whole:Route@14609,whole:Route@14970,whole:Route@16193,whole:Route@16496,whole:Route@16652,whole:Route@18982,whole:Route@19067,whole:Route@21510,whole:Route@2176,whole:Route@21909,whole:Route@24331,whole:Route@29232,whole:Route@3109,whole:Route@32096,whole:Route@3554,whole:Route@6418,whole:Route@9738 6767d178b99cc164cb17b78e707127ef47942717d219da593b03ce186da4dfe1 |
| 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@191 8843a3ecde0ff16e87f21a668d4b5d678da850098589795343a8d189dd029f04 |
| -internal/release/roothistory_app.go RootHistory.AppRefusals authority E1 E1 explicit app inventory plus retained host records and per-host completeness; missing, extra, malformed or pending histories refuse minting. file:Read@227,file:Read@749 1c198ebb982ec81d54cbec4c1d7e85019ce1209bd661945d4b9e2a3152b3cced |
| +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 |
| +internal/release/roothistory_app.go RootHistory.AppRefusals authority E1 E1 explicit app inventory plus retained host records and per-host completeness; missing, extra, malformed or pending histories refuse minting. file:Read@297,file:Read@819 9df40b5eb5b878ceba87b596204cddce04183cf78df4d1e5cd0dcbfe13e5d25e |
| internal/release/roothistory_pass.go RootHistoryPass.begin authority E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@607,file:Prepare@638,file:Read@292,whole:Route@218 dde605d1d382309a8546c7fb4a78e276909bc7741a83b93c93d0f2ec9c783004 |
| internal/release/roothistory_pass.go RootHistoryPass.complete authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@205,whole:Route@278,whole:Route@350 10a2d72856ad28919399878857ee8d40e60877b86f76ba12d6f98d456001ccd2 |
| internal/release/roothistory_pass.go RootHistoryPass.recheck authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@133 b335ffd791175d39f69451d7f55b9fb59674cacd48ff6221ad3edf9719208038 |
| internal/release/roothistory_pass.go RootHistoryPass.resolve authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:App@394,registry:Host@341,whole:Route@240,whole:Route@378 7a0d3ca9f00fedcb219693d9ee98a68d03ae67e83b0efd42a943eccebadc7105 |
| -internal/release/roothistory_pass.go RootHistoryPass.selectRoute authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@134 41e83679f252a6347c471b271034e6a3bab9772e18ad57ff9058a3f472ac01cc |
| +internal/release/roothistory_pass.go RootHistoryPass.selectRoute authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@134,whole:Route@370 83fabcbe7eea1c210666501b217ede898c0f64d22ff88db92b6c0a5790736dcd |
| internal/release/roothistory_pass.go passConfig.bindProof authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. whole:Route@214,whole:Route@277,whole:Route@353 395b72a300204af57355f0d6d037438b1210798f17c26efa9cd3dbef1f098806 |
| internal/release/roothistory_pass.go passConfig.recheckHistory authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@184,whole:Route@162 2a7c830d3889757864bf69ddc17a9ee1cff2151dd27c90e69c9b6e4f85854332 |
| internal/release/roothistory_pass.go passConfig.recordRefusal authority E1 E1 canonical binding selection and recheck; durable attempt precedes effects; only independent installed observation settles and retires the exact target refusal. registry:Host@151,whole:Route@241 298ca88f7dc02be7779aaa527990b4e4548c1fe5498fba0272a6e7041b236354 |
| @@ -316,4 +316,8 @@ internal/host/rootrollback.go captureRollback authority B3,J1,E1 Root history in |
| internal/host/rootrollback.go loadRollback authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Read@451,registry:Host@1014,whole:Route@1026 6929d919e83b02b708bb4d45b53617a94502fb85e44f8914b869a0a2b90b7eda |
| internal/host/rootrollback_resume.go resumeRootRestart authority B3,J1,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:EvalSymlinks@1384,registry:Account@1477,registry:Account@1997,registry:App@1505,registry:App@2025,registry:Unit@1255,whole:Route@1257,whole:Route@1479,whole:Route@1507,whole:Route@1999,whole:Route@2027 d51ee3af37b32715f6ddc4d7e70d9dff0eb5d9a50af15cb3bb331d8a796d24a6 |
| internal/release/roothistory_pass.go RootHistoryPass.CompleteAttempt authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. whole:Route@136,whole:Route@255 97d46675209fa1d5ba064afded9b8b3265b741be9a30dfec6efab01a745d1d82 |
| -internal/release/roothistory_pass.go type RootHistoryPass authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@209 fcf30eb478e7f66189021aee547518c518fa68cd53aee153e026af82eca392a8 |
| +internal/release/roothistory_pass.go type RootHistoryPass authority B3,E1 Root history intent is linked to captured canonical rollback authority before live effects; verified recovery settles only its exact intent. Fixtures: TestPlan14HistoryRollbackLink, TestPlan14HistoryRecoveryRefusesChanges, TestPlan14HistoryRecoveryProcessCrash, TestPlan14HistorySettlementRetriesVisibleSync. E1 migration and restart consumption remain incomplete. file:Prepare@215 c90889be3f234547b634149feceb82515c03de58598912758152f39604f23274 |
| +internal/release/roothistory_reconstruct.go RootHistory.ReconstructionStatus authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@189 f2da2a6c97bc40bf972f1346b578c47334585053d85244c9ba6b63c45f0570a3 |
| +internal/release/roothistory_reconstruct.go RootHistory.reconstructionImages authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@1190,file:Read@226,file:Read@2940,file:Read@629 7fcdb81486fea19d7e05482dee69513bfe31294fec61eff9f6ed7db82117d806 |
| +internal/release/roothistory_reconstruct.go RootHistory.reconstructionState authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@178 7c53af838e0dc7ec7ad6ae5341da983bde442288126e9eea21f80d6a62fc478a |
| +internal/release/roothistory_reconstruct.go RootHistory.resumeReconstruction authority E1 E1 signed root-history reconstruction; exact namespace identity, revisions and canonical sources; journaled completion retains denies and stronger bounds. Tests: TestPlan14RootHistoryReconstruction, TestPlan14RootHistoryReconstructionCrash, TestPlan14RootHistoryReconstructionDeliveryPass. file:Read@915 39d8949054462cf53823e4b6fc802a08349bbac79750f6beb5b90095880925b8 |
diff --git a/internal/release/plan14_root_reconstruction_test.go b/internal/release/plan14_root_reconstruction_test.go| new file mode 100644 |
| index 000000000..f6f1de88a |
| --- /dev/null |
| +++ b/internal/release/plan14_root_reconstruction_test.go |
| @@ -0,0 +1,432 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type reconstructionCrashStore struct { |
| + *healthyRetryStore |
| + stage string |
| + fail bool |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionDeliveryPass(t *testing.T) { |
| + for _, attack := range []string{"valid", "missing", "forged", "source", "revision"} { |
| + t.Run(attack, func(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + r, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Refusals.Complete = false |
| + if err := h.write(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := t.Context() |
| + st := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + reg := testRegistry(t) |
| + addRoute(t, reg, b.Host, b.App, "canary/x", false) |
| + shipRelease(t, st, b.Account, b.App, "new", adoptPlatform, []byte("new")) |
| + if err := Promote(ctx, st, priv, b.Account, b.App, "canary/x", "new"); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, err := h.ReconstructionStatus() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + g := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "delivered", IssuedAt: now, Account: b.Account, App: b.App, |
| + Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "record", Revision: 1}}} |
| + if attack == "source" { |
| + g.Hosts[0].Source = "forged" |
| + } |
| + if attack == "revision" { |
| + g.Hosts[0].Revision++ |
| + } |
| + signer := RootSigner(priv) |
| + if attack == "forged" { |
| + _, other, _ := ed25519.GenerateKey(rand.Reader) |
| + signer = RootSigner(other) |
| + } |
| + if attack != "missing" { |
| + if err := PublishHistoryReconstruction(ctx, st, signer, g); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + deps := &RootHistoryPass{History: h, Resolve: func(context.Context, registry.Route) (HistoryBinding, string, error) { return b, "record", nil }, Installed: func(context.Context, registry.Route, string) (string, error) { return "", nil }} |
| + deps.Reconstruct = func(ctx context.Context, b HistoryBinding) error { |
| + return h.ReceiveReconstruction(ctx, st, pub, b, now, func(HistoryBinding) (string, error) { return "record", nil }) |
| + } |
| + effects := 0 |
| + opt := t.TempDir() |
| + err = Pass(ctx, reg, st, pub, opt, func(string) error { effects++; return nil }, WithRootHistory(deps)) |
| + if attack == "valid" { |
| + if err != nil || effects != 1 { |
| + t.Fatalf("delivered reconstruction: effects=%d err=%v", effects, err) |
| + } |
| + if live, _ := LiveVersion(opt, b.Host); live != "new" { |
| + t.Fatalf("live=%q", live) |
| + } |
| + if err := deps.Reconstruct(ctx, b); err != nil { |
| + t.Fatalf("consumed delivery replay: %v", err) |
| + } |
| + } else if err == nil || effects != 0 { |
| + t.Fatalf("%s: effects=%d err=%v", attack, effects, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func (s *reconstructionCrashStore) Write(name string, raw []byte) error { |
| + old, _ := s.Read(name) |
| + if err := s.healthyRetryStore.Write(name, raw); err != nil { |
| + return err |
| + } |
| + if bytes.Equal(old, raw) { |
| + return nil |
| + } |
| + stage := name |
| + if name == reconstructionKey { |
| + var state reconstructionState |
| + if err := json.Unmarshal(raw, &state); err != nil { |
| + return err |
| + } |
| + if state.Pending != nil { |
| + stage = "intent" |
| + } else if state.Last != "" { |
| + stage = "complete" |
| + } |
| + } |
| + if s.stage == stage { |
| + if s.fail { |
| + return s.fault |
| + } |
| + os.Exit(83) |
| + } |
| + return nil |
| +} |
| + |
| +func reconstructionCrashFixture(t *testing.T) (*RootHistory, *healthyRetryStore, HistoryBinding, ed25519.PublicKey, []byte, time.Time) { |
| + t.Helper() |
| + h, s, b := rootHistoryFixture(t) |
| + status, err := h.ReconstructionStatus() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + g := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "crash", IssuedAt: now, Account: b.Account, App: b.App, |
| + Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "canonical", Revision: 1}}, Holds: map[string]HistoryReconstructionHold{"stable": {Window: time.Hour, SetAt: now}}} |
| + wire, err := SignRootEnvelope(RootSigner(priv), g) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return h, s, b, pub, wire, now |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionCrash(t *testing.T) { |
| + resolve := func(HistoryBinding) (string, error) { return "canonical", nil } |
| + if dir := os.Getenv("PLAN14_RECONSTRUCTION_CHILD"); dir != "" { |
| + pub, err := os.ReadFile(filepath.Join(dir, "pub")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wire, err := os.ReadFile(filepath.Join(dir, "wire")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := &reconstructionCrashStore{healthyRetryStore: &healthyRetryStore{dir: dir}, stage: os.Getenv("PLAN14_RECONSTRUCTION_STAGE")} |
| + h := &RootHistory{Store: s} |
| + err = h.Reconstruct(pub, wire, "bab", "console", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC), resolve) |
| + t.Fatalf("crash boundary not reached: %v", err) |
| + } |
| + for _, stage := range []string{"intent", "host", "app", rootBakeKey, "complete"} { |
| + t.Run(stage, func(t *testing.T) { |
| + h, s, b, pub, wire, now := reconstructionCrashFixture(t) |
| + name := stage |
| + if stage == "host" { |
| + name = historyKey(b) |
| + } |
| + if stage == "app" { |
| + name = historyAppKey(b.Account, b.App) |
| + } |
| + for key, data := range map[string][]byte{"pub": pub, "wire": wire} { |
| + if err := os.WriteFile(filepath.Join(s.dir, key), data, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14RootHistoryReconstructionCrash$") |
| + cmd.Env = append(os.Environ(), "PLAN14_RECONSTRUCTION_CHILD="+s.dir, "PLAN14_RECONSTRUCTION_STAGE="+name) |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 83 { |
| + t.Fatalf("child: %v %s", err, out) |
| + } |
| + if stage != "complete" { |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("partial reconstruction granted refusal completeness") |
| + } |
| + if _, err := h.Programs(b); err == nil { |
| + t.Fatal("partial reconstruction granted program history") |
| + } |
| + if _, err := h.AppRefusals(b.Account, b.App); err == nil { |
| + t.Fatal("partial reconstruction granted mint inventory") |
| + } |
| + if _, err := h.LoadBake([]HistoryBinding{b}, now); err == nil { |
| + t.Fatal("partial reconstruction granted bake completeness") |
| + } |
| + if err := h.ResumeReconstruction(func(HistoryBinding) (string, error) { return "changed", nil }); err == nil { |
| + t.Fatal("changed canonical source resumed") |
| + } |
| + } |
| + if err := h.ResumeReconstruction(resolve); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.AppRefusals(b.Account, b.App); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := s.Read(rootBakeKey) |
| + if err := h.ResumeReconstruction(resolve); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _ := s.Read(rootBakeKey) |
| + if !bytes.Equal(before, after) { |
| + t.Fatal("resume changed committed clock") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionVisibleSync(t *testing.T) { |
| + h, s, b, pub, wire, now := reconstructionCrashFixture(t) |
| + resolve := func(HistoryBinding) (string, error) { return "canonical", nil } |
| + fault := &reconstructionCrashStore{healthyRetryStore: s, stage: historyKey(b), fail: true} |
| + h.Store = fault |
| + if err := h.Reconstruct(pub, wire, b.Account, b.App, now, resolve); !errors.Is(err, s.fault) { |
| + t.Fatalf("write: %v", err) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("visible unsynced reconstruction granted permission") |
| + } |
| + fault.stage = "" |
| + if err := h.ResumeReconstruction(resolve); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Refusals(b); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryReconstruction(t *testing.T) { |
| + for _, attack := range []string{"valid", "signature", "identity", "revision", "source", "expired", "future", "pending", "omitted-deny"} { |
| + t.Run(attack, func(t *testing.T) { |
| + h, disk, b := rootHistoryFixture(t) |
| + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) |
| + ref := Refusal{Schema: 1, Host: b.Host, App: b.App, Version: "bad", RefusedAt: now, PointerPromotedAt: now} |
| + if err := h.AddRefusal(b, ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, err := h.ReconstructionStatus() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + g := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "op1", IssuedAt: now, |
| + Account: b.Account, App: b.App, Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "canonical", Revision: 2, Refusals: []Refusal{ref}}}, |
| + Holds: map[string]HistoryReconstructionHold{"stable": {Window: time.Hour, SetAt: now}}, Production: true, ProductionSetAt: now} |
| + switch attack { |
| + case "identity": |
| + g.Identity = "other-box" |
| + case "revision": |
| + g.Revision++ |
| + case "source": |
| + g.Hosts[0].Source = "forged" |
| + case "expired": |
| + g.IssuedAt = now.Add(-25 * time.Hour) |
| + case "future": |
| + g.IssuedAt = now.Add(2 * time.Minute) |
| + case "pending": |
| + if err := h.Begin(b, HistoryAttempt{ID: "attempt", Version: "bad", Source: "canonical", Started: now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + g.Hosts[0].Revision++ |
| + case "omitted-deny": |
| + g.Hosts[0].Refusals = nil |
| + } |
| + wire, err := SignRootEnvelope(RootSigner(priv), g) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if attack == "signature" { |
| + pub, _, _ = ed25519.GenerateKey(rand.Reader) |
| + } |
| + resolve := func(HistoryBinding) (string, error) { return "canonical", nil } |
| + err = h.Reconstruct(pub, wire, b.Account, b.App, now, resolve) |
| + if attack != "valid" && attack != "omitted-deny" { |
| + if err == nil { |
| + t.Fatal("invalid reconstruction accepted") |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + refs, err := h.AppRefusals(b.Account, b.App) |
| + if err != nil || len(refs) != 1 || refs[0].Version != "bad" { |
| + t.Fatalf("retained denials: %v %v", refs, err) |
| + } |
| + clock, err := h.LoadBake([]HistoryBinding{b}, now) |
| + if err != nil || clock.Holds[channelClockKey(b.Account, b.App, "stable")].Window != time.Hour { |
| + t.Fatalf("bake: %v %v", clock, err) |
| + } |
| + before, _ := disk.Read(rootBakeKey) |
| + if err := h.Reconstruct(pub, wire, b.Account, b.App, now.Add(time.Hour), resolve); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _ := disk.Read(rootBakeKey) |
| + if string(before) != string(after) { |
| + t.Fatal("replay changed bake observation") |
| + } |
| + if err := os.WriteFile(filepath.Join(t.TempDir(), "refusal.json"), []byte(`{"recovered":true}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if blocked, err := h.Blocks(b, &ChannelPointer{Version: "bad", PromotedAt: now}); err != nil || !blocked { |
| + t.Fatalf("reconstruction cleared deny: %v %v", blocked, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionUnknownAndFresh(t *testing.T) { |
| + h, disk, b := rootHistoryFixture(t) |
| + if err := os.Remove(filepath.Join(disk.dir, historyKey(b))); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, err := h.ReconstructionStatus() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Refusals(b); err == nil { |
| + t.Fatal("status initialized history") |
| + } |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + now := time.Now().UTC() |
| + g := HistoryReconstruction{Schema: 1, Kind: "carlos-release-history", Identity: status.Identity, Revision: status.Revision, Operation: "fresh", IssuedAt: now, Account: b.Account, App: b.App, |
| + Hosts: []HistoryReconstructionHost{{Host: b.Host, Source: "canonical"}}} |
| + wire, _ := SignRootEnvelope(RootSigner(priv), g) |
| + if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "canonical", nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := h.Programs(b); err == nil { |
| + t.Fatal("reconstruction invented installed programs") |
| + } |
| + if refs, err := h.Refusals(b); err != nil || len(refs) != 0 { |
| + t.Fatalf("explicit empty reconstruction: %v %v", refs, err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryObservationKeepsReconstructionRevision(t *testing.T) { |
| + h, _, b := rootHistoryFixture(t) |
| + before, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.RecordInstalledProgram(b, "old/program"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.Revision != before.Revision { |
| + t.Fatalf("repeat observation invalidated operator reconstruction: %d -> %d", before.Revision, after.Revision) |
| + } |
| + if err := h.RecordInstalledProgram(b, "new/program"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err = h.read(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.Revision != before.Revision+1 { |
| + t.Fatal("new program did not advance revision") |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionPreservesOtherRecovery(t *testing.T) { |
| + h, _, b, pub, wire, now := reconstructionCrashFixture(t) |
| + other := b |
| + other.Host = "other.example.com" |
| + other.App = "another" |
| + if err := h.write(historyRecord{Schema: 1, Binding: other, Revision: 1, Programs: historyPrograms{Revision: 1, Complete: true}, Refusals: historyRefusals{Revision: 1, Complete: true}, Attempt: &HistoryAttempt{ID: "unresolved", Version: "bad", Source: "source", Started: now}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "canonical", nil }); err == nil { |
| + t.Fatal("reconstruction could block unrelated automatic recovery") |
| + } |
| + if pending, err := h.Pending(other); err != nil || pending == nil || pending.ID != "unresolved" { |
| + t.Fatalf("recovery unavailable: %v %v", pending, err) |
| + } |
| +} |
| + |
| +func TestPlan14RootHistoryReconstructionRestartsHistoricalWait(t *testing.T) { |
| + h, _, b, pub, wire, now := reconstructionCrashFixture(t) |
| + seedRootBake(t, h, b, now.Add(-24*time.Hour)) |
| + clock, err := h.LoadBake([]HistoryBinding{b}, now.Add(-24*time.Hour)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + clock.Observe(b.Account, b.App, "stable", "v1", now.Add(-24*time.Hour)) |
| + old := now.Add(-24 * time.Hour) |
| + clock.Production = map[string]*prodMemory{prodKey(b.Account, b.App): {WasProduction: true, NewestSetAt: old, OffSetAt: old, OffFirstSeen: old}} |
| + clock.Holds = map[string]*holdMemory{channelClockKey(b.Account, b.App, "stable"): {Window: 2 * time.Hour, Pending: time.Minute, PendingAt: old, DeletedAt: old}} |
| + clock.Channels[channelClockKey(b.Account, "other", "stable")] = map[string]time.Time{"v1": old} |
| + if err := clock.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + var grant HistoryReconstruction |
| + if err := VerifyRootEnvelope(pub, wire, &grant); err != nil { |
| + t.Fatal(err) |
| + } |
| + grant.BakeRevision = 2 |
| + priv, pub := testKeys(t) |
| + wire, err = SignRootEnvelope(RootSigner(priv), grant) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "canonical", nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + clock, err = h.LoadBake([]HistoryBinding{b}, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + eligible, ok := clock.EligibleAt(b.Account, b.App, "stable", "v1", map[string]time.Duration{"stable": time.Hour}) |
| + if !ok || eligible.Before(now.Add(time.Hour)) { |
| + t.Fatalf("reconstructed bound inherited elapsed time: %v %v", eligible, ok) |
| + } |
| + mem := clock.Holds[channelClockKey(b.Account, b.App, "stable")] |
| + if mem.Window != 2*time.Hour || !mem.PendingAt.Equal(now) || !mem.DeletedAt.Equal(now) { |
| + t.Fatalf("historical hold changed: %+v", mem) |
| + } |
| + prod := clock.Production[prodKey(b.Account, b.App)] |
| + if !prod.WasProduction || !prod.OffFirstSeen.Equal(now) { |
| + t.Fatalf("historical production changed: %+v", prod) |
| + } |
| + if !clock.Channels[channelClockKey(b.Account, "other", "stable")]["v1"].Equal(old) { |
| + t.Fatal("reconstruction changed another app's clock") |
| + } |
| +} |
diff --git a/internal/release/rootbake.go b/internal/release/rootbake.go| index b7452cba7..845b0d95a 100644 |
| --- a/internal/release/rootbake.go |
| +++ b/internal/release/rootbake.go |
| @@ -69,6 +69,9 @@ func (h *RootHistory) writeBake(r rootBakeRecord) error { |
| } |
| func (h *RootHistory) LoadBake(bindings []HistoryBinding, now time.Time) (*FirstSeen, error) { |
| + if err := h.reconstructionReady(); err != nil { |
| + return nil, err |
| + } |
| if h == nil || h.Store == nil || now.IsZero() { |
| return nil, os.ErrPermission |
| } |
diff --git a/internal/release/roothistory.go b/internal/release/roothistory.go| index 220cc6639..53ff406c7 100644 |
| --- a/internal/release/roothistory.go |
| +++ b/internal/release/roothistory.go |
| @@ -109,6 +109,9 @@ func validHistory(r historyRecord, b HistoryBinding) bool { |
| func (h *RootHistory) read(b HistoryBinding) (historyRecord, error) { |
| var r historyRecord |
| + if err := h.reconstructionReady(); err != nil { |
| + return r, err |
| + } |
| if h == nil || h.Store == nil || !b.valid() { |
| return r, os.ErrPermission |
| } |
| @@ -163,6 +166,9 @@ func (h *RootHistory) RecordInstalledProgram(b HistoryBinding, program string) e |
| } else if err != nil { |
| return err |
| } |
| + if r.Programs.Complete && (program == "" || slices.Contains(r.Programs.Values, program)) { |
| + return nil |
| + } |
| if r.Revision == ^uint64(0) { |
| return os.ErrPermission |
| } |
diff --git a/internal/release/roothistory_app.go b/internal/release/roothistory_app.go| index 1b0ad3c36..095aa6ddd 100644 |
| --- a/internal/release/roothistory_app.go |
| +++ b/internal/release/roothistory_app.go |
| @@ -52,6 +52,9 @@ func (h *RootHistory) writeApp(a historyApp) error { |
| } |
| func (h *RootHistory) AppRefusals(acct, app string) ([]Refusal, error) { |
| + if err := h.reconstructionReady(); err != nil { |
| + return nil, err |
| + } |
| if h == nil || h.Store == nil || !ValidAccount(acct) || !ValidApp(app) { |
| return nil, os.ErrPermission |
| } |
diff --git a/internal/release/roothistory_delivery.go b/internal/release/roothistory_delivery.go| new file mode 100644 |
| index 000000000..7ebbd973b |
| --- /dev/null |
| +++ b/internal/release/roothistory_delivery.go |
| @@ -0,0 +1,51 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "strconv" |
| + "time" |
| +) |
| + |
| +func HistoryReconstructionKey(acct, app, identity string, revision uint64) string { |
| + return appPrefix(acct, app) + "release-history/" + identity + "/" + strconv.FormatUint(revision, 10) + ".json" |
| +} |
| + |
| +func PublishHistoryReconstruction(ctx context.Context, store Store, signer Signer, g HistoryReconstruction) error { |
| + if !ValidAccount(g.Account) || !ValidApp(g.App) || len(g.Identity) != 64 || g.Revision == 0 { |
| + return os.ErrPermission |
| + } |
| + if _, err := hex.DecodeString(g.Identity); err != nil { |
| + return os.ErrPermission |
| + } |
| + wire, err := SignRootEnvelope(signer, g) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = store.Put(ctx, HistoryReconstructionKey(g.Account, g.App, g.Identity, g.Revision), wire, "") |
| + return err |
| +} |
| + |
| +func (h *RootHistory) ReceiveReconstruction(ctx context.Context, store Store, pub ed25519.PublicKey, b HistoryBinding, now time.Time, resolve func(HistoryBinding) (string, error)) error { |
| + if !b.valid() || store == nil { |
| + return os.ErrPermission |
| + } |
| + if err := h.ResumeReconstruction(resolve); err != nil { |
| + return err |
| + } |
| + status, err := h.ReconstructionStatus() |
| + if err != nil { |
| + return err |
| + } |
| + wire, _, err := store.Get(ctx, HistoryReconstructionKey(b.Account, b.App, status.Identity, status.Revision)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + return h.Reconstruct(pub, wire, b.Account, b.App, now, resolve) |
| +} |
diff --git a/internal/release/roothistory_pass.go b/internal/release/roothistory_pass.go| index 558385a4b..2e0dfc873 100644 |
| --- a/internal/release/roothistory_pass.go |
| +++ b/internal/release/roothistory_pass.go |
| @@ -13,10 +13,11 @@ import ( |
| ) |
| type RootHistoryPass struct { |
| - History *RootHistory |
| - Resolve func(context.Context, registry.Route) (HistoryBinding, string, error) |
| - Installed func(context.Context, registry.Route, string) (string, error) |
| - Prepare func(context.Context, HistoryBinding, HistoryAttempt, hostwrite.Identity) error |
| + History *RootHistory |
| + Resolve func(context.Context, registry.Route) (HistoryBinding, string, error) |
| + Installed func(context.Context, registry.Route, string) (string, error) |
| + Prepare func(context.Context, HistoryBinding, HistoryAttempt, hostwrite.Identity) error |
| + Reconstruct func(context.Context, HistoryBinding) error |
| } |
| type historySelection struct { |
| @@ -53,6 +54,14 @@ func (h *RootHistoryPass) selectRoute(ctx context.Context, rt registry.Route) (h |
| if h.Installed == nil { |
| return sel, os.ErrPermission |
| } |
| + if h.Reconstruct != nil { |
| + if err := h.Reconstruct(ctx, sel.binding); err != nil { |
| + return sel, err |
| + } |
| + if err := h.recheck(ctx, rt, sel); err != nil { |
| + return sel, err |
| + } |
| + } |
| if _, err := h.History.Programs(sel.binding); err != nil { |
| return sel, err |
| } |
diff --git a/internal/release/roothistory_reconstruct.go b/internal/release/roothistory_reconstruct.go| new file mode 100644 |
| index 000000000..5ce3e6230 |
| --- /dev/null |
| +++ b/internal/release/roothistory_reconstruct.go |
| @@ -0,0 +1,446 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "slices" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const reconstructionKey = "reconstruction.state" |
| + |
| +type HistoryReconstruction struct { |
| + Schema int `json:"schema"` |
| + Kind string `json:"kind"` |
| + Identity string `json:"identity"` |
| + Revision uint64 `json:"revision"` |
| + Operation string `json:"operation"` |
| + IssuedAt time.Time `json:"issued_at"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + AppRevision uint64 `json:"app_revision"` |
| + BakeRevision uint64 `json:"bake_revision"` |
| + Hosts []HistoryReconstructionHost `json:"hosts"` |
| + Production bool `json:"production"` |
| + ProductionSetAt time.Time `json:"production_set_at"` |
| + Holds map[string]HistoryReconstructionHold `json:"holds"` |
| +} |
| + |
| +type HistoryReconstructionHost struct { |
| + Host string `json:"host"` |
| + Source string `json:"source"` |
| + Revision uint64 `json:"revision"` |
| + Refusals []Refusal `json:"refusals"` |
| +} |
| + |
| +type HistoryReconstructionHold struct { |
| + Window time.Duration `json:"window"` |
| + SetAt time.Time `json:"set_at"` |
| +} |
| + |
| +type HistoryReconstructionStatus struct { |
| + Identity string `json:"identity"` |
| + Revision uint64 `json:"revision"` |
| + Pending bool `json:"pending"` |
| +} |
| + |
| +type reconstructionImage struct { |
| + Name string `json:"name"` |
| + Before string `json:"before"` |
| + After json.RawMessage `json:"after"` |
| +} |
| + |
| +type reconstructionIntent struct { |
| + Grant HistoryReconstruction `json:"grant"` |
| + Digest string `json:"digest"` |
| + Images []reconstructionImage `json:"images"` |
| +} |
| + |
| +type reconstructionState struct { |
| + Schema int `json:"schema"` |
| + Identity string `json:"identity"` |
| + Revision uint64 `json:"revision"` |
| + Last string `json:"last,omitempty"` |
| + Pending *reconstructionIntent `json:"pending,omitempty"` |
| +} |
| + |
| +func reconstructionDigest(b []byte) string { |
| + s := sha256.Sum256(b) |
| + return hex.EncodeToString(s[:]) |
| +} |
| + |
| +func (h *RootHistory) reconstructionState() (reconstructionState, error) { |
| + var s reconstructionState |
| + if h == nil || h.Store == nil { |
| + return s, os.ErrPermission |
| + } |
| + b, err := h.Store.Read(reconstructionKey) |
| + if err != nil { |
| + return s, err |
| + } |
| + if len(b) > rootstate.MaxRecord || strictCanonicalJSON(b, &s) != nil || s.Schema != 1 || len(s.Identity) != 64 || s.Revision == 0 { |
| + return reconstructionState{}, os.ErrPermission |
| + } |
| + if _, err := hex.DecodeString(s.Identity); err != nil { |
| + return reconstructionState{}, os.ErrPermission |
| + } |
| + if err := h.Store.Write(reconstructionKey, b); err != nil { |
| + return reconstructionState{}, err |
| + } |
| + return s, nil |
| +} |
| + |
| +func (h *RootHistory) writeReconstruction(s reconstructionState) error { |
| + b, err := json.Marshal(s) |
| + if err != nil { |
| + return err |
| + } |
| + if len(b) > rootstate.MaxRecord { |
| + return os.ErrPermission |
| + } |
| + return h.Store.Write(reconstructionKey, b) |
| +} |
| + |
| +func (h *RootHistory) ReconstructionStatus() (HistoryReconstructionStatus, error) { |
| + s, err := h.reconstructionState() |
| + if errors.Is(err, os.ErrNotExist) { |
| + var id [32]byte |
| + if _, err := rand.Read(id[:]); err != nil { |
| + return HistoryReconstructionStatus{}, err |
| + } |
| + s = reconstructionState{Schema: 1, Identity: hex.EncodeToString(id[:]), Revision: 1} |
| + err = h.writeReconstruction(s) |
| + } |
| + if err != nil { |
| + return HistoryReconstructionStatus{}, err |
| + } |
| + return HistoryReconstructionStatus{Identity: s.Identity, Revision: s.Revision, Pending: s.Pending != nil}, nil |
| +} |
| + |
| +func (h *RootHistory) reconstructionReady() error { |
| + s, err := h.reconstructionState() |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if s.Pending != nil { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func reconstructionSources(g HistoryReconstruction, resolve func(HistoryBinding) (string, error)) error { |
| + if resolve == nil || !ValidAccount(g.Account) || !ValidApp(g.App) || len(g.Hosts) == 0 || len(g.Hosts) > historyLimit { |
| + return os.ErrPermission |
| + } |
| + seen := map[string]bool{} |
| + for _, host := range g.Hosts { |
| + b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} |
| + if !b.valid() || seen[host.Host] || host.Source == "" { |
| + return os.ErrPermission |
| + } |
| + seen[host.Host] = true |
| + source, err := resolve(b) |
| + if err != nil { |
| + return err |
| + } |
| + if source != host.Source { |
| + return os.ErrPermission |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (h *RootHistory) Reconstruct(pub ed25519.PublicKey, wire []byte, acct, app string, now time.Time, resolve func(HistoryBinding) (string, error)) error { |
| + if len(wire) > rootstate.MaxRecord || now.IsZero() { |
| + return os.ErrPermission |
| + } |
| + var g HistoryReconstruction |
| + if err := VerifyRootEnvelope(pub, wire, &g); err != nil { |
| + return err |
| + } |
| + if g.Schema != 1 || g.Kind != "carlos-release-history" || g.Operation == "" || len(g.Operation) > 256 || g.Account != acct || g.App != app || |
| + g.IssuedAt.Before(now.Add(-HostRetryGrantTTL)) || g.IssuedAt.After(now.Add(hostRetrySkew)) { |
| + return os.ErrPermission |
| + } |
| + if err := reconstructionSources(g, resolve); err != nil { |
| + return err |
| + } |
| + s, err := h.reconstructionState() |
| + if err != nil { |
| + return err |
| + } |
| + if s.Identity != g.Identity { |
| + return os.ErrPermission |
| + } |
| + raw, err := json.Marshal(g) |
| + if err != nil { |
| + return err |
| + } |
| + digest := reconstructionDigest(raw) |
| + if s.Pending != nil { |
| + if s.Pending.Digest != digest { |
| + return os.ErrPermission |
| + } |
| + return h.resumeReconstruction(s, resolve) |
| + } |
| + if s.Last == digest { |
| + return nil |
| + } |
| + if s.Revision != g.Revision || s.Revision == ^uint64(0) { |
| + return os.ErrPermission |
| + } |
| + images, err := h.reconstructionImages(g, now) |
| + if err != nil { |
| + return err |
| + } |
| + if err := reconstructionSources(g, resolve); err != nil { |
| + return err |
| + } |
| + s.Pending = &reconstructionIntent{Grant: g, Digest: digest, Images: images} |
| + if err := h.writeReconstruction(s); err != nil { |
| + return err |
| + } |
| + return h.resumeReconstruction(s, resolve) |
| +} |
| + |
| +func (h *RootHistory) ResumeReconstruction(resolve func(HistoryBinding) (string, error)) error { |
| + s, err := h.reconstructionState() |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if s.Pending == nil { |
| + return nil |
| + } |
| + return h.resumeReconstruction(s, resolve) |
| +} |
| + |
| +func (h *RootHistory) resumeReconstruction(s reconstructionState, resolve func(HistoryBinding) (string, error)) error { |
| + p := s.Pending |
| + if p == nil || s.Revision == ^uint64(0) || len(p.Images) != len(p.Grant.Hosts)+2 || p.Grant.Identity != s.Identity || p.Grant.Revision != s.Revision { |
| + return os.ErrPermission |
| + } |
| + raw, err := json.Marshal(p.Grant) |
| + if err != nil || reconstructionDigest(raw) != p.Digest { |
| + return os.ErrPermission |
| + } |
| + if err := reconstructionSources(p.Grant, resolve); err != nil { |
| + return err |
| + } |
| + allowed := map[string]bool{rootBakeKey: true, historyAppKey(p.Grant.Account, p.Grant.App): true} |
| + for _, host := range p.Grant.Hosts { |
| + allowed[historyKey(HistoryBinding{Host: host.Host, Account: p.Grant.Account, App: p.Grant.App})] = true |
| + } |
| + for _, im := range p.Images { |
| + if !allowed[im.Name] || len(im.After) == 0 { |
| + return os.ErrPermission |
| + } |
| + delete(allowed, im.Name) |
| + old, err := h.Store.Read(im.Name) |
| + before := "absent" |
| + if err == nil { |
| + before = reconstructionDigest(old) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + if before != im.Before && before != reconstructionDigest(im.After) { |
| + return os.ErrPermission |
| + } |
| + } |
| + for _, im := range p.Images { |
| + if err := h.Store.Write(im.Name, im.After); err != nil { |
| + return err |
| + } |
| + } |
| + if err := reconstructionSources(p.Grant, resolve); err != nil { |
| + return err |
| + } |
| + s.Revision++ |
| + s.Last = p.Digest |
| + s.Pending = nil |
| + return h.writeReconstruction(s) |
| +} |
| + |
| +func (h *RootHistory) reconstructionImages(g HistoryReconstruction, now time.Time) ([]reconstructionImage, error) { |
| + var images []reconstructionImage |
| + add := func(name string, v any) error { |
| + before := "absent" |
| + old, err := h.Store.Read(name) |
| + if err == nil { |
| + before = reconstructionDigest(old) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + raw, err := json.Marshal(v) |
| + if err != nil { |
| + return err |
| + } |
| + images = append(images, reconstructionImage{Name: name, Before: before, After: raw}) |
| + return nil |
| + } |
| + a := historyApp{Schema: 1, Account: g.Account, App: g.App, Hosts: []string{}} |
| + if raw, err := h.Store.Read(historyAppKey(g.Account, g.App)); err == nil { |
| + if strictCanonicalJSON(raw, &a) != nil || !validHistoryApp(a) || a.Account != g.Account || a.App != g.App { |
| + return nil, os.ErrPermission |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + if a.Revision != g.AppRevision || a.Revision == ^uint64(0) { |
| + return nil, os.ErrPermission |
| + } |
| + names, err := h.Store.Names() |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, name := range names { |
| + if name == rootBakeKey || !strings.HasSuffix(name, ".json") { |
| + continue |
| + } |
| + raw, err := h.Store.Read(name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r historyRecord |
| + if strictCanonicalJSON(raw, &r) != nil || !validHistory(r, r.Binding) || historyKey(r.Binding) != name || r.Attempt != nil { |
| + return nil, os.ErrPermission |
| + } |
| + if r.Binding.Account == g.Account && r.Binding.App == g.App && !slices.Contains(a.Hosts, r.Binding.Host) && !slices.ContainsFunc(g.Hosts, func(host HistoryReconstructionHost) bool { return host.Host == r.Binding.Host }) { |
| + return nil, os.ErrPermission |
| + } |
| + } |
| + for _, host := range g.Hosts { |
| + b := HistoryBinding{Host: host.Host, Account: g.Account, App: g.App} |
| + r, err := h.read(b) |
| + if errors.Is(err, os.ErrNotExist) { |
| + r = historyRecord{Schema: 1, Binding: b} |
| + } else if err != nil { |
| + return nil, err |
| + } |
| + if r.Revision != host.Revision || r.Attempt != nil || r.Revision == ^uint64(0) { |
| + return nil, os.ErrPermission |
| + } |
| + r.Revision++ |
| + if r.Programs.Revision == 0 { |
| + r.Programs.Revision = r.Revision |
| + } |
| + r.Refusals.Revision, r.Refusals.Complete = r.Revision, true |
| + for _, ref := range host.Refusals { |
| + ref.Recovered = false |
| + if !slices.Contains(r.Refusals.Values, ref) { |
| + r.Refusals.Values = append(r.Refusals.Values, ref) |
| + } |
| + } |
| + if !validHistory(r, b) { |
| + return nil, os.ErrPermission |
| + } |
| + if err := add(historyKey(b), r); err != nil { |
| + return nil, err |
| + } |
| + if !slices.Contains(a.Hosts, b.Host) { |
| + a.Hosts = append(a.Hosts, b.Host) |
| + } |
| + } |
| + a.Revision++ |
| + a.Complete = true |
| + if !validHistoryApp(a) { |
| + return nil, os.ErrPermission |
| + } |
| + if err := add(historyAppKey(g.Account, g.App), a); err != nil { |
| + return nil, err |
| + } |
| + bake := rootBakeRecord{Schema: 1, Complete: map[string]uint64{}, Clock: FirstSeen{Channels: map[string]map[string]time.Time{}}} |
| + if raw, err := h.Store.Read(rootBakeKey); err == nil { |
| + if strictCanonicalJSON(raw, &bake) != nil || !validRootBake(bake) || now.Before(bake.Observed) { |
| + return nil, os.ErrPermission |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + if bake.Revision != g.BakeRevision || bake.Revision == ^uint64(0) || len(g.Holds) > historyLimit { |
| + return nil, os.ErrPermission |
| + } |
| + bake.Revision++ |
| + bake.Observed = now.UTC() |
| + bake.Complete[prodKey(g.Account, g.App)] = bake.Revision |
| + for key, versions := range bake.Clock.Channels { |
| + if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { |
| + continue |
| + } |
| + for version := range versions { |
| + versions[version] = now.UTC() |
| + } |
| + } |
| + if bake.Clock.Production == nil { |
| + bake.Clock.Production = map[string]*prodMemory{} |
| + } |
| + if bake.Clock.Holds == nil { |
| + bake.Clock.Holds = map[string]*holdMemory{} |
| + } |
| + pk := prodKey(g.Account, g.App) |
| + prod := bake.Clock.Production[pk] |
| + if prod == nil { |
| + prod = &prodMemory{} |
| + bake.Clock.Production[pk] = prod |
| + } |
| + if !prod.OffFirstSeen.IsZero() { |
| + prod.OffFirstSeen = now.UTC() |
| + } |
| + if g.Production { |
| + prod.WasProduction = true |
| + prod.OffSetAt, prod.OffFirstSeen = time.Time{}, time.Time{} |
| + } |
| + if g.ProductionSetAt.After(prod.NewestSetAt) { |
| + prod.NewestSetAt = g.ProductionSetAt |
| + } |
| + for key, mem := range bake.Clock.Holds { |
| + if !strings.HasPrefix(key, holdKeyPrefix(g.Account, g.App)) { |
| + continue |
| + } |
| + if !mem.PendingAt.IsZero() { |
| + mem.PendingAt = now.UTC() |
| + } |
| + if !mem.DeletedAt.IsZero() { |
| + mem.DeletedAt = now.UTC() |
| + } |
| + } |
| + for channel, bound := range g.Holds { |
| + if !ValidChannel(channel) || bound.Window < 0 { |
| + return nil, os.ErrPermission |
| + } |
| + key := channelClockKey(g.Account, g.App, channel) |
| + mem := bake.Clock.Holds[key] |
| + if mem == nil { |
| + mem = &holdMemory{} |
| + bake.Clock.Holds[key] = mem |
| + } |
| + if bound.Window > mem.Window { |
| + mem.Window = bound.Window |
| + mem.Pending = 0 |
| + mem.PendingAt = time.Time{} |
| + } |
| + if bound.SetAt.After(mem.SetAt) { |
| + mem.SetAt = bound.SetAt |
| + } |
| + } |
| + if !validRootBake(bake) { |
| + return nil, os.ErrPermission |
| + } |
| + if err := add(rootBakeKey, bake); err != nil { |
| + return nil, err |
| + } |
| + return images, nil |
| +} |