@xemahq/xema 0.25.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -37
- package/dist/assets/appliance-base/images.lock.json +126 -126
- package/dist/assets/appliance-lean/images.lock.json +123 -123
- package/dist/assets/cloud/images.lock.json +126 -126
- package/dist/assets/core/images.lock.json +120 -120
- package/dist/assets/distribution-assets.manifest.json +15 -16
- package/dist/assets/minimal/images.lock.json +108 -108
- package/dist/assets/oss/images.lock.json +126 -126
- package/dist/assets/starter/images.lock.json +123 -123
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +6 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/backing-registration.d.ts.map +1 -1
- package/dist/runtime/backing-registration.js +7 -2
- package/dist/runtime/backing-registration.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +3 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +20 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +3 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +37 -8
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +3 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +2 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +270 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +99 -0
- package/edition/release-edition-artifact.mjs +158 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +430 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +18 -7
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -154
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- package/dist/runtime/runner/chart-trust.js.map +0 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// fleet-coordinate-finality — has this coordinate FINISHED MOVING?
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// A seal pins what a MUTABLE TAG currently resolves to. `imageRef:contentHash`
|
|
7
|
+
// is a build-input key, not a content address: two builds of one input set wear
|
|
8
|
+
// the same tag, and the arm64 lane REPUBLISHES that tag by design —
|
|
9
|
+
// `imagetools create -t :<contentHash>` writes a NEW index at the SAME
|
|
10
|
+
// coordinate. So sealing a coordinate that is still awaiting its arm64
|
|
11
|
+
// supplement pins a manifest the tag will stop serving.
|
|
12
|
+
//
|
|
13
|
+
// THE REGRESSION THIS WAS WRITTEN FOR, measured — and how it is now graded
|
|
14
|
+
// -----------------------------------------------------------------------
|
|
15
|
+
// `xema-internal` was sealed at 09:25 on 2026-09-18 while 40 of its coordinates
|
|
16
|
+
// were still amd64-only, awaiting the supplement. The arm64 backfill — the
|
|
17
|
+
// CORRECT repair — then moved those digests, and 63 of its 66 seals named a
|
|
18
|
+
// manifest their tag no longer served.
|
|
19
|
+
//
|
|
20
|
+
// Its own per-edition verification PASSED, and passed CORRECTLY: `xema-internal`
|
|
21
|
+
// declares `requiredPlatforms: ["linux/amd64"]`, and amd64-only satisfies that.
|
|
22
|
+
// No rule the sealer could evaluate was violated.
|
|
23
|
+
//
|
|
24
|
+
// WHY A PER-EDITION CHECK CANNOT CATCH IT — the whole point of this module
|
|
25
|
+
// -----------------------------------------------------------------------
|
|
26
|
+
// `appliance-base` and `appliance-lean` declare `linux/arm64`, and they share 63
|
|
27
|
+
// of those coordinates with `xema-internal` by identical `imageRef` AND
|
|
28
|
+
// `contentHash`. So `xema-internal`'s stability depended on a platform
|
|
29
|
+
// requirement declared in an edition it cannot see.
|
|
30
|
+
//
|
|
31
|
+
// > PLATFORM VERIFICATION IS PER-EDITION. FINALITY IS A FLEET PROPERTY.
|
|
32
|
+
//
|
|
33
|
+
// OWNER RULING 2026-09-29 NARROWS WHAT IS REFUSED. Finality is graded against
|
|
34
|
+
// the sealing edition's OWN requiredPlatforms. The xema-internal shape above —
|
|
35
|
+
// an amd64-only edition sealing while another edition's arm64 is pending — is
|
|
36
|
+
// now ALLOWED and RECORDED (`pendingElsewhere`), the residual accepted: its
|
|
37
|
+
// sealed digest stays resolvable and serves every platform it requires. A
|
|
38
|
+
// coordinate pending a platform the sealing edition itself declares is still
|
|
39
|
+
// refused. The fleet map is still built from every edition, because the
|
|
40
|
+
// recorded residual must name who is waiting.
|
|
41
|
+
//
|
|
42
|
+
// A per-edition sealer cannot express that even in principle. This module is the
|
|
43
|
+
// fleet-scoped half, and it lives HERE — beside the sealer, in the repository
|
|
44
|
+
// that holds every edition's lock — because that is where the data and the edit
|
|
45
|
+
// both are. An aggregator gate would grade this repository at a RECORDED
|
|
46
|
+
// GITLINK: it would fire late, in a repository nobody was editing, about a
|
|
47
|
+
// change made somewhere else.
|
|
48
|
+
//
|
|
49
|
+
// IT RESOLVES NOTHING ITSELF
|
|
50
|
+
// --------------------------
|
|
51
|
+
// `verifyDistributionPlatforms` already fetches each index, hash-checks the
|
|
52
|
+
// bytes, walks every child manifest BY DIGEST and reads each image CONFIG for
|
|
53
|
+
// its real platform — and it already returns `publishedPlatforms`, the FULL set
|
|
54
|
+
// the coordinate serves rather than only the subset this edition required. That
|
|
55
|
+
// return value is the input here. There is no second registry walk and no
|
|
56
|
+
// second opinion about what a coordinate serves.
|
|
57
|
+
//
|
|
58
|
+
// WHAT IT DOES NOT COVER, said out loud rather than discovered
|
|
59
|
+
// -----------------------------------------------------------
|
|
60
|
+
// The fleet is the EDITIONS ROOT IT IS GIVEN. Run against a customer's own root
|
|
61
|
+
// it grades that customer's editions and cannot see the vendor's, because they
|
|
62
|
+
// are not there to read. That is why every verdict prints its corpus — the
|
|
63
|
+
// number of editions and coordinates graded — so the scope of an answer is
|
|
64
|
+
// visible in the answer rather than assumed from the fact that it passed.
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
68
|
+
import { join } from 'node:path';
|
|
69
|
+
|
|
70
|
+
import { discoverEditionDirectories } from './distribution-paths.mjs';
|
|
71
|
+
import { platformIdentity } from './verify-image-platforms.mjs';
|
|
72
|
+
|
|
73
|
+
export const IMAGE_LOCK_FILENAME = 'images.lock.json';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The coordinate a subject names, in the ONE spelling the verifier uses.
|
|
77
|
+
*
|
|
78
|
+
* `verifyDistributionPlatforms` builds `${imageRef}:${contentHash}` and returns
|
|
79
|
+
* it as `coordinate` on every verified subject. This function must produce the
|
|
80
|
+
* same string for the lock-side map, or the two halves would silently fail to
|
|
81
|
+
* join and every lookup would miss — which is a refusal that grades nothing
|
|
82
|
+
* while reporting success. The join is asserted rather than trusted: see the
|
|
83
|
+
* unknown-coordinate throw in {@link assertCoordinatesFinal}.
|
|
84
|
+
*/
|
|
85
|
+
export function coordinateOf(subject) {
|
|
86
|
+
return `${subject.imageRef}:${subject.contentHash}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pure: every platform any edition requires at each coordinate, and WHO
|
|
91
|
+
* requires it.
|
|
92
|
+
*
|
|
93
|
+
* `editions` is `[{ distributionId, subjects }]` — already read, so the
|
|
94
|
+
* decision table is testable without a filesystem or a registry.
|
|
95
|
+
*
|
|
96
|
+
* The returned shape is `Map<coordinate, Map<platformIdentity, string[]>>`,
|
|
97
|
+
* where the array is the sorted edition ids requiring that platform there. The
|
|
98
|
+
* REQUIRERS are carried rather than only the platforms because a refusal that
|
|
99
|
+
* cannot name who is still waiting sends the reader to look for the reason
|
|
100
|
+
* themselves, and the reason is in another edition's declaration.
|
|
101
|
+
*/
|
|
102
|
+
export function collectFleetPlatformRequirements({ editions }) {
|
|
103
|
+
if (!Array.isArray(editions) || editions.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'fleet-coordinate-finality: no edition was supplied; a fleet requirement map ' +
|
|
106
|
+
'built over zero editions would make every coordinate look final, which is ' +
|
|
107
|
+
'the refusal passing while grading nothing',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const byCoordinate = new Map();
|
|
111
|
+
for (const edition of editions) {
|
|
112
|
+
const { distributionId, subjects } = edition;
|
|
113
|
+
if (typeof distributionId !== 'string' || distributionId.length === 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'fleet-coordinate-finality: an edition was supplied with no distributionId',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(subjects) || subjects.length === 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`fleet-coordinate-finality: "${distributionId}" has an image lock declaring no ` +
|
|
121
|
+
'subjects. An edition that contributes no coordinate is indistinguishable ' +
|
|
122
|
+
'from one this pass failed to read, so it is refused rather than skipped.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
for (const subject of subjects) {
|
|
126
|
+
if (
|
|
127
|
+
!Array.isArray(subject.requiredPlatforms) ||
|
|
128
|
+
subject.requiredPlatforms.length === 0
|
|
129
|
+
) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`fleet-coordinate-finality: "${distributionId}" subject ${subject.kind}:${subject.id} ` +
|
|
132
|
+
'declares no requiredPlatforms. It would contribute nothing to the fleet map ' +
|
|
133
|
+
'and silently weaken the refusal for every edition sharing its coordinate.',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const coordinate = coordinateOf(subject);
|
|
137
|
+
let platforms = byCoordinate.get(coordinate);
|
|
138
|
+
if (!platforms) {
|
|
139
|
+
platforms = new Map();
|
|
140
|
+
byCoordinate.set(coordinate, platforms);
|
|
141
|
+
}
|
|
142
|
+
for (const platform of subject.requiredPlatforms) {
|
|
143
|
+
const identity = platformIdentity(platform);
|
|
144
|
+
let requirers = platforms.get(identity);
|
|
145
|
+
if (!requirers) {
|
|
146
|
+
requirers = new Set();
|
|
147
|
+
platforms.set(identity, requirers);
|
|
148
|
+
}
|
|
149
|
+
requirers.add(distributionId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Freeze the requirer sets into sorted arrays so a message built from this map
|
|
154
|
+
// is deterministic, and so a caller cannot mutate the map it was handed.
|
|
155
|
+
return new Map(
|
|
156
|
+
[...byCoordinate].map(([coordinate, platforms]) => [
|
|
157
|
+
coordinate,
|
|
158
|
+
new Map(
|
|
159
|
+
[...platforms].map(([identity, requirers]) => [
|
|
160
|
+
identity,
|
|
161
|
+
[...requirers].sort(),
|
|
162
|
+
]),
|
|
163
|
+
),
|
|
164
|
+
]),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read every edition's image lock under `editionsRoot`, in BOTH tiers.
|
|
170
|
+
*
|
|
171
|
+
* A pending-merge edition is a real edition that will ship its coordinates, so
|
|
172
|
+
* a platform IT requires is just as much a reason a coordinate can still move.
|
|
173
|
+
* Measured 2026-09-19: the two pending editions carry a declaration and no lock,
|
|
174
|
+
* so today this widening admits nothing — which is the point at which to add it,
|
|
175
|
+
* rather than after a pending edition has acquired one.
|
|
176
|
+
*
|
|
177
|
+
* TWO ABSENCES THAT ARE NOT THE SAME, and conflating them is how this check
|
|
178
|
+
* would quietly stop working:
|
|
179
|
+
*
|
|
180
|
+
* * NO LOCK FILE — the edition makes no coordinate claim at all. It
|
|
181
|
+
* contributes nothing, legitimately, and is COUNTED and reported so the
|
|
182
|
+
* corpus line shows it was seen and skipped.
|
|
183
|
+
* * A LOCK THAT CANNOT BE READ — refused. An unreadable lock contributes no
|
|
184
|
+
* requirements either, and the difference between "requires nothing" and
|
|
185
|
+
* "we failed to find out" is the whole value of the refusal.
|
|
186
|
+
*
|
|
187
|
+
* `JSON.parse` rather than the kernel's `parseImageLock` is deliberate. Only
|
|
188
|
+
* three fields are read — `imageRef`, `contentHash`, `requiredPlatforms` — and
|
|
189
|
+
* requiring a BUILT KERNEL to grade the OTHER editions would make this refusal
|
|
190
|
+
* unrunnable in exactly the checkouts that have no kernel to build. The edition
|
|
191
|
+
* being sealed is still parsed by the kernel on the path that seals it.
|
|
192
|
+
*/
|
|
193
|
+
export function readFleetImageLocks(editionsRoot) {
|
|
194
|
+
const editions = [];
|
|
195
|
+
const withoutLock = [];
|
|
196
|
+
for (const { id, directory } of discoverEditionDirectories(editionsRoot)) {
|
|
197
|
+
const lockPath = join(directory, IMAGE_LOCK_FILENAME);
|
|
198
|
+
if (!existsSync(lockPath)) {
|
|
199
|
+
withoutLock.push(id);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`fleet-coordinate-finality: cannot read ${lockPath}: ${error.message}. ` +
|
|
208
|
+
'Refusing to grade coordinate finality over a fleet this pass could not ' +
|
|
209
|
+
'fully read — an unreadable lock declares no requirement, which would make ' +
|
|
210
|
+
'a coordinate look final because it was never asked about.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
editions.push({ distributionId: id, subjects: raw.subjects });
|
|
214
|
+
}
|
|
215
|
+
if (editions.length === 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`fleet-coordinate-finality: found no image lock under ${editionsRoot}; an empty ` +
|
|
218
|
+
'scan is not a result',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return { editions, withoutLock: withoutLock.sort() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Refuse to seal a coordinate THIS edition still awaits a platform at; record —
|
|
226
|
+
* and allow — one only ANOTHER edition still awaits.
|
|
227
|
+
*
|
|
228
|
+
* Owner ruling 2026-09-29: finality is TARGET-PLATFORM aware. A coordinate
|
|
229
|
+
* pending only a platform this edition does not declare is the accepted
|
|
230
|
+
* residual, returned as `pendingElsewhere` so the caller prints it. A later
|
|
231
|
+
* supplement for that other edition may move the tag's digest; this edition's
|
|
232
|
+
* seal keeps pinning the manifest it verified, which still resolves by digest
|
|
233
|
+
* and still carries every platform this edition requires.
|
|
234
|
+
*
|
|
235
|
+
* `verified` is `verifyDistributionPlatforms(...).subjects`, each carrying
|
|
236
|
+
* `coordinate` and `publishedPlatforms` — the platforms the registry ACTUALLY
|
|
237
|
+
* serves there, which is strictly wider than the subset this edition required.
|
|
238
|
+
*
|
|
239
|
+
* THE SUBJECT SET IS THE WRITES, NOT THE LOCK. This grades the subjects about to
|
|
240
|
+
* RECEIVE a digest, because that is the moment the regression is introduced. An
|
|
241
|
+
* already-sealed subject is past it: re-refusing one would make the daily
|
|
242
|
+
* reconciler permanently red with no action available to clear it, and a gate
|
|
243
|
+
* that cannot be cleared is one people learn to bypass.
|
|
244
|
+
*
|
|
245
|
+
* Throws with `error.notFinal === true` — a PROPERTY, never a message match, so
|
|
246
|
+
* the reconciler's skip cannot silently widen the day this wording changes. Same
|
|
247
|
+
* discipline as `error.status` on the not-published-yet path.
|
|
248
|
+
*/
|
|
249
|
+
export function assertCoordinatesFinal({
|
|
250
|
+
distributionId,
|
|
251
|
+
writes,
|
|
252
|
+
verified,
|
|
253
|
+
fleetRequirements,
|
|
254
|
+
}) {
|
|
255
|
+
const graded = [];
|
|
256
|
+
const pendingElsewhere = [];
|
|
257
|
+
if (!Array.isArray(writes) || writes.length === 0) {
|
|
258
|
+
return { distributionId, graded, coordinateCount: 0, pendingElsewhere };
|
|
259
|
+
}
|
|
260
|
+
const byKey = new Map(
|
|
261
|
+
verified.map((entry) => [`${entry.subjectKind}:${entry.subjectId}`, entry]),
|
|
262
|
+
);
|
|
263
|
+
const blockers = [];
|
|
264
|
+
for (const write of writes) {
|
|
265
|
+
const entry = byKey.get(write.key);
|
|
266
|
+
if (!entry) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`fleet-coordinate-finality: ${write.key} is planned for sealing but has no ` +
|
|
269
|
+
'verification result to read published platforms from; refusing to grade ' +
|
|
270
|
+
'finality for a subject the verifier did not cover',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const required = fleetRequirements.get(entry.coordinate);
|
|
274
|
+
if (!required) {
|
|
275
|
+
// NON-VACUITY GUARD. The edition being sealed is itself one of the
|
|
276
|
+
// editions read into the map, so every coordinate it seals MUST be in
|
|
277
|
+
// there. A miss means the two halves are keyed differently and every
|
|
278
|
+
// lookup would return nothing — a refusal that passes while grading
|
|
279
|
+
// nothing, which is this tree's most-repeated defect.
|
|
280
|
+
throw new Error(
|
|
281
|
+
`fleet-coordinate-finality: coordinate ${entry.coordinate} (${write.key}) is absent ` +
|
|
282
|
+
'from the fleet requirement map, which cannot happen while the edition being ' +
|
|
283
|
+
'sealed is itself in that map. The lock-side and verifier-side coordinate ' +
|
|
284
|
+
'spellings have diverged; refusing rather than passing over a lookup that ' +
|
|
285
|
+
'silently misses.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const served = new Set(entry.publishedPlatforms ?? []);
|
|
289
|
+
const unmet = [...required]
|
|
290
|
+
.filter(([identity]) => !served.has(identity))
|
|
291
|
+
.map(([identity, requirers]) => ({ platform: identity, requiredBy: requirers }));
|
|
292
|
+
graded.push({ key: write.key, coordinate: entry.coordinate });
|
|
293
|
+
// TARGET-PLATFORM AWARE (owner ruling 2026-09-29). Finality is graded
|
|
294
|
+
// against THIS edition's own requiredPlatforms: a platform only ANOTHER
|
|
295
|
+
// edition awaits here is the accepted residual — allowed, and RECORDED so
|
|
296
|
+
// the caller can print it. A platform this edition itself declares and the
|
|
297
|
+
// registry does not serve is still refused.
|
|
298
|
+
const own = unmet.filter((u) => u.requiredBy.includes(distributionId));
|
|
299
|
+
const foreign = unmet.filter((u) => !u.requiredBy.includes(distributionId));
|
|
300
|
+
if (foreign.length > 0) {
|
|
301
|
+
pendingElsewhere.push({
|
|
302
|
+
key: write.key,
|
|
303
|
+
coordinate: entry.coordinate,
|
|
304
|
+
unmet: foreign,
|
|
305
|
+
served: [...served].sort(),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (own.length > 0) {
|
|
309
|
+
blockers.push({ key: write.key, coordinate: entry.coordinate, unmet: own, served: [...served].sort() });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (blockers.length > 0) {
|
|
313
|
+
const detail = blockers
|
|
314
|
+
.slice(0, 6)
|
|
315
|
+
.map(
|
|
316
|
+
(b) =>
|
|
317
|
+
` ${b.key} ${b.coordinate}\n` +
|
|
318
|
+
b.unmet
|
|
319
|
+
.map(
|
|
320
|
+
(u) =>
|
|
321
|
+
` awaits ${u.platform}, required by ${u.requiredBy.join(', ')} ` +
|
|
322
|
+
`(serves: ${b.served.join(', ') || 'nothing'})`,
|
|
323
|
+
)
|
|
324
|
+
.join('\n'),
|
|
325
|
+
)
|
|
326
|
+
.join('\n');
|
|
327
|
+
const error = new Error(
|
|
328
|
+
`fleet-coordinate-finality: "${distributionId}" cannot be sealed — ${blockers.length} of ` +
|
|
329
|
+
`${writes.length} coordinate(s) are NOT FINAL for a platform THIS edition requires: ` +
|
|
330
|
+
'the registry does not yet serve it there, so the coordinate will be supplemented ' +
|
|
331
|
+
'— and the arm64 lane supplements by REPUBLISHING the tag (`imagetools create -t ' +
|
|
332
|
+
':<contentHash>`), which would move the very digest this seal is about to pin.\n' +
|
|
333
|
+
`${detail}${blockers.length > 6 ? `\n ... and ${blockers.length - 6} more` : ''}\n` +
|
|
334
|
+
'Complete the supplement for this edition\'s own platforms, then seal.',
|
|
335
|
+
);
|
|
336
|
+
error.notFinal = true;
|
|
337
|
+
error.blockers = blockers;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
return { distributionId, graded, coordinateCount: graded.length, pendingElsewhere };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Is this failure "another edition can still supplement this coordinate"?
|
|
345
|
+
*
|
|
346
|
+
* A PROPERTY, never a message match — see {@link assertCoordinatesFinal}.
|
|
347
|
+
*/
|
|
348
|
+
export function isNotFinalYet(error) {
|
|
349
|
+
return Boolean(error) && error.notFinal === true;
|
|
350
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// git-source-auth — the ONE place the edition library turns a read token into git
|
|
2
|
+
// authentication for a repository URL, shared by every script that fetches or
|
|
3
|
+
// lists a repository (first-party sources and a customer's own git source alike).
|
|
4
|
+
//
|
|
5
|
+
// The credential reaches git through GIT_CONFIG_* environment variables, never
|
|
6
|
+
// argv, a file or a clone's config, and it is scoped to the ORIGIN of the URL it
|
|
7
|
+
// is for: `http.<origin>/.extraHeader` is applied by git only to URLs under that
|
|
8
|
+
// origin, so the token is not sent to any other host, nor to a host a redirect
|
|
9
|
+
// leads to. For a first-party source pass `sourceRepositoryUrlPrefix` (from
|
|
10
|
+
// edition-workspace.json); for a customer git source pass its own url.
|
|
11
|
+
//
|
|
12
|
+
// Why this is not `gitEnvWithAuth` of the CLI's runtime (src/runtime/sources/kinds/git.ts):
|
|
13
|
+
// the edition library is plain ESM that `node` runs straight from this directory
|
|
14
|
+
// and that the evidence verifier bundle is built from, so it cannot import the CLI's
|
|
15
|
+
// TypeScript sources or its compiled CommonJS output. The shape is the same
|
|
16
|
+
// on purpose (Basic `<declared username>:<token>`, `Authorization` header, origin
|
|
17
|
+
// scope; the username is a required argument, never a built-in default), so there is exactly ONE implementation per runtime.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `base` plus the GIT_CONFIG_* header that authenticates git against `url`'s
|
|
21
|
+
* origin as `username` with `token`. A token that cannot be applied is refused: an empty
|
|
22
|
+
* token, or a URL that is not an absolute https URL with a host. There is no
|
|
23
|
+
* fall-back to the ambient credential helper.
|
|
24
|
+
*/
|
|
25
|
+
export function gitAuthEnv(url, username, token, base = process.env) {
|
|
26
|
+
if (typeof username !== 'string' || username.length === 0 || username.includes(':')) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`a git username is required to authenticate git against ${String(url)} (non-empty, no ":"); ` +
|
|
29
|
+
'it is declared, never defaulted: `sourceAuthUsername` in edition-workspace.json for first-party sources, ' +
|
|
30
|
+
'the source\'s own `authUsername` for a customer git source',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
34
|
+
throw new Error(`a read token is required to authenticate git against ${String(url)}`);
|
|
35
|
+
}
|
|
36
|
+
let origin;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
if (parsed.protocol !== 'https:' || parsed.hostname === '') {throw new Error('not an https URL with a host');}
|
|
40
|
+
origin = parsed.origin;
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`cannot scope a git credential to "${String(url)}": it is not an absolute https URL with a host`);
|
|
43
|
+
}
|
|
44
|
+
const credential = Buffer.from(`${username}:${token}`, 'utf8').toString('base64');
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
GIT_CONFIG_COUNT: '1',
|
|
48
|
+
GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
|
|
49
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${credential}`,
|
|
50
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants as fsConstants,
|
|
5
|
+
fstatSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
canonicalJsonBytes,
|
|
18
|
+
parseCanonicalJsonBytes,
|
|
19
|
+
} from './image-evidence.mjs';
|
|
20
|
+
|
|
21
|
+
const MAX_REPLAY_STATE_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
function openRegularFileNoFollow(path) {
|
|
24
|
+
const descriptor = openSync(
|
|
25
|
+
path,
|
|
26
|
+
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
|
|
27
|
+
);
|
|
28
|
+
const metadata = fstatSync(descriptor);
|
|
29
|
+
if (!metadata.isFile()) {
|
|
30
|
+
closeSync(descriptor);
|
|
31
|
+
throw new Error(`replay state "${path}" is not a regular file`);
|
|
32
|
+
}
|
|
33
|
+
if ((metadata.mode & 0o777) !== 0o600) {
|
|
34
|
+
closeSync(descriptor);
|
|
35
|
+
throw new Error(`replay state "${path}" must have exact mode 0600`);
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
typeof process.getuid === 'function' &&
|
|
39
|
+
metadata.uid !== process.getuid()
|
|
40
|
+
) {
|
|
41
|
+
closeSync(descriptor);
|
|
42
|
+
throw new Error(`replay state "${path}" must be owned by the verifier`);
|
|
43
|
+
}
|
|
44
|
+
return descriptor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertTrustedStateDirectory(path) {
|
|
48
|
+
const descriptor = openSync(
|
|
49
|
+
path,
|
|
50
|
+
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
|
|
51
|
+
);
|
|
52
|
+
try {
|
|
53
|
+
const metadata = fstatSync(descriptor);
|
|
54
|
+
if (!metadata.isDirectory()) {
|
|
55
|
+
throw new Error(`replay state parent "${path}" is not a directory`);
|
|
56
|
+
}
|
|
57
|
+
if ((metadata.mode & 0o777) !== 0o700) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`replay state parent "${path}" must have exact mode 0700`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
typeof process.getuid === 'function' &&
|
|
64
|
+
metadata.uid !== process.getuid()
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`replay state parent "${path}" must be owned by the verifier`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
closeSync(descriptor);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeDurableReplacement(path, value) {
|
|
76
|
+
const directory = realpathSync(dirname(path));
|
|
77
|
+
const temporaryPath = join(
|
|
78
|
+
directory,
|
|
79
|
+
`.${basename(path)}.${randomUUID()}.tmp`,
|
|
80
|
+
);
|
|
81
|
+
let descriptor;
|
|
82
|
+
try {
|
|
83
|
+
descriptor = openSync(
|
|
84
|
+
temporaryPath,
|
|
85
|
+
fsConstants.O_CREAT |
|
|
86
|
+
fsConstants.O_EXCL |
|
|
87
|
+
fsConstants.O_WRONLY |
|
|
88
|
+
fsConstants.O_NOFOLLOW,
|
|
89
|
+
0o600,
|
|
90
|
+
);
|
|
91
|
+
writeFileSync(descriptor, canonicalJsonBytes(value));
|
|
92
|
+
fsyncSync(descriptor);
|
|
93
|
+
closeSync(descriptor);
|
|
94
|
+
descriptor = undefined;
|
|
95
|
+
renameSync(temporaryPath, path);
|
|
96
|
+
const directoryDescriptor = openSync(directory, fsConstants.O_RDONLY);
|
|
97
|
+
try {
|
|
98
|
+
fsyncSync(directoryDescriptor);
|
|
99
|
+
} finally {
|
|
100
|
+
closeSync(directoryDescriptor);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
if (descriptor !== undefined) {closeSync(descriptor);}
|
|
104
|
+
removeIfPresent(temporaryPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove `path`; only its absence is tolerated. */
|
|
109
|
+
function removeIfPresent(path) {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(path);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code !== 'ENOENT') {throw error;}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function verifyAndAdvanceReplayState({
|
|
118
|
+
statePath,
|
|
119
|
+
distributionId,
|
|
120
|
+
trustPolicyDigest,
|
|
121
|
+
parseReplayState,
|
|
122
|
+
verify,
|
|
123
|
+
}) {
|
|
124
|
+
if (!isAbsolute(statePath)) {
|
|
125
|
+
throw new Error('replay state path must be absolute');
|
|
126
|
+
}
|
|
127
|
+
const stateDirectory = realpathSync(dirname(statePath));
|
|
128
|
+
if (stateDirectory !== dirname(statePath)) {
|
|
129
|
+
throw new Error('replay state parent path must be canonical');
|
|
130
|
+
}
|
|
131
|
+
assertTrustedStateDirectory(stateDirectory);
|
|
132
|
+
const canonicalStatePath = join(stateDirectory, basename(statePath));
|
|
133
|
+
const lockPath = `${canonicalStatePath}.lock`;
|
|
134
|
+
let lockDescriptor;
|
|
135
|
+
try {
|
|
136
|
+
lockDescriptor = openSync(
|
|
137
|
+
lockPath,
|
|
138
|
+
fsConstants.O_CREAT |
|
|
139
|
+
fsConstants.O_EXCL |
|
|
140
|
+
fsConstants.O_WRONLY |
|
|
141
|
+
fsConstants.O_NOFOLLOW,
|
|
142
|
+
0o600,
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error?.code === 'EEXIST') {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`replay state "${canonicalStatePath}" is locked by another verifier; explicit operator reconciliation is required for a stale lock`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
writeFileSync(
|
|
155
|
+
lockDescriptor,
|
|
156
|
+
`${process.pid} ${new Date().toISOString()}\n`,
|
|
157
|
+
);
|
|
158
|
+
fsyncSync(lockDescriptor);
|
|
159
|
+
const stateDescriptor = openRegularFileNoFollow(canonicalStatePath);
|
|
160
|
+
let state;
|
|
161
|
+
try {
|
|
162
|
+
const before = fstatSync(stateDescriptor);
|
|
163
|
+
if (before.size > MAX_REPLAY_STATE_BYTES) {
|
|
164
|
+
throw new Error(`replay state exceeds ${MAX_REPLAY_STATE_BYTES} bytes`);
|
|
165
|
+
}
|
|
166
|
+
const stateBytes = Buffer.alloc(before.size);
|
|
167
|
+
let offset = 0;
|
|
168
|
+
while (offset < stateBytes.length) {
|
|
169
|
+
const count = readSync(
|
|
170
|
+
stateDescriptor,
|
|
171
|
+
stateBytes,
|
|
172
|
+
offset,
|
|
173
|
+
stateBytes.length - offset,
|
|
174
|
+
offset,
|
|
175
|
+
);
|
|
176
|
+
if (count === 0) {
|
|
177
|
+
throw new Error('replay state was truncated while reading');
|
|
178
|
+
}
|
|
179
|
+
offset += count;
|
|
180
|
+
}
|
|
181
|
+
if (readSync(stateDescriptor, Buffer.alloc(1), 0, 1, offset) !== 0) {
|
|
182
|
+
throw new Error('replay state grew while reading');
|
|
183
|
+
}
|
|
184
|
+
const after = fstatSync(stateDescriptor);
|
|
185
|
+
for (const field of ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']) {
|
|
186
|
+
if (after[field] !== before[field]) {
|
|
187
|
+
throw new Error('replay state changed while reading');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
state = parseReplayState(
|
|
191
|
+
parseCanonicalJsonBytes(stateBytes, 'distribution image replay state'),
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
closeSync(stateDescriptor);
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
state.distributionId !== distributionId ||
|
|
198
|
+
state.trustPolicyDigest !== trustPolicyDigest
|
|
199
|
+
) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'replay state does not match the selected distribution and committed trust policy',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const verified = await verify(state);
|
|
206
|
+
const completeness = verified.completenessStatement.predicate;
|
|
207
|
+
if (completeness.releaseSequence <= state.lastReleaseSequence) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
'verified completeness evidence does not advance releaseSequence',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (completeness.nonce === state.lastCompletenessNonce) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'verified completeness evidence reuses the last accepted nonce',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
verified.completenessStatementDigest ===
|
|
219
|
+
state.lastCompletenessStatementDigest
|
|
220
|
+
) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
'verified completeness evidence reuses the last accepted statement',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const nextState = parseReplayState({
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
distributionId,
|
|
228
|
+
trustPolicyDigest,
|
|
229
|
+
lastReleaseSequence: completeness.releaseSequence,
|
|
230
|
+
lastCompletenessStatementDigest: verified.completenessStatementDigest,
|
|
231
|
+
lastCompletenessNonce: completeness.nonce,
|
|
232
|
+
});
|
|
233
|
+
writeDurableReplacement(canonicalStatePath, nextState);
|
|
234
|
+
return { verified, replayState: nextState };
|
|
235
|
+
} finally {
|
|
236
|
+
try {
|
|
237
|
+
closeSync(lockDescriptor);
|
|
238
|
+
} finally {
|
|
239
|
+
unlinkSync(lockPath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|