@dsh-enhanced/plugin-control-plane 0.1.6 → 0.1.12
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 +153 -32
- package/bin/dsh-local-release-adapter.js +1381 -0
- package/cordis.patch.yml +1 -0
- package/lib/approval.d.ts +13 -0
- package/lib/approval.d.ts.map +1 -0
- package/lib/approval.js +79 -0
- package/lib/approval.js.map +1 -0
- package/lib/attestation.d.ts +14 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +222 -0
- package/lib/attestation.js.map +1 -0
- package/lib/catalog-interpreter.d.ts +12 -0
- package/lib/catalog-interpreter.d.ts.map +1 -0
- package/lib/catalog-interpreter.js +100 -0
- package/lib/catalog-interpreter.js.map +1 -0
- package/lib/catalog.d.ts +95 -10
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +1031 -18
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +9 -0
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +1160 -162
- package/lib/cli.js.map +1 -1
- package/lib/host-attestor.d.ts +13 -0
- package/lib/host-attestor.d.ts.map +1 -0
- package/lib/host-attestor.js +139 -0
- package/lib/host-attestor.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -1
- package/lib/lockfile.d.ts +7 -0
- package/lib/lockfile.d.ts.map +1 -0
- package/lib/lockfile.js +271 -0
- package/lib/lockfile.js.map +1 -0
- package/lib/release.d.ts +51 -0
- package/lib/release.d.ts.map +1 -0
- package/lib/release.js +1188 -0
- package/lib/release.js.map +1 -0
- package/lib/service.d.ts +9 -11
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +57 -29
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +705 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +258 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +1848 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +23 -3
- package/lib/tools.js.map +1 -1
- package/lib/trust.d.ts +79 -0
- package/lib/trust.d.ts.map +1 -0
- package/lib/trust.js +477 -0
- package/lib/trust.js.map +1 -0
- package/lib/types.d.ts +740 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
#!/usr/bin/node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owner-controlled, local-only Stage 4 release adapter.
|
|
5
|
+
*
|
|
6
|
+
* The control plane supplies no ambient credentials to this program. The only
|
|
7
|
+
* configuration input is one phase-specific, allowlisted
|
|
8
|
+
* DSH_RELEASE_<PHASE>_CONFIG path. That owner-private file selects one fixed
|
|
9
|
+
* phase, a private signing-key path, and the local Git/filesystem resources
|
|
10
|
+
* that phase may use.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import {
|
|
14
|
+
createHash,
|
|
15
|
+
createPrivateKey,
|
|
16
|
+
createPublicKey,
|
|
17
|
+
sign,
|
|
18
|
+
verify,
|
|
19
|
+
} from 'node:crypto'
|
|
20
|
+
import {
|
|
21
|
+
chmodSync,
|
|
22
|
+
closeSync,
|
|
23
|
+
constants as fsConstants,
|
|
24
|
+
cpSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
fsyncSync,
|
|
27
|
+
lstatSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
openSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
readlinkSync,
|
|
32
|
+
realpathSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
readSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
fstatSync,
|
|
38
|
+
statSync,
|
|
39
|
+
symlinkSync,
|
|
40
|
+
unlinkSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
linkSync,
|
|
43
|
+
} from 'node:fs'
|
|
44
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
45
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
46
|
+
|
|
47
|
+
export const LOCAL_RELEASE_ADAPTER_VERSION = 'dsh-local-release-adapter-1'
|
|
48
|
+
const PHASES = new Set(['pr', 'review', 'merge', 'build', 'sign', 'publish', 'registry-verify', 'catalog-admission'])
|
|
49
|
+
const DIGEST = /^[a-f0-9]{64}$/u
|
|
50
|
+
const COMMIT = /^[a-f0-9]{40}$/u
|
|
51
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u
|
|
52
|
+
const PACKAGE = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/u
|
|
53
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u
|
|
54
|
+
const MAX_INPUT_BYTES = 1_048_576
|
|
55
|
+
const MAX_ARTIFACT_BYTES = 268_435_456
|
|
56
|
+
const ARTIFACT_FDS = Object.freeze({ tarball: ['DSH_RELEASE_TARBALL_FD', 3, MAX_ARTIFACT_BYTES],
|
|
57
|
+
sbom: ['DSH_RELEASE_SBOM_FD', 4, 16_777_216], provenance: ['DSH_RELEASE_PROVENANCE_FD', 5, 16_777_216] })
|
|
58
|
+
|
|
59
|
+
class PublishAmbiguity extends Error {
|
|
60
|
+
constructor(detail) { super(detail); this.name = 'PublishAmbiguity' }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function canonicalReleaseValue(value) {
|
|
64
|
+
if (Array.isArray(value)) return `[${value.map(canonicalReleaseValue).join(',')}]`
|
|
65
|
+
if (typeof value === 'object' && value !== null) {
|
|
66
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined)
|
|
67
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
68
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalReleaseValue(item)}`).join(',')}}`
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256Bytes(value) { return createHash('sha256').update(value).digest('hex') }
|
|
74
|
+
function sha512Integrity(value) { return `sha512-${createHash('sha512').update(value).digest('base64')}` }
|
|
75
|
+
function digest(value) { return sha256Bytes(canonicalReleaseValue(value)) }
|
|
76
|
+
function fail(message) { throw new Error(`local release adapter: ${message}`) }
|
|
77
|
+
function object(value, label) {
|
|
78
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) fail(`${label} must be an object`)
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
function text(value, label, pattern = /^.+$/u, maximum = 2_000) {
|
|
82
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > maximum || value.includes('\0')
|
|
83
|
+
|| value.includes('\r') || value.includes('\n') || !pattern.test(value)) fail(`${label} is invalid`)
|
|
84
|
+
return value
|
|
85
|
+
}
|
|
86
|
+
function exactKeys(value, expected, label) {
|
|
87
|
+
if (Object.keys(value).sort().join('\0') !== [...expected].sort().join('\0')) fail(`${label} has unknown or missing fields`)
|
|
88
|
+
}
|
|
89
|
+
function canonicalPath(value, label) {
|
|
90
|
+
const path = text(value, label)
|
|
91
|
+
if (!isAbsolute(path) || path === '/' || realpathSync(path) !== resolve(path)) fail(`${label} must be an existing canonical absolute path`)
|
|
92
|
+
return path
|
|
93
|
+
}
|
|
94
|
+
function privateDirectory(path, label) {
|
|
95
|
+
const metadata = lstatSync(path); const uid = process.getuid?.()
|
|
96
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0
|
|
97
|
+
|| (uid !== undefined && metadata.uid !== uid) || realpathSync(path) !== resolve(path)) fail(`${label} must be an owner-private canonical directory`)
|
|
98
|
+
}
|
|
99
|
+
function ensurePrivateSubdirectory(root, components, label) {
|
|
100
|
+
let current = root; privateDirectory(current, `${label} root`)
|
|
101
|
+
for (const component of components) {
|
|
102
|
+
if (!/^[A-Za-z0-9%._-]+$/u.test(component) || component === '.' || component === '..') fail(`${label} component is invalid`)
|
|
103
|
+
current = join(current, component)
|
|
104
|
+
if (!existsSync(current)) mkdirSync(current, { mode: 0o700 })
|
|
105
|
+
privateDirectory(current, label)
|
|
106
|
+
}
|
|
107
|
+
return current
|
|
108
|
+
}
|
|
109
|
+
function fsyncDirectory(path) { const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY); try { fsyncSync(descriptor) } finally { closeSync(descriptor) } }
|
|
110
|
+
function privateFile(path, label, maximum = 65_536) {
|
|
111
|
+
const canonical = canonicalPath(path, label); const metadata = lstatSync(canonical); const uid = process.getuid?.()
|
|
112
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.size < 1 || metadata.size > maximum
|
|
113
|
+
|| (metadata.mode & 0o077) !== 0 || (uid !== undefined && metadata.uid !== uid)) fail(`${label} must be an owner-private regular file`)
|
|
114
|
+
privateDirectory(dirname(canonical), `${label} directory`)
|
|
115
|
+
return canonical
|
|
116
|
+
}
|
|
117
|
+
function assertPrivateAncestors(path, stop, label) {
|
|
118
|
+
let current = resolve(path); const boundary = resolve(stop)
|
|
119
|
+
while (true) {
|
|
120
|
+
privateDirectory(current, label)
|
|
121
|
+
if (current === boundary) return
|
|
122
|
+
const parent = dirname(current)
|
|
123
|
+
if (parent === current || (current !== boundary && !current.startsWith(`${boundary}${sep}`))) fail(`${label} escapes its trusted root`)
|
|
124
|
+
current = parent
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function safeRegularFile(path, label, maximum = MAX_ARTIFACT_BYTES) {
|
|
128
|
+
const canonical = canonicalPath(path, label); const metadata = lstatSync(canonical); const uid = process.getuid?.()
|
|
129
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.size < 1 || metadata.size > maximum
|
|
130
|
+
|| (metadata.mode & 0o022) !== 0 || (uid !== undefined && metadata.uid !== uid && metadata.uid !== 0)) fail(`${label} is unsafe`)
|
|
131
|
+
return canonical
|
|
132
|
+
}
|
|
133
|
+
function stableBytes(path, label, maximum = MAX_ARTIFACT_BYTES) {
|
|
134
|
+
const canonical = safeRegularFile(path, label, maximum)
|
|
135
|
+
const descriptor = openSync(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
|
|
136
|
+
let bytes; let before; let after
|
|
137
|
+
try { before = fstatSync(descriptor, { bigint: true }); bytes = inheritedDescriptorBytes(descriptor, label, maximum); after = fstatSync(descriptor, { bigint: true }) }
|
|
138
|
+
finally { closeSync(descriptor) }
|
|
139
|
+
const pathAfter = lstatSync(canonical, { bigint: true })
|
|
140
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs
|
|
141
|
+
|| before.ctimeNs !== after.ctimeNs || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino
|
|
142
|
+
|| BigInt(bytes.length) !== before.size) fail(`${label} changed during read`)
|
|
143
|
+
return bytes
|
|
144
|
+
}
|
|
145
|
+
function openPinnedFile(spec, label, maximum = MAX_ARTIFACT_BYTES, executable = false) {
|
|
146
|
+
const item = object(spec, label); exactKeys(item, ['path', 'sha256'], label)
|
|
147
|
+
const path = canonicalPath(item.path, `${label}.path`); const expected = text(item.sha256, `${label}.sha256`, DIGEST)
|
|
148
|
+
const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); const uid = process.getuid?.()
|
|
149
|
+
try {
|
|
150
|
+
const before = fstatSync(descriptor, { bigint: true }); const pathBefore = lstatSync(path, { bigint: true })
|
|
151
|
+
const expectedUid = uid === undefined ? undefined : BigInt(uid)
|
|
152
|
+
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximum)
|
|
153
|
+
|| (before.mode & 0o022n) !== 0n || (executable && (before.mode & 0o111n) === 0n)
|
|
154
|
+
|| (expectedUid !== undefined && before.uid !== expectedUid && before.uid !== 0n)
|
|
155
|
+
|| pathBefore.isSymbolicLink() || pathBefore.dev !== before.dev || pathBefore.ino !== before.ino) fail(`${label} is unsafe`)
|
|
156
|
+
const bytes = inheritedDescriptorBytes(descriptor, label, maximum); const after = fstatSync(descriptor, { bigint: true })
|
|
157
|
+
if (sha256Bytes(bytes) !== expected || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
|
|
158
|
+
|| after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) fail(`${label} digest or identity changed`)
|
|
159
|
+
return { path, sha256: expected, descriptor, device: before.dev, inode: before.ino }
|
|
160
|
+
} catch (error) { closeSync(descriptor); throw error }
|
|
161
|
+
}
|
|
162
|
+
function verifyPinnedFile(value, label, maximum = MAX_ARTIFACT_BYTES) {
|
|
163
|
+
const metadata = fstatSync(value.descriptor, { bigint: true })
|
|
164
|
+
if (metadata.dev !== value.device || metadata.ino !== value.inode
|
|
165
|
+
|| sha256Bytes(inheritedDescriptorBytes(value.descriptor, label, maximum)) !== value.sha256) fail(`${label} changed during use`)
|
|
166
|
+
}
|
|
167
|
+
function closePinnedFile(value) { if (value?.descriptor !== undefined) closeSync(value.descriptor) }
|
|
168
|
+
function openPinnedDirectory(value, label) {
|
|
169
|
+
const path = canonicalPath(value.path, `${label}.path`); const pathMetadata = lstatSync(path, { bigint: true })
|
|
170
|
+
const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
|
|
171
|
+
const metadata = fstatSync(descriptor, { bigint: true }); const uid = process.getuid?.(); const expectedUid = uid === undefined ? undefined : BigInt(uid)
|
|
172
|
+
if (!metadata.isDirectory() || (metadata.mode & 0o022n) !== 0n || (expectedUid !== undefined && metadata.uid !== expectedUid && metadata.uid !== 0n)) {
|
|
173
|
+
closeSync(descriptor); fail(`${label} directory descriptor is unsafe`)
|
|
174
|
+
}
|
|
175
|
+
if (pathMetadata.isSymbolicLink() || pathMetadata.dev !== metadata.dev || pathMetadata.ino !== metadata.ino) {
|
|
176
|
+
closeSync(descriptor); fail(`${label} directory identity changed while opening`)
|
|
177
|
+
}
|
|
178
|
+
return { ...value, descriptor, device: metadata.dev, inode: metadata.ino, label }
|
|
179
|
+
}
|
|
180
|
+
function closePinnedDirectory(value) { if (value?.descriptor !== undefined) closeSync(value.descriptor) }
|
|
181
|
+
function verifyPinnedDirectory(value) {
|
|
182
|
+
const metadata = fstatSync(value.descriptor, { bigint: true })
|
|
183
|
+
if (!metadata.isDirectory() || metadata.dev !== value.device || metadata.ino !== value.inode) fail(`${value.label} directory changed during use`)
|
|
184
|
+
}
|
|
185
|
+
function directoryInventory(path, label) {
|
|
186
|
+
const root = canonicalPath(path, `${label}.path`); const rootMetadata = lstatSync(root); const uid = process.getuid?.()
|
|
187
|
+
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink() || (rootMetadata.mode & 0o022) !== 0
|
|
188
|
+
|| (uid !== undefined && rootMetadata.uid !== uid && rootMetadata.uid !== 0)) fail(`${label} is not a trusted directory`)
|
|
189
|
+
const inventory = []
|
|
190
|
+
const visit = (directory, prefix = '') => {
|
|
191
|
+
for (const name of readdirSync(directory).sort()) {
|
|
192
|
+
const entryPath = join(directory, name); const entryName = prefix === '' ? name : `${prefix}/${name}`; const metadata = lstatSync(entryPath)
|
|
193
|
+
if ((!metadata.isSymbolicLink() && (metadata.mode & 0o022) !== 0)
|
|
194
|
+
|| (uid !== undefined && metadata.uid !== uid && metadata.uid !== 0)) {
|
|
195
|
+
fail(`${label} contains an unsafe entry: ${entryName}`)
|
|
196
|
+
}
|
|
197
|
+
if (metadata.isDirectory()) { inventory.push({ path: entryName, type: 'directory', mode: metadata.mode & 0o777 }); visit(entryPath, entryName) }
|
|
198
|
+
else if (metadata.isFile()) {
|
|
199
|
+
const bytes = readFileSync(entryPath)
|
|
200
|
+
if (bytes.length > MAX_ARTIFACT_BYTES) fail(`${label} contains an oversized file`)
|
|
201
|
+
inventory.push({ path: entryName, type: 'file', mode: metadata.mode & 0o777, bytes: bytes.length, sha256: sha256Bytes(bytes) })
|
|
202
|
+
} else if (metadata.isSymbolicLink()) {
|
|
203
|
+
const target = readlinkSync(entryPath)
|
|
204
|
+
if (isAbsolute(target)) fail(`${label} contains an absolute symlink`)
|
|
205
|
+
const resolved = resolve(dirname(entryPath), target)
|
|
206
|
+
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) fail(`${label} contains an escaping symlink: ${entryName} -> ${target}`)
|
|
207
|
+
inventory.push({ path: entryName, type: 'symlink', target })
|
|
208
|
+
} else fail(`${label} contains an unsupported entry`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
visit(root)
|
|
212
|
+
return { path: root, inventory, sha256: digest(inventory) }
|
|
213
|
+
}
|
|
214
|
+
function pinnedDirectory(spec, label) {
|
|
215
|
+
const item = object(spec, label); exactKeys(item, ['path', 'sha256'], label)
|
|
216
|
+
const expected = text(item.sha256, `${label}.sha256`, DIGEST); const inspected = directoryInventory(item.path, label)
|
|
217
|
+
if (inspected.sha256 !== expected) fail(`${label} digest does not match the owner pin`)
|
|
218
|
+
return { path: inspected.path, sha256: expected }
|
|
219
|
+
}
|
|
220
|
+
function copyPinnedTree(source, destination, label) {
|
|
221
|
+
const sourceRoot = openPinnedDirectory(source, label)
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(destination, { mode: 0o700 })
|
|
224
|
+
const visit = (sourceDirectory, sourceLogicalPath, destinationDirectory) => {
|
|
225
|
+
for (const name of readdirSync(`/proc/self/fd/${sourceDirectory.descriptor}`).sort()) {
|
|
226
|
+
const sourcePath = `/proc/self/fd/${sourceDirectory.descriptor}/${name}`; const destinationPath = join(destinationDirectory, name)
|
|
227
|
+
const metadata = lstatSync(sourcePath)
|
|
228
|
+
if (metadata.isDirectory()) {
|
|
229
|
+
mkdirSync(destinationPath, { mode: metadata.mode & 0o777 })
|
|
230
|
+
chmodSync(destinationPath, metadata.mode & 0o777)
|
|
231
|
+
const child = openSync(sourcePath, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
|
|
232
|
+
try { visit({ descriptor: child }, join(sourceLogicalPath, name), destinationPath) } finally { closeSync(child) }
|
|
233
|
+
} else if (metadata.isFile()) {
|
|
234
|
+
const bytes = readFileSync(sourcePath)
|
|
235
|
+
writeFileSync(destinationPath, bytes, { mode: metadata.mode & 0o777, flag: 'wx' })
|
|
236
|
+
chmodSync(destinationPath, metadata.mode & 0o777)
|
|
237
|
+
} else if (metadata.isSymbolicLink()) {
|
|
238
|
+
const target = readlinkSync(sourcePath)
|
|
239
|
+
if (isAbsolute(target)) fail(`${label} contains an absolute symlink`)
|
|
240
|
+
const resolved = resolve(dirname(join(sourceLogicalPath, name)), target)
|
|
241
|
+
if (resolved !== source.path && !resolved.startsWith(`${source.path}${sep}`)) fail(`${label} contains an escaping symlink`)
|
|
242
|
+
symlinkSync(target, destinationPath)
|
|
243
|
+
} else fail(`${label} contains an unsupported entry`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
visit(sourceRoot, source.path, destination)
|
|
247
|
+
} finally { closePinnedDirectory(sourceRoot) }
|
|
248
|
+
if (directoryInventory(destination, `${label} snapshot`).sha256 !== source.sha256) fail(`${label} snapshot digest differs`)
|
|
249
|
+
}
|
|
250
|
+
function readBounded(path, label, maximum = MAX_ARTIFACT_BYTES) { return stableBytes(path, label, maximum) }
|
|
251
|
+
function inheritedArtifactBytes(kind) {
|
|
252
|
+
const [environmentName, expectedFd, maximum] = ARTIFACT_FDS[kind]
|
|
253
|
+
if (process.env[environmentName] !== String(expectedFd)) fail(`${environmentName} must bind inherited fd ${expectedFd}`)
|
|
254
|
+
return inheritedDescriptorBytes(expectedFd, `inherited ${kind}`, maximum)
|
|
255
|
+
}
|
|
256
|
+
function inspectExecutable(spec, label) {
|
|
257
|
+
const executable = openPinnedFile(spec, label, MAX_ARTIFACT_BYTES, true); closePinnedFile(executable)
|
|
258
|
+
return { path: executable.path, sha256: executable.sha256 }
|
|
259
|
+
}
|
|
260
|
+
function inheritedDescriptorBytes(descriptor, label, maximum) {
|
|
261
|
+
const before = fstatSync(descriptor, { bigint: true }); const uid = process.getuid?.(); const expectedUid = uid === undefined ? undefined : BigInt(uid)
|
|
262
|
+
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximum) || (before.mode & 0o022n) !== 0n
|
|
263
|
+
|| (expectedUid !== undefined && before.uid !== expectedUid && before.uid !== 0n)) fail(`${label} fd is unsafe`)
|
|
264
|
+
const bytes = Buffer.alloc(Number(before.size)); let offset = 0
|
|
265
|
+
while (offset < bytes.length) { const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset); if (count === 0) fail(`${label} ended early`); offset += count }
|
|
266
|
+
const after = fstatSync(descriptor, { bigint: true })
|
|
267
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeNs !== before.mtimeNs
|
|
268
|
+
|| after.ctimeNs !== before.ctimeNs) fail(`${label} changed during read`)
|
|
269
|
+
return bytes
|
|
270
|
+
}
|
|
271
|
+
function runningAdapterDigest() {
|
|
272
|
+
const argument = process.argv[1]
|
|
273
|
+
if (typeof argument !== 'string') fail('adapter executable argument is missing')
|
|
274
|
+
const descriptor = argument.match(/^\/proc\/self\/fd\/(\d+)$/u)
|
|
275
|
+
return descriptor === null ? sha256Bytes(stableBytes(argument, 'running adapter executable', MAX_ARTIFACT_BYTES))
|
|
276
|
+
: sha256Bytes(inheritedDescriptorBytes(Number(descriptor[1]), 'running adapter executable', MAX_ARTIFACT_BYTES))
|
|
277
|
+
}
|
|
278
|
+
function runningInterpreterDigest() {
|
|
279
|
+
const descriptor = process.execPath.match(/^\/proc\/self\/fd\/(\d+)$/u)
|
|
280
|
+
return descriptor === null ? sha256Bytes(stableBytes(process.execPath, 'running adapter interpreter', MAX_ARTIFACT_BYTES))
|
|
281
|
+
: sha256Bytes(inheritedDescriptorBytes(Number(descriptor[1]), 'running adapter interpreter', MAX_ARTIFACT_BYTES))
|
|
282
|
+
}
|
|
283
|
+
function relativePath(value, label) {
|
|
284
|
+
const path = text(value, label, /^[A-Za-z0-9._/@+-]+$/u)
|
|
285
|
+
if (isAbsolute(path) || path === '.' || path.split('/').some(part => part === '' || part === '.' || part === '..')) fail(`${label} is not a safe relative path`)
|
|
286
|
+
return path
|
|
287
|
+
}
|
|
288
|
+
function within(root, candidate, label) {
|
|
289
|
+
const value = resolve(root, candidate); const suffix = relative(root, value)
|
|
290
|
+
if (suffix === '' || suffix.startsWith(`..${sep}`) || suffix === '..' || isAbsolute(suffix)) fail(`${label} escapes its root`)
|
|
291
|
+
return value
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readOwnerJson(path, label) {
|
|
295
|
+
const source = readBounded(path, label, 65_536).toString('utf8')
|
|
296
|
+
try { return object(JSON.parse(source), label) } catch (error) { if (error instanceof SyntaxError) fail(`${label} is not valid JSON`); throw error }
|
|
297
|
+
}
|
|
298
|
+
function readPrivateJsonUnder(root, name, label) {
|
|
299
|
+
const path = join(root, name); const canonical = privateFile(path, label, 262_144)
|
|
300
|
+
if (!canonical.startsWith(`${root}${sep}`)) fail(`${label} escapes its root`)
|
|
301
|
+
return readOwnerJson(canonical, label)
|
|
302
|
+
}
|
|
303
|
+
function configEnvironment(phase) { return `DSH_RELEASE_${phase.toUpperCase().replaceAll('-', '_')}_CONFIG` }
|
|
304
|
+
function loadConfig(environment, phase) {
|
|
305
|
+
const environmentName = configEnvironment(phase)
|
|
306
|
+
const configPath = privateFile(text(environment[environmentName], environmentName), 'adapter config')
|
|
307
|
+
const config = readOwnerJson(configPath, 'adapter config')
|
|
308
|
+
const allowed = ['schemaVersion', 'id', 'phase', 'executablePath', 'authority', 'keyId', 'privateKeyPath', 'authorizationAuthority', 'stateRoot',
|
|
309
|
+
'registryVerifier', 'git', 'build', 'registry', 'catalog']
|
|
310
|
+
if (Object.keys(config).some(key => !allowed.includes(key))) fail('adapter config has unknown fields')
|
|
311
|
+
if (config.schemaVersion !== 1 || !PHASES.has(config.phase)) fail('adapter config schema or phase is invalid')
|
|
312
|
+
const id = text(config.id, 'adapter id', ID); const executablePath = canonicalPath(config.executablePath, 'adapter executable path')
|
|
313
|
+
const authority = text(config.authority, 'adapter authority', ID); const keyId = text(config.keyId, 'adapter key id', ID)
|
|
314
|
+
const privateKeyPath = privateFile(config.privateKeyPath, 'adapter private key', 16_384)
|
|
315
|
+
const stateRoot = canonicalPath(config.stateRoot, 'adapter state root'); privateDirectory(stateRoot, 'adapter state root')
|
|
316
|
+
const authorizationAuthority = loadPublicIdentity(config.authorizationAuthority, 'release authorization authority')
|
|
317
|
+
const registryVerifier = config.registryVerifier === undefined
|
|
318
|
+
? undefined : loadPublicIdentity(config.registryVerifier, 'registry verifier')
|
|
319
|
+
if (phase === 'catalog-admission' && registryVerifier === undefined) fail('catalog adapter requires an explicit registry verifier')
|
|
320
|
+
if (phase !== 'catalog-admission' && registryVerifier !== undefined) fail('registry verifier identity is only valid for catalog admission')
|
|
321
|
+
const privateKey = createPrivateKey(readBounded(privateKeyPath, 'adapter private key', 16_384))
|
|
322
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') fail('adapter private key must be Ed25519')
|
|
323
|
+
if (registryVerifier !== undefined && ((registryVerifier.authority === authority && registryVerifier.keyId === keyId)
|
|
324
|
+
|| registryVerifier.publicKey.equals(createPublicKey(privateKey)))) {
|
|
325
|
+
fail('catalog adapter and registry verifier identities and keys must be independent')
|
|
326
|
+
}
|
|
327
|
+
return { ...config, configPath, id, executablePath, authority, keyId, privateKeyPath, privateKey, stateRoot, authorizationAuthority, registryVerifier }
|
|
328
|
+
}
|
|
329
|
+
function loadPublicIdentity(value, label) {
|
|
330
|
+
const item = object(value, label); exactKeys(item, ['authority', 'keyId', 'publicKeyPath'], label)
|
|
331
|
+
const authority = text(item.authority, `${label}.authority`, ID); const keyId = text(item.keyId, `${label}.keyId`, ID)
|
|
332
|
+
const publicKeyPath = privateFile(item.publicKeyPath, `${label}.public key`, 16_384)
|
|
333
|
+
const publicKey = createPublicKey(readBounded(publicKeyPath, `${label}.public key`, 16_384))
|
|
334
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') fail(`${label} public key must be Ed25519`)
|
|
335
|
+
return { authority, keyId, publicKeyPath, publicKey }
|
|
336
|
+
}
|
|
337
|
+
function gitConfig(value) {
|
|
338
|
+
const item = object(value, 'git config')
|
|
339
|
+
const allowed = ['executable', 'remote', 'targetBranch', 'authorName', 'authorEmail', 'reviewStore', 'reviewDecisionRoot', 'reviewAuthority']
|
|
340
|
+
if (Object.keys(item).some(key => !allowed.includes(key))) fail('git config has unknown fields')
|
|
341
|
+
const executable = inspectExecutable(item.executable, 'git')
|
|
342
|
+
const remote = canonicalPath(item.remote, 'git remote')
|
|
343
|
+
const remoteMetadata = lstatSync(remote); const uid = process.getuid?.()
|
|
344
|
+
if (!remoteMetadata.isDirectory() || remoteMetadata.isSymbolicLink() || (remoteMetadata.mode & 0o077) !== 0
|
|
345
|
+
|| (uid !== undefined && remoteMetadata.uid !== uid)) fail('git remote must be owner-private')
|
|
346
|
+
if (!existsSync(join(remote, 'HEAD'))) fail('git remote must be a local bare repository')
|
|
347
|
+
assertPrivateAncestors(remote, dirname(remote), 'git remote')
|
|
348
|
+
const targetBranch = text(item.targetBranch, 'git target branch', /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u)
|
|
349
|
+
if (targetBranch.includes('..') || targetBranch.startsWith('/') || targetBranch.endsWith('/') || targetBranch.includes('//')) fail('git target branch is invalid')
|
|
350
|
+
const authorName = text(item.authorName, 'git author name', /^.{1,128}$/u, 128)
|
|
351
|
+
const authorEmail = text(item.authorEmail, 'git author email', /^[^@\s]+@[^@\s]+$/u, 254)
|
|
352
|
+
let reviewStore; let reviewDecisionRoot; let reviewAuthority
|
|
353
|
+
if (item.reviewStore !== undefined) { reviewStore = canonicalPath(item.reviewStore, 'review store'); privateDirectory(reviewStore, 'review store') }
|
|
354
|
+
if (item.reviewDecisionRoot !== undefined) {
|
|
355
|
+
reviewDecisionRoot = canonicalPath(item.reviewDecisionRoot, 'review decision root'); privateDirectory(reviewDecisionRoot, 'review decision root')
|
|
356
|
+
}
|
|
357
|
+
if (item.reviewAuthority !== undefined) reviewAuthority = loadPublicIdentity(item.reviewAuthority, 'review authority')
|
|
358
|
+
return { executable, remote, targetBranch, authorName, authorEmail, reviewStore, reviewDecisionRoot, reviewAuthority }
|
|
359
|
+
}
|
|
360
|
+
function registryConfig(value) {
|
|
361
|
+
const item = object(value, 'registry config')
|
|
362
|
+
const allowed = ['id', 'root', 'locator', 'downloadRoot', 'signer']
|
|
363
|
+
if (Object.keys(item).some(key => !allowed.includes(key))) fail('registry config has unknown fields')
|
|
364
|
+
const id = text(item.id, 'registry id', ID); const root = canonicalPath(item.root, 'registry root'); privateDirectory(root, 'registry root')
|
|
365
|
+
const locator = text(item.locator, 'registry locator')
|
|
366
|
+
if (locator !== pathToFileURL(root).href) fail('registry locator must be the exact local registry file URL')
|
|
367
|
+
let downloadRoot
|
|
368
|
+
if (item.downloadRoot !== undefined) {
|
|
369
|
+
downloadRoot = canonicalPath(item.downloadRoot, 'registry download root'); privateDirectory(downloadRoot, 'registry download root')
|
|
370
|
+
if (downloadRoot === root || downloadRoot.startsWith(`${root}${sep}`) || root.startsWith(`${downloadRoot}${sep}`)) fail('registry verifier download root must be independent')
|
|
371
|
+
}
|
|
372
|
+
const signer = item.signer === undefined ? undefined : loadPublicIdentity(item.signer, 'artifact signer')
|
|
373
|
+
return { id, root, locator, downloadRoot, signer }
|
|
374
|
+
}
|
|
375
|
+
function registryPublicationPath(registry, packageName, packageVersion) {
|
|
376
|
+
return join(registry.root, 'packages', encodeURIComponent(packageName), packageVersion, 'publication.json')
|
|
377
|
+
}
|
|
378
|
+
function catalogConfig(value) {
|
|
379
|
+
const item = object(value, 'catalog config'); exactKeys(item, ['id', 'path', 'helper', 'interpreter'], 'catalog config')
|
|
380
|
+
const id = text(item.id, 'catalog id', ID); const path = canonicalPath(item.path, 'catalog path')
|
|
381
|
+
const helper = object(item.helper, 'catalog admission helper'); exactKeys(helper, ['path', 'sha256'], 'catalog admission helper')
|
|
382
|
+
const interpreter = object(item.interpreter, 'catalog helper interpreter'); exactKeys(interpreter, ['path', 'sha256'], 'catalog helper interpreter')
|
|
383
|
+
inspectExecutable(interpreter, 'catalog helper interpreter')
|
|
384
|
+
return { id, path, helper, interpreter }
|
|
385
|
+
}
|
|
386
|
+
function buildConfig(value) {
|
|
387
|
+
const item = object(value, 'build config')
|
|
388
|
+
exactKeys(item, ['sandboxExecutable', 'tarExecutable', 'nodeExecutable', 'pnpmExecutable', 'pnpmRoot', 'storeRoot'], 'build config')
|
|
389
|
+
const sandboxExecutable = inspectExecutable(item.sandboxExecutable, 'build sandbox')
|
|
390
|
+
const tarExecutable = inspectExecutable(item.tarExecutable, 'tar')
|
|
391
|
+
if (sandboxExecutable.path !== '/usr/bin/bwrap') fail('local build adapter requires the pinned Linux bubblewrap sandbox')
|
|
392
|
+
const nodeExecutable = inspectExecutable(item.nodeExecutable, 'build Node executable')
|
|
393
|
+
const pnpmExecutable = inspectExecutable(item.pnpmExecutable, 'pnpm executable')
|
|
394
|
+
const pnpmRoot = pinnedDirectory(item.pnpmRoot, 'pnpm root')
|
|
395
|
+
const storeRoot = pinnedDirectory(item.storeRoot, 'pnpm store')
|
|
396
|
+
if (nodeExecutable.path !== join(pnpmRoot.path, 'node')) fail('Node executable must be the pinned pnpm root node entrypoint')
|
|
397
|
+
if (pnpmExecutable.path !== join(pnpmRoot.path, 'pnpm')) fail('pnpm executable must be the pinned pnpm root entrypoint')
|
|
398
|
+
let pnpmManifest
|
|
399
|
+
try { pnpmManifest = object(JSON.parse(readFileSync(join(pnpmRoot.path, 'package.json'), 'utf8')), 'pnpm manifest') }
|
|
400
|
+
catch (error) { if (error instanceof SyntaxError) fail('pnpm manifest is invalid'); throw error }
|
|
401
|
+
const pnpmVersion = text(pnpmManifest.version, 'pnpm version', VERSION)
|
|
402
|
+
const storeVersion = Number.parseInt(pnpmVersion.split('.')[0], 10)
|
|
403
|
+
const projectsPath = join(storeRoot.path, `v${storeVersion}`, 'projects')
|
|
404
|
+
if (!existsSync(projectsPath) || !lstatSync(projectsPath).isDirectory()) fail('pnpm store project registry is unavailable')
|
|
405
|
+
return { sandboxExecutable, tarExecutable, nodeExecutable, pnpmExecutable, pnpmRoot, storeRoot, storeVersion }
|
|
406
|
+
}
|
|
407
|
+
function verifyBuildPins(build) {
|
|
408
|
+
for (const [item, label] of [[build.pnpmRoot, 'pnpm root'], [build.storeRoot, 'pnpm store']]) {
|
|
409
|
+
if (directoryInventory(item.path, label).sha256 !== item.sha256) fail(`${label} changed after configuration validation`)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function command(executable, args, cwd, extraEnvironment = {}, input, inherited = [], hooks = {}) {
|
|
414
|
+
if (process.platform !== 'linux' || !existsSync('/proc/self/fd')) fail('descriptor-pinned commands require Linux /proc/self/fd')
|
|
415
|
+
const pinned = openPinnedFile(executable, `pinned ${basename(executable.path)} executable`, MAX_ARTIFACT_BYTES, true)
|
|
416
|
+
const descriptor = 3 + inherited.length
|
|
417
|
+
try {
|
|
418
|
+
const header = Buffer.alloc(4); readSync(pinned.descriptor, header, 0, header.length, 0)
|
|
419
|
+
if (!header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) fail('pinned child executable must be a native ELF binary')
|
|
420
|
+
hooks.beforeSpawn?.({ executable: pinned.path, descriptor: pinned.descriptor, args })
|
|
421
|
+
const result = spawnSync(`/proc/self/fd/${descriptor}`, args, {
|
|
422
|
+
cwd, env: { LANG: 'C', LC_ALL: 'C', TZ: 'UTC', PATH: '/usr/bin:/bin', ...extraEnvironment }, input, encoding: null,
|
|
423
|
+
maxBuffer: 32 * 1024 * 1024, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe', ...inherited, pinned.descriptor],
|
|
424
|
+
})
|
|
425
|
+
hooks.afterSpawn?.({ executable: pinned.path, descriptor: pinned.descriptor, args, status: result.status })
|
|
426
|
+
verifyPinnedFile(pinned, `pinned ${basename(executable.path)} executable`)
|
|
427
|
+
if (result.error !== undefined || (result.status !== 0 && !hooks.allowedStatuses?.includes(result.status))) {
|
|
428
|
+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8').trim() : ''
|
|
429
|
+
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString('utf8').trim() : ''
|
|
430
|
+
const detail = (stderr === '' ? stdout : stderr).slice(0, 2_000)
|
|
431
|
+
fail(`pinned ${basename(executable.path)} command failed (${args.join(' ')}, ${String(result.status)})${detail === '' ? '' : `: ${detail}`}`)
|
|
432
|
+
}
|
|
433
|
+
return result.stdout ?? Buffer.alloc(0)
|
|
434
|
+
} finally {
|
|
435
|
+
try { hooks.afterFinally?.() } finally { closePinnedFile(pinned) }
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function runPinnedNodeModule(interpreter, module, source, cwd, input, hooks = {}) {
|
|
439
|
+
if (process.platform !== 'linux' || !existsSync('/proc/self/fd')) fail('descriptor-pinned modules require Linux /proc/self/fd')
|
|
440
|
+
const node = openPinnedFile(interpreter, 'pinned module interpreter', MAX_ARTIFACT_BYTES, true)
|
|
441
|
+
const script = openPinnedFile(module, 'pinned module', MAX_ARTIFACT_BYTES, false)
|
|
442
|
+
try {
|
|
443
|
+
const header = Buffer.alloc(4); readSync(node.descriptor, header, 0, header.length, 0)
|
|
444
|
+
if (!header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) fail('pinned module interpreter must be a native ELF binary')
|
|
445
|
+
hooks.beforeSpawn?.({ interpreter: node.path, module: script.path })
|
|
446
|
+
const result = spawnSync('/proc/self/fd/4', ['--input-type=module', '-e', source], { cwd, input, encoding: null, maxBuffer: 32 * 1024 * 1024,
|
|
447
|
+
env: { LANG: 'C', LC_ALL: 'C', TZ: 'UTC', PATH: '/usr/bin:/bin' }, windowsHide: true,
|
|
448
|
+
stdio: ['pipe', 'pipe', 'pipe', script.descriptor, node.descriptor] })
|
|
449
|
+
hooks.afterSpawn?.({ interpreter: node.path, module: script.path, status: result.status })
|
|
450
|
+
verifyPinnedFile(node, 'pinned module interpreter'); verifyPinnedFile(script, 'pinned module')
|
|
451
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
452
|
+
const detail = (result.stderr ?? Buffer.alloc(0)).toString('utf8').trim().slice(0, 2_000)
|
|
453
|
+
fail(`pinned module failed (${String(result.status)})${detail === '' ? '' : `: ${detail}`}`)
|
|
454
|
+
}
|
|
455
|
+
return result.stdout ?? Buffer.alloc(0)
|
|
456
|
+
} finally {
|
|
457
|
+
try { hooks.afterFinally?.() } finally { closePinnedFile(script); closePinnedFile(node) }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const SAFE_GIT_CONFIG = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.attributesFile=/dev/null']
|
|
461
|
+
function openSandboxContext(build, workspace, output, runRoot) {
|
|
462
|
+
const storeProjects = join(runRoot, 'store-projects')
|
|
463
|
+
if (!existsSync(storeProjects)) mkdirSync(storeProjects, { mode: 0o700 })
|
|
464
|
+
const toolchainSnapshot = join(runRoot, 'toolchain-snapshot'); const storeSnapshot = join(runRoot, 'store-snapshot')
|
|
465
|
+
if (!existsSync(toolchainSnapshot)) copyPinnedTree(build.pnpmRoot, toolchainSnapshot, 'pnpm root')
|
|
466
|
+
if (!existsSync(storeSnapshot)) copyPinnedTree(build.storeRoot, storeSnapshot, 'pnpm store')
|
|
467
|
+
const pnpmRoot = openPinnedDirectory({ path: toolchainSnapshot, sha256: build.pnpmRoot.sha256 }, 'pnpm snapshot')
|
|
468
|
+
const storeRoot = openPinnedDirectory({ path: storeSnapshot, sha256: build.storeRoot.sha256 }, 'pnpm store snapshot')
|
|
469
|
+
const workspaceRoot = openPinnedDirectory({ path: workspace }, 'build workspace')
|
|
470
|
+
const outputRoot = openPinnedDirectory({ path: output }, 'build output')
|
|
471
|
+
const storeProjectsRoot = openPinnedDirectory({ path: storeProjects }, 'pnpm project registry')
|
|
472
|
+
const mounts = [pnpmRoot, storeRoot, workspaceRoot, outputRoot, storeProjectsRoot]
|
|
473
|
+
return { mounts, runRoot }
|
|
474
|
+
}
|
|
475
|
+
function closeSandboxContext(context) { for (const item of context.mounts) closePinnedDirectory(item) }
|
|
476
|
+
function sandboxCommand(build, args, context, hooks = {}) {
|
|
477
|
+
const { mounts, runRoot } = context
|
|
478
|
+
for (const item of mounts) verifyPinnedDirectory(item)
|
|
479
|
+
const mountFd = index => String(3 + index)
|
|
480
|
+
const sandboxArgs = ['--unshare-all', '--die-with-parent', '--new-session', '--clearenv',
|
|
481
|
+
'--ro-bind', '/usr', '/usr', '--ro-bind', '/lib', '/lib', '--ro-bind', '/lib64', '/lib64',
|
|
482
|
+
'--ro-bind-fd', mountFd(0), '/toolchain', '--ro-bind-fd', mountFd(1), '/store',
|
|
483
|
+
'--bind-fd', mountFd(4), `/store/v${build.storeVersion}/projects`, '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home',
|
|
484
|
+
'--bind-fd', mountFd(2), '/workspace', '--bind-fd', mountFd(3), '/output',
|
|
485
|
+
'--chdir', '/workspace', '--setenv', 'HOME', '/home', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'SOURCE_DATE_EPOCH', '0',
|
|
486
|
+
'--setenv', 'PATH', '/toolchain:/usr/bin:/bin', '/toolchain/pnpm', ...args]
|
|
487
|
+
command(build.sandboxExecutable, sandboxArgs, runRoot, {}, undefined, mounts.map(item => item.descriptor),
|
|
488
|
+
{ beforeSpawn: hooks.beforeSandboxSpawn, afterSpawn: hooks.afterSandboxSpawn, afterFinally: hooks.afterSandboxFinally })
|
|
489
|
+
for (const item of mounts) verifyPinnedDirectory(item)
|
|
490
|
+
}
|
|
491
|
+
function git(gitValue, args, cwd, environment = {}, hooks = {}) {
|
|
492
|
+
return command(gitValue.executable, [...SAFE_GIT_CONFIG, ...args], cwd,
|
|
493
|
+
{ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', ...environment }, undefined, [],
|
|
494
|
+
{ beforeSpawn: hooks.beforeGitSpawn, afterSpawn: hooks.afterGitSpawn, afterFinally: hooks.afterGitFinally })
|
|
495
|
+
}
|
|
496
|
+
function remoteRef(gitValue, ref, hooks = {}) {
|
|
497
|
+
const output = command(gitValue.executable, ['--git-dir', gitValue.remote, 'show-ref', '--verify', '--hash', ref], gitValue.remote, {}, undefined, [], {
|
|
498
|
+
beforeSpawn: hooks.beforeGitSpawn, afterSpawn: hooks.afterGitSpawn, afterFinally: hooks.afterGitFinally, allowedStatuses: [1, 128],
|
|
499
|
+
}).toString('utf8').trim()
|
|
500
|
+
return output === '' ? undefined : output
|
|
501
|
+
}
|
|
502
|
+
function commitEnvironment(config, requestedAt) {
|
|
503
|
+
const date = new Date(requestedAt).toISOString()
|
|
504
|
+
return {
|
|
505
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
|
506
|
+
GIT_AUTHOR_NAME: config.authorName, GIT_AUTHOR_EMAIL: config.authorEmail, GIT_AUTHOR_DATE: date,
|
|
507
|
+
GIT_COMMITTER_NAME: config.authorName, GIT_COMMITTER_EMAIL: config.authorEmail, GIT_COMMITTER_DATE: date,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function safeScope(value) {
|
|
511
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) fail('source scope is invalid')
|
|
512
|
+
const scope = [...new Set(value.map((entry, index) => relativePath(entry.normalize('NFC').trim(), `scope[${index}]`)))].sort()
|
|
513
|
+
return scope
|
|
514
|
+
}
|
|
515
|
+
function changedPaths(gitValue, worktree, baseCommit) {
|
|
516
|
+
const tracked = git(gitValue, ['--literal-pathspecs', 'diff', '--no-renames', '--name-only', '-z', baseCommit, '--'], worktree)
|
|
517
|
+
.toString('utf8').split('\0').filter(Boolean)
|
|
518
|
+
const untracked = git(gitValue, ['--literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z'], worktree)
|
|
519
|
+
.toString('utf8').split('\0').filter(Boolean)
|
|
520
|
+
return [...new Set([...tracked, ...untracked])].sort()
|
|
521
|
+
}
|
|
522
|
+
function expectedSourceScope(name) { return ['plugins/README.md', `plugins/${name}`].sort() }
|
|
523
|
+
function pathAllowed(path, scope) {
|
|
524
|
+
return scope.some(root => path === root || (root !== 'plugins/README.md' && path.startsWith(`${root}/`)))
|
|
525
|
+
}
|
|
526
|
+
function sourceDigests(gitValue, worktree, baseCommit, scope, operationDirectory) {
|
|
527
|
+
const indexPath = join(operationDirectory, 'source.index')
|
|
528
|
+
rmSync(indexPath, { force: true })
|
|
529
|
+
const environment = { GIT_INDEX_FILE: indexPath, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }
|
|
530
|
+
git(gitValue, ['read-tree', baseCommit], worktree, environment)
|
|
531
|
+
git(gitValue, ['--literal-pathspecs', 'add', '--all', '--', ...scope], worktree, environment)
|
|
532
|
+
const tree = git(gitValue, ['write-tree'], worktree, environment).toString('utf8').trim()
|
|
533
|
+
const treeListing = git(gitValue, ['--literal-pathspecs', '-c', 'core.quotepath=false', 'ls-files', '--stage', '-z', '--', ...scope], worktree, environment)
|
|
534
|
+
const patch = git(gitValue, ['--literal-pathspecs', '-c', 'core.quotepath=false', 'diff', '--cached', '--binary', '--full-index', '--no-color',
|
|
535
|
+
baseCommit, '--', ...scope], worktree, environment)
|
|
536
|
+
if (!COMMIT.test(tree) || patch.length === 0) fail('source change is empty')
|
|
537
|
+
const binding = Buffer.from(`${baseCommit}\0${JSON.stringify(scope)}\0`)
|
|
538
|
+
const treeDigest = createHash('sha256').update('dsh-source-tree-v2\0').update(binding).update(treeListing).digest('hex')
|
|
539
|
+
const patchDigest = createHash('sha256').update('dsh-source-patch-v2\0').update(binding).update(patch).digest('hex')
|
|
540
|
+
return { indexPath, environment, tree, treeDigest, patchDigest }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function validateAuthorization(request) {
|
|
544
|
+
const authorization = object(request.authorization, 'source authorization')
|
|
545
|
+
exactKeys(authorization, ['schemaVersion', 'kind', 'authorizationId', 'authority', 'keyId', 'planId', 'planDigest', 'baseCommit',
|
|
546
|
+
'checkedTreeDigest', 'checkedPatchDigest', 'scope', 'releasePolicy', 'authorizedAt', 'expiresAt', 'signature', 'signatureDigest'], 'source authorization')
|
|
547
|
+
const policy = object(authorization.releasePolicy, 'source release policy')
|
|
548
|
+
exactKeys(policy, ['targetBranch', 'candidateId', 'packageName', 'packageVersion', 'packagePath', 'dshBaseline', 'capabilities',
|
|
549
|
+
'authorities', 'requires', 'registryId', 'registryLocator', 'registryReference', 'catalogId', 'catalogPath',
|
|
550
|
+
'minimumReproducibleBuilds'], 'source release policy')
|
|
551
|
+
text(authorization.signatureDigest, 'authorization signature digest', DIGEST)
|
|
552
|
+
const authorizationSignature = text(authorization.signature, 'authorization signature', /^[A-Za-z0-9+/]+={0,2}$/u, 16_384)
|
|
553
|
+
const authorizationSignatureBytes = Buffer.from(authorizationSignature, 'base64')
|
|
554
|
+
if (authorization.schemaVersion !== 1 || authorization.kind !== 'dsh-source-release-authorization'
|
|
555
|
+
|| !ID.test(authorization.authorizationId) || !ID.test(authorization.authority) || !ID.test(authorization.keyId)
|
|
556
|
+
|| authorization.planId !== request.plan.id || authorization.planDigest !== request.plan.digest
|
|
557
|
+
|| authorization.baseCommit === undefined || !COMMIT.test(authorization.baseCommit)
|
|
558
|
+
|| !DIGEST.test(authorization.checkedTreeDigest) || !DIGEST.test(authorization.checkedPatchDigest)
|
|
559
|
+
|| digest(authorization.scope) !== digest(safeScope(authorization.scope))
|
|
560
|
+
|| !Number.isSafeInteger(authorization.authorizedAt) || !Number.isSafeInteger(authorization.expiresAt)
|
|
561
|
+
|| authorization.authorizedAt > request.requestedAt || request.requestedAt > authorization.expiresAt
|
|
562
|
+
|| text(policy.targetBranch, 'authorized target branch') === '' || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(policy.candidateId)
|
|
563
|
+
|| !PACKAGE.test(policy.packageName) || !VERSION.test(policy.packageVersion)
|
|
564
|
+
|| relativePath(policy.packagePath, 'authorized package path') !== policy.packagePath
|
|
565
|
+
|| !VERSION.test(policy.dshBaseline) || !Number.isSafeInteger(policy.minimumReproducibleBuilds) || policy.minimumReproducibleBuilds < 2
|
|
566
|
+
|| policy.minimumReproducibleBuilds > 16
|
|
567
|
+
|| authorizationSignatureBytes.length !== 64 || authorizationSignatureBytes.toString('base64') !== authorizationSignature
|
|
568
|
+
|| sha256Bytes(authorizationSignatureBytes) !== authorization.signatureDigest
|
|
569
|
+
|| policy.registryId !== request.registry.id || policy.registryLocator !== request.registry.locator
|
|
570
|
+
|| ('catalog' in request && (policy.catalogId !== request.catalog.id || policy.catalogPath !== request.catalog.path))
|
|
571
|
+
|| typeof policy.registryReference !== 'string' || policy.registryReference === '') fail('source authorization is not bound to this request')
|
|
572
|
+
const normalizeStrings = (value, label) => {
|
|
573
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(entry => typeof entry !== 'string' || entry.normalize('NFC').trim() === '')) fail(`${label} is invalid`)
|
|
574
|
+
const normalized = [...new Set(value.map(entry => entry.normalize('NFC').trim()))].sort()
|
|
575
|
+
if (digest(value) !== digest(normalized)) fail(`${label} is not canonical`)
|
|
576
|
+
return normalized
|
|
577
|
+
}
|
|
578
|
+
normalizeStrings(policy.capabilities, 'authorized capabilities'); normalizeStrings(policy.authorities, 'authorized authorities')
|
|
579
|
+
if (!Array.isArray(policy.requires)) fail('authorized requirements are invalid')
|
|
580
|
+
for (const requirement of policy.requires) {
|
|
581
|
+
const item = object(requirement, 'authorized requirement'); exactKeys(item, ['package', 'version', 'integrity'], 'authorized requirement')
|
|
582
|
+
text(item.package, 'authorized required package', PACKAGE); text(item.version, 'authorized required version', VERSION)
|
|
583
|
+
text(item.integrity, 'authorized required integrity', /^sha512-[A-Za-z0-9+/]+={0,2}$/u)
|
|
584
|
+
}
|
|
585
|
+
return authorization
|
|
586
|
+
}
|
|
587
|
+
function verifyAuthorizationSignature(authorization, config) {
|
|
588
|
+
if (authorization.authority !== config.authorizationAuthority.authority || authorization.keyId !== config.authorizationAuthority.keyId) {
|
|
589
|
+
fail('source authorization uses an untrusted authority')
|
|
590
|
+
}
|
|
591
|
+
const { signature, signatureDigest: _signatureDigest, ...unsigned } = authorization
|
|
592
|
+
if (!verify(null, Buffer.from(canonicalReleaseValue(unsigned)), config.authorizationAuthority.publicKey, Buffer.from(signature, 'base64'))) {
|
|
593
|
+
fail('source authorization signature is invalid')
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function validateRequest(request, config) {
|
|
597
|
+
exactKeys(object(request, 'release request'), ['schemaVersion', 'kind', 'operationId', 'attempt', 'requestedAt', 'receiptTtlMs',
|
|
598
|
+
'installationId', 'ledger', 'plan', 'release', 'authorization', 'adapter', 'registry', 'catalog', 'phase', 'input'], 'release request')
|
|
599
|
+
if (request.schemaVersion !== 1 || request.kind !== 'dsh-source-release-request' || request.phase !== config.phase
|
|
600
|
+
|| !PHASES.has(request.phase) || !ID.test(request.operationId) || !Number.isSafeInteger(request.requestedAt)
|
|
601
|
+
|| !Number.isSafeInteger(request.attempt) || request.attempt < 1 || !Number.isSafeInteger(request.receiptTtlMs)
|
|
602
|
+
|| request.receiptTtlMs < 1_000 || request.receiptTtlMs > 300_000) fail('release request envelope is invalid')
|
|
603
|
+
const ledger = object(request.ledger, 'release ledger'); exactKeys(ledger, ['id', 'path'], 'release ledger')
|
|
604
|
+
const plan = object(request.plan, 'release plan'); exactKeys(plan, ['id', 'digest', 'revision'], 'release plan')
|
|
605
|
+
const release = object(request.release, 'release fence'); exactKeys(release, ['id', 'fence'], 'release fence')
|
|
606
|
+
const registry = object(request.registry, 'release registry'); exactKeys(registry, ['id', 'locator'], 'release registry')
|
|
607
|
+
const catalog = object(request.catalog, 'release catalog'); exactKeys(catalog, ['id', 'path'], 'release catalog')
|
|
608
|
+
const adapter = object(request.adapter, 'adapter identity'); exactKeys(adapter, ['id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'adapter identity')
|
|
609
|
+
const interpreter = object(adapter.interpreter, 'adapter interpreter'); exactKeys(interpreter, ['path', 'sha256'], 'adapter interpreter')
|
|
610
|
+
if (!ID.test(plan.id) || !DIGEST.test(plan.digest) || !Number.isSafeInteger(plan.revision) || plan.revision < 1
|
|
611
|
+
|| !ID.test(release.id) || !Number.isSafeInteger(release.fence) || release.fence < 1
|
|
612
|
+
|| adapter.id !== config.id || adapter.version !== LOCAL_RELEASE_ADAPTER_VERSION || adapter.authority !== config.authority || adapter.keyId !== config.keyId
|
|
613
|
+
|| adapter.path !== config.executablePath || runningAdapterDigest() !== adapter.sha256
|
|
614
|
+
|| runningInterpreterDigest() !== interpreter.sha256) {
|
|
615
|
+
fail('release request is not bound to this adapter')
|
|
616
|
+
}
|
|
617
|
+
const authorization = validateAuthorization(request)
|
|
618
|
+
verifyAuthorizationSignature(authorization, config)
|
|
619
|
+
const input = object(request.input, `${request.phase} input`)
|
|
620
|
+
const fields = { pr: ['repository', 'worktree', 'baseCommit', 'name', 'scope', 'expectedTreeDigest', 'expectedPatchDigest'],
|
|
621
|
+
review: ['prId', 'headCommit', 'baseCommit', 'prEvidenceDigest'], merge: ['prId', 'headCommit', 'reviewId', 'reviewEvidenceDigest', 'targetBranch'],
|
|
622
|
+
build: ['repository', 'mergeCommit', 'mergeEvidenceDigest', 'name', 'expectedCandidateId', 'expectedPackageName', 'expectedPackageVersion',
|
|
623
|
+
'expectedPackagePath', 'expectedDshBaseline', 'expectedCapabilities', 'expectedAuthorities', 'expectedRequires'],
|
|
624
|
+
sign: ['artifact', 'buildEvidenceDigest'], publish: ['artifact', 'artifactStatementDigest', 'artifactSignature', 'signEvidenceDigest'],
|
|
625
|
+
'registry-verify': ['artifact', 'artifactStatementDigest', 'artifactSignature', 'registryReference', 'publishEvidenceDigest'],
|
|
626
|
+
'catalog-admission': ['artifact', 'artifactStatementDigest', 'artifactSignature', 'registryReference', 'registryVerificationRequest',
|
|
627
|
+
'registryVerificationReceipt', 'verificationEvidenceDigest',
|
|
628
|
+
'expectedBeforeCatalogDigest', 'expectedAfterCatalogDigest', 'candidate'] }
|
|
629
|
+
exactKeys(input, fields[request.phase], `${request.phase} input`)
|
|
630
|
+
return { authorization }
|
|
631
|
+
}
|
|
632
|
+
function validateCatalogRegistryVerificationReceipt(request, config) {
|
|
633
|
+
if (request.phase !== 'catalog-admission') return undefined
|
|
634
|
+
if (config.registryVerifier === undefined) fail('catalog adapter has no trusted registry verifier')
|
|
635
|
+
const verificationRequest = object(request.input.registryVerificationRequest, 'registry verification request')
|
|
636
|
+
exactKeys(verificationRequest, ['schemaVersion', 'kind', 'operationId', 'attempt', 'requestedAt', 'receiptTtlMs',
|
|
637
|
+
'installationId', 'ledger', 'plan', 'release', 'authorization', 'adapter', 'registry', 'catalog', 'phase', 'input'],
|
|
638
|
+
'registry verification request')
|
|
639
|
+
if (verificationRequest.phase !== 'registry-verify') fail('nested registry verification request has the wrong phase')
|
|
640
|
+
const verificationInput = object(verificationRequest.input, 'registry verification request input')
|
|
641
|
+
exactKeys(verificationInput, ['artifact', 'artifactStatementDigest', 'artifactSignature', 'registryReference', 'publishEvidenceDigest'],
|
|
642
|
+
'registry verification request input')
|
|
643
|
+
const verificationAdapter = object(verificationRequest.adapter, 'registry verifier adapter identity')
|
|
644
|
+
exactKeys(verificationAdapter, ['id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'registry verifier adapter identity')
|
|
645
|
+
const verificationLedger = object(verificationRequest.ledger, 'registry verification ledger')
|
|
646
|
+
exactKeys(verificationLedger, ['id', 'path'], 'registry verification ledger')
|
|
647
|
+
const verificationPlan = object(verificationRequest.plan, 'registry verification plan')
|
|
648
|
+
exactKeys(verificationPlan, ['id', 'digest', 'revision'], 'registry verification plan')
|
|
649
|
+
const verificationRelease = object(verificationRequest.release, 'registry verification release')
|
|
650
|
+
exactKeys(verificationRelease, ['id', 'fence'], 'registry verification release')
|
|
651
|
+
const verificationRegistry = object(verificationRequest.registry, 'registry verification registry')
|
|
652
|
+
exactKeys(verificationRegistry, ['id', 'locator'], 'registry verification registry')
|
|
653
|
+
const verificationCatalog = object(verificationRequest.catalog, 'registry verification catalog')
|
|
654
|
+
exactKeys(verificationCatalog, ['id', 'path'], 'registry verification catalog')
|
|
655
|
+
const verificationInterpreter = verificationAdapter.interpreter === null ? null
|
|
656
|
+
: object(verificationAdapter.interpreter, 'registry verifier interpreter')
|
|
657
|
+
if (verificationInterpreter !== null) exactKeys(verificationInterpreter, ['path', 'sha256'], 'registry verifier interpreter')
|
|
658
|
+
const receipt = object(request.input.registryVerificationReceipt, 'registry verification receipt')
|
|
659
|
+
exactKeys(receipt, ['schemaVersion', 'receiptId', 'authority', 'keyId', 'installationId', 'planId', 'planDigest', 'releaseId',
|
|
660
|
+
'fence', 'operationId', 'requestDigest', 'phase', 'outcome', 'evidence', 'evidenceDigest', 'observedAt', 'expiresAt', 'signature'],
|
|
661
|
+
'registry verification receipt')
|
|
662
|
+
const evidence = object(receipt.evidence, 'registry verification evidence')
|
|
663
|
+
exactKeys(evidence, ['kind', 'registryId', 'registryReference', 'independentlyDownloaded', 'downloadedBytes', 'downloadedSha256',
|
|
664
|
+
'downloadedIntegrity', 'artifactStatementDigest', 'artifactSignatureDigest', 'publishEvidenceDigest'], 'registry verification evidence')
|
|
665
|
+
const signatureValue = text(receipt.signature, 'registry verification receipt signature', /^[A-Za-z0-9+/]+={0,2}$/u, 16_384)
|
|
666
|
+
const signatureBytes = Buffer.from(signatureValue, 'base64')
|
|
667
|
+
for (const [value, label] of [[receipt.planDigest, 'registry receipt plan digest'], [receipt.requestDigest, 'registry receipt request digest'],
|
|
668
|
+
[receipt.evidenceDigest, 'registry receipt evidence digest'], [evidence.downloadedSha256, 'registry downloaded digest'],
|
|
669
|
+
[evidence.artifactStatementDigest, 'registry artifact statement digest'],
|
|
670
|
+
[evidence.artifactSignatureDigest, 'registry artifact signature digest'],
|
|
671
|
+
[evidence.publishEvidenceDigest, 'registry publish evidence digest']]) text(value, label, DIGEST)
|
|
672
|
+
text(evidence.downloadedIntegrity, 'registry downloaded integrity', /^sha512-[A-Za-z0-9+/]+={0,2}$/u)
|
|
673
|
+
if (verificationRequest.schemaVersion !== 1 || verificationRequest.kind !== 'dsh-source-release-request'
|
|
674
|
+
|| verificationRequest.phase !== 'registry-verify' || !ID.test(verificationRequest.operationId)
|
|
675
|
+
|| !Number.isSafeInteger(verificationRequest.attempt) || verificationRequest.attempt < 1
|
|
676
|
+
|| !ID.test(verificationLedger.id) || typeof verificationLedger.path !== 'string' || !isAbsolute(verificationLedger.path)
|
|
677
|
+
|| !ID.test(verificationPlan.id) || !DIGEST.test(verificationPlan.digest) || !Number.isSafeInteger(verificationPlan.revision) || verificationPlan.revision < 1
|
|
678
|
+
|| !ID.test(verificationRelease.id) || !Number.isSafeInteger(verificationRelease.fence) || verificationRelease.fence < 1
|
|
679
|
+
|| !ID.test(verificationRegistry.id) || typeof verificationRegistry.locator !== 'string'
|
|
680
|
+
|| !ID.test(verificationCatalog.id) || typeof verificationCatalog.path !== 'string' || !isAbsolute(verificationCatalog.path)
|
|
681
|
+
|| !ID.test(verificationAdapter.id) || !ID.test(verificationAdapter.authority) || !ID.test(verificationAdapter.keyId)
|
|
682
|
+
|| verificationAdapter.version !== LOCAL_RELEASE_ADAPTER_VERSION || typeof verificationAdapter.path !== 'string'
|
|
683
|
+
|| !isAbsolute(verificationAdapter.path) || !DIGEST.test(verificationAdapter.sha256)
|
|
684
|
+
|| (verificationInterpreter !== null && (!isAbsolute(verificationInterpreter.path) || !DIGEST.test(verificationInterpreter.sha256)))
|
|
685
|
+
|| receipt.schemaVersion !== 1 || receipt.phase !== 'registry-verify' || receipt.outcome !== 'passed'
|
|
686
|
+
|| evidence.kind !== 'registry-verify' || evidence.independentlyDownloaded !== true
|
|
687
|
+
|| !ID.test(receipt.receiptId) || !ID.test(receipt.authority) || !ID.test(receipt.keyId)
|
|
688
|
+
|| !ID.test(receipt.planId) || !ID.test(receipt.releaseId) || !ID.test(receipt.operationId)
|
|
689
|
+
|| !Number.isSafeInteger(receipt.fence) || receipt.fence < 1
|
|
690
|
+
|| !Number.isSafeInteger(receipt.observedAt) || !Number.isSafeInteger(receipt.expiresAt)
|
|
691
|
+
|| !Number.isSafeInteger(verificationRequest.requestedAt) || !Number.isSafeInteger(verificationRequest.receiptTtlMs)
|
|
692
|
+
|| verificationRequest.receiptTtlMs < 1_000 || verificationRequest.receiptTtlMs > 300_000
|
|
693
|
+
|| receipt.expiresAt <= receipt.observedAt || receipt.expiresAt - receipt.observedAt > verificationRequest.receiptTtlMs
|
|
694
|
+
|| verificationRequest.requestedAt < request.authorization.authorizedAt
|
|
695
|
+
|| verificationRequest.requestedAt > request.authorization.expiresAt || receipt.observedAt < verificationRequest.requestedAt
|
|
696
|
+
|| receipt.observedAt > request.requestedAt || receipt.expiresAt > request.authorization.expiresAt || Date.now() > receipt.expiresAt
|
|
697
|
+
|| !Number.isSafeInteger(evidence.downloadedBytes) || evidence.downloadedBytes < 1
|
|
698
|
+
|| signatureBytes.length !== 64 || signatureBytes.toString('base64') !== signatureValue) {
|
|
699
|
+
fail('registry verification receipt envelope is invalid or expired')
|
|
700
|
+
}
|
|
701
|
+
const verificationRequestDigest = digest(verificationRequest)
|
|
702
|
+
if (receipt.authority !== config.registryVerifier.authority || receipt.keyId !== config.registryVerifier.keyId) {
|
|
703
|
+
fail('registry verification receipt uses an untrusted verifier identity')
|
|
704
|
+
}
|
|
705
|
+
const { signature: _signature, ...unsigned } = receipt
|
|
706
|
+
if (receipt.evidenceDigest !== digest(evidence)
|
|
707
|
+
|| !verify(null, Buffer.from(canonicalReleaseValue(unsigned)), config.registryVerifier.publicKey, signatureBytes)) {
|
|
708
|
+
fail('registry verification receipt signature or evidence digest is invalid')
|
|
709
|
+
}
|
|
710
|
+
const artifact = request.input.artifact
|
|
711
|
+
const artifactSignatureDigest = sha256Bytes(Buffer.from(request.input.artifactSignature, 'base64'))
|
|
712
|
+
if (request.input.verificationEvidenceDigest !== receipt.evidenceDigest
|
|
713
|
+
|| receipt.operationId !== verificationRequest.operationId || receipt.requestDigest !== verificationRequestDigest
|
|
714
|
+
|| receipt.authority !== verificationAdapter.authority || receipt.keyId !== verificationAdapter.keyId
|
|
715
|
+
|| receipt.installationId !== request.installationId || receipt.planId !== request.plan.id
|
|
716
|
+
|| receipt.planDigest !== request.plan.digest || receipt.releaseId !== request.release.id || receipt.fence !== request.release.fence
|
|
717
|
+
|| verificationRequest.installationId !== request.installationId || verificationRequest.plan.id !== request.plan.id
|
|
718
|
+
|| verificationRequest.plan.digest !== request.plan.digest || verificationRequest.plan.revision + 1 !== request.plan.revision
|
|
719
|
+
|| verificationRequest.release.id !== request.release.id
|
|
720
|
+
|| verificationRequest.release.fence !== request.release.fence
|
|
721
|
+
|| digest(verificationRequest.authorization) !== digest(request.authorization)
|
|
722
|
+
|| digest(verificationLedger) !== digest(request.ledger) || digest(verificationCatalog) !== digest(request.catalog)
|
|
723
|
+
|| digest(verificationRequest.registry) !== digest(request.registry)
|
|
724
|
+
|| digest(verificationInput.artifact) !== digest(artifact)
|
|
725
|
+
|| verificationInput.registryReference !== request.input.registryReference
|
|
726
|
+
|| verificationInput.artifactStatementDigest !== request.input.artifactStatementDigest
|
|
727
|
+
|| verificationInput.artifactSignature !== request.input.artifactSignature
|
|
728
|
+
|| evidence.publishEvidenceDigest !== verificationInput.publishEvidenceDigest
|
|
729
|
+
|| evidence.registryId !== request.registry.id || evidence.registryReference !== request.input.registryReference
|
|
730
|
+
|| evidence.downloadedBytes !== artifact.tarballBytes || evidence.downloadedSha256 !== artifact.tarballSha256
|
|
731
|
+
|| evidence.downloadedIntegrity !== artifact.tarballIntegrity
|
|
732
|
+
|| evidence.artifactStatementDigest !== request.input.artifactStatementDigest
|
|
733
|
+
|| evidence.artifactSignatureDigest !== artifactSignatureDigest) {
|
|
734
|
+
fail('registry verification receipt is not bound to this catalog request and signed artifact')
|
|
735
|
+
}
|
|
736
|
+
return receipt
|
|
737
|
+
}
|
|
738
|
+
function validatePhasePolicy(request, config) {
|
|
739
|
+
const policy = request.authorization.releasePolicy
|
|
740
|
+
if (request.phase === 'pr' || request.phase === 'review' || request.phase === 'merge' || request.phase === 'build') {
|
|
741
|
+
if (gitConfig(config.git).targetBranch !== policy.targetBranch) fail('Git adapter is not bound to the authorized target branch')
|
|
742
|
+
}
|
|
743
|
+
if (request.phase === 'build') {
|
|
744
|
+
buildConfig(config.build)
|
|
745
|
+
}
|
|
746
|
+
if (request.phase === 'publish' || request.phase === 'registry-verify' || request.phase === 'catalog-admission') {
|
|
747
|
+
const registry = registryConfig(config.registry)
|
|
748
|
+
if (registry.id !== policy.registryId || registry.locator !== request.registry.locator) fail('registry adapter is not bound to the authorized registry')
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function operationContext(request, config) {
|
|
753
|
+
const requestDigest = digest(request); const operationKey = sha256Bytes(request.operationId)
|
|
754
|
+
const operationsRoot = ensurePrivateSubdirectory(config.stateRoot, ['operations'], 'operations directory')
|
|
755
|
+
const directory = ensurePrivateSubdirectory(operationsRoot, [operationKey], 'operation directory')
|
|
756
|
+
const bindingPath = join(directory, 'binding.json'); const binding = { operationId: request.operationId, requestDigest }
|
|
757
|
+
if (!existsSync(bindingPath)) immutableJson(bindingPath, binding, 0o600)
|
|
758
|
+
const prior = readOwnerJson(bindingPath, 'operation binding')
|
|
759
|
+
if (prior.operationId !== request.operationId || prior.requestDigest !== requestDigest) fail('operation id was reused with a different request')
|
|
760
|
+
const releaseLock = acquireProcessLock(join(directory, 'execution.lock'), 'operation')
|
|
761
|
+
const invocations = ensurePrivateSubdirectory(config.stateRoot, ['invocations'], 'invocations directory')
|
|
762
|
+
immutableJson(join(invocations, `${operationKey}-${process.pid}.json`), {
|
|
763
|
+
operationId: request.operationId, requestDigest, phase: request.phase, pid: process.pid,
|
|
764
|
+
executable: config.executablePath, authority: config.authority, keyId: config.keyId,
|
|
765
|
+
}, 0o600)
|
|
766
|
+
const receiptPath = join(directory, 'receipt.json')
|
|
767
|
+
if (existsSync(receiptPath)) {
|
|
768
|
+
const cached = readOwnerJson(receiptPath, 'operation receipt')
|
|
769
|
+
if (cached.requestDigest !== requestDigest) fail('cached operation request digest changed')
|
|
770
|
+
releaseLock(); return { directory, requestDigest, receiptPath, cached: cached.receipt, releaseLock: undefined }
|
|
771
|
+
}
|
|
772
|
+
return { directory, requestDigest, receiptPath, releaseLock }
|
|
773
|
+
}
|
|
774
|
+
function reconciliationContext(request, config) {
|
|
775
|
+
const requestDigest = digest(request); const operationKey = sha256Bytes(request.operationId)
|
|
776
|
+
const operationsRoot = ensurePrivateSubdirectory(config.stateRoot, ['reconciliations'], 'reconciliations directory')
|
|
777
|
+
const directory = ensurePrivateSubdirectory(operationsRoot, [operationKey], 'reconciliation directory')
|
|
778
|
+
const bindingPath = join(directory, 'binding.json'); const binding = { operationId: request.operationId, requestDigest }
|
|
779
|
+
if (!existsSync(bindingPath)) immutableJson(bindingPath, binding, 0o600)
|
|
780
|
+
const prior = readOwnerJson(bindingPath, 'reconciliation binding')
|
|
781
|
+
if (prior.operationId !== request.operationId || prior.requestDigest !== requestDigest) fail('reconciliation operation id was reused with a different request')
|
|
782
|
+
const releaseLock = acquireProcessLock(join(directory, 'execution.lock'), 'reconciliation')
|
|
783
|
+
const invocations = ensurePrivateSubdirectory(config.stateRoot, ['invocations'], 'invocations directory')
|
|
784
|
+
immutableJson(join(invocations, `${operationKey}-${process.pid}.reconcile.json`), { operationId: request.operationId, requestDigest,
|
|
785
|
+
phase: 'registry-verify', command: 'reconcile', pid: process.pid, executable: config.executablePath,
|
|
786
|
+
authority: config.authority, keyId: config.keyId }, 0o600)
|
|
787
|
+
const receiptPath = join(directory, 'receipt.json')
|
|
788
|
+
if (existsSync(receiptPath)) {
|
|
789
|
+
const cached = readOwnerJson(receiptPath, 'reconciliation receipt')
|
|
790
|
+
if (cached.requestDigest !== requestDigest) fail('cached reconciliation request digest changed')
|
|
791
|
+
releaseLock(); return { directory, requestDigest, receiptPath, cached: cached.receipt, releaseLock: undefined }
|
|
792
|
+
}
|
|
793
|
+
return { directory, requestDigest, receiptPath, releaseLock }
|
|
794
|
+
}
|
|
795
|
+
function acquireProcessLock(path, label, retried = false) {
|
|
796
|
+
try {
|
|
797
|
+
writeSynced(path, Buffer.from(`${JSON.stringify({ pid: process.pid })}\n`), 0o600)
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (error?.code !== 'EEXIST') throw error
|
|
800
|
+
let owner
|
|
801
|
+
try { owner = readOwnerJson(path, `${label} execution lock`) } catch { fail(`${label} execution lock is invalid`) }
|
|
802
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail(`${label} execution lock is invalid`)
|
|
803
|
+
try { process.kill(owner.pid, 0); fail(`${label} is already executing`) } catch (probe) {
|
|
804
|
+
if (probe?.code !== 'ESRCH') throw probe
|
|
805
|
+
}
|
|
806
|
+
if (retried) fail(`${label} stale execution lock raced`)
|
|
807
|
+
unlinkSync(path); return acquireProcessLock(path, label, true)
|
|
808
|
+
}
|
|
809
|
+
let released = false
|
|
810
|
+
return () => {
|
|
811
|
+
if (released) return
|
|
812
|
+
const owner = readOwnerJson(path, `${label} execution lock`)
|
|
813
|
+
if (owner.pid !== process.pid) fail(`${label} execution lock ownership changed`)
|
|
814
|
+
unlinkSync(path); released = true
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function writeSynced(path, bytes, mode = 0o600) {
|
|
818
|
+
const descriptor = openSync(path, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, mode)
|
|
819
|
+
try { writeFileSync(descriptor, bytes); fsyncSync(descriptor) } finally { closeSync(descriptor) }
|
|
820
|
+
}
|
|
821
|
+
function immutableJson(path, value, mode = 0o600) {
|
|
822
|
+
const bytes = Buffer.from(`${canonicalReleaseValue(value)}\n`)
|
|
823
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
824
|
+
writeSynced(temporary, bytes, mode)
|
|
825
|
+
try { linkSync(temporary, path); fsyncDirectory(dirname(path)) } catch (error) { if (error?.code !== 'EEXIST') throw error } finally { unlinkSync(temporary); fsyncDirectory(dirname(path)) }
|
|
826
|
+
}
|
|
827
|
+
function immutableCopy(path, bytes, mode = 0o444) {
|
|
828
|
+
if (existsSync(path)) {
|
|
829
|
+
if (sha256Bytes(readBounded(path, 'immutable destination')) !== sha256Bytes(bytes)) fail('immutable destination already contains different bytes')
|
|
830
|
+
return false
|
|
831
|
+
}
|
|
832
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); chmodSync(dirname(path), 0o700)
|
|
833
|
+
const temporary = join(dirname(path), `.tmp-${basename(path)}-${process.pid}-${Date.now()}`)
|
|
834
|
+
writeSynced(temporary, bytes, 0o600)
|
|
835
|
+
try { linkSync(temporary, path); chmodSync(path, mode); fsyncDirectory(dirname(path)) } catch (error) {
|
|
836
|
+
if (error?.code !== 'EEXIST') throw error
|
|
837
|
+
if (sha256Bytes(readFileSync(path)) !== sha256Bytes(bytes)) fail('immutable destination raced with different bytes')
|
|
838
|
+
} finally { unlinkSync(temporary); fsyncDirectory(dirname(path)) }
|
|
839
|
+
return true
|
|
840
|
+
}
|
|
841
|
+
function assertUnexpired(request, label) { if (Date.now() > request.authorization.expiresAt) fail(`${label} crossed the authorization expiry`) }
|
|
842
|
+
|
|
843
|
+
function prPhase(request, config, context, authorization) {
|
|
844
|
+
const input = object(request.input, 'PR input'); const gitValue = gitConfig(config.git)
|
|
845
|
+
const repository = canonicalPath(input.repository, 'source repository'); const worktree = canonicalPath(input.worktree, 'source worktree')
|
|
846
|
+
if (repository !== worktree) {
|
|
847
|
+
const common = git(gitValue, ['rev-parse', '--path-format=absolute', '--git-common-dir'], worktree).toString('utf8').trim()
|
|
848
|
+
const repositoryGit = git(gitValue, ['rev-parse', '--path-format=absolute', '--git-common-dir'], repository).toString('utf8').trim()
|
|
849
|
+
if (realpathSync(common) !== realpathSync(repositoryGit)) fail('source worktree is not linked to the approved repository')
|
|
850
|
+
}
|
|
851
|
+
const baseCommit = text(input.baseCommit, 'base commit', COMMIT); const sourceName = text(input.name, 'source name', /^[a-z0-9][a-z0-9-]{0,63}$/u)
|
|
852
|
+
const scope = safeScope(input.scope); const expectedScope = expectedSourceScope(sourceName)
|
|
853
|
+
if (digest(scope) !== digest(expectedScope) || digest(input.scope) !== digest(scope)
|
|
854
|
+
|| authorization.baseCommit !== baseCommit || digest(scope) !== digest(safeScope(authorization.scope))) {
|
|
855
|
+
fail('PR input changed after owner authorization')
|
|
856
|
+
}
|
|
857
|
+
const current = git(gitValue, ['rev-parse', 'HEAD'], worktree).toString('utf8').trim()
|
|
858
|
+
if (current !== baseCommit || remoteRef(gitValue, `refs/heads/${gitValue.targetBranch}`) !== baseCommit) fail('source or target branch moved from the authorized base')
|
|
859
|
+
const changes = changedPaths(gitValue, worktree, baseCommit)
|
|
860
|
+
if (changes.length === 0 || changes.some(path => !pathAllowed(path, scope))) fail('source changes escape the authorized scope')
|
|
861
|
+
const source = sourceDigests(gitValue, worktree, baseCommit, scope, context.directory)
|
|
862
|
+
const expectedTreeDigest = text(input.expectedTreeDigest, 'expected tree digest', DIGEST)
|
|
863
|
+
const expectedPatchDigest = text(input.expectedPatchDigest, 'expected patch digest', DIGEST)
|
|
864
|
+
if (source.treeDigest !== expectedTreeDigest || source.patchDigest !== expectedPatchDigest
|
|
865
|
+
|| authorization.checkedTreeDigest !== source.treeDigest || authorization.checkedPatchDigest !== source.patchDigest) fail('source bytes changed after post-check authorization')
|
|
866
|
+
const headCommit = git(gitValue, ['commit-tree', source.tree, '-p', baseCommit, '-m', `dsh source release ${request.plan.id}`],
|
|
867
|
+
worktree, { ...source.environment, ...commitEnvironment(gitValue, request.requestedAt) }).toString('utf8').trim()
|
|
868
|
+
if (!COMMIT.test(headCommit) || headCommit === baseCommit) fail('PR commit was not created')
|
|
869
|
+
const prId = `pr-${sha256Bytes(request.operationId).slice(0, 32)}`; const ref = `refs/dsh-release/pulls/${prId}/head`
|
|
870
|
+
const prior = remoteRef(gitValue, ref)
|
|
871
|
+
assertUnexpired(request, 'PR creation')
|
|
872
|
+
if (prior === undefined) git(gitValue, ['push', '--force-with-lease=' + ref + ':', gitValue.remote, `${headCommit}:${ref}`], worktree)
|
|
873
|
+
else if (prior !== headCommit) fail('PR ref already exists with a different head')
|
|
874
|
+
if (remoteRef(gitValue, ref) !== headCommit) fail('PR ref did not become durable')
|
|
875
|
+
return { kind: 'pr', prId, baseCommit, headCommit,
|
|
876
|
+
repositoryDigest: digest({ remote: gitValue.remote, ref, baseCommit, headCommit, treeDigest: source.treeDigest, patchDigest: source.patchDigest }),
|
|
877
|
+
treeDigest: source.treeDigest, patchDigest: source.patchDigest }
|
|
878
|
+
}
|
|
879
|
+
function reviewPhase(request, config) {
|
|
880
|
+
const input = object(request.input, 'review input'); const gitValue = gitConfig(config.git)
|
|
881
|
+
if (gitValue.reviewStore === undefined || gitValue.reviewDecisionRoot === undefined) fail('review stores are not configured')
|
|
882
|
+
const prId = text(input.prId, 'PR id', ID); const headCommit = text(input.headCommit, 'review head', COMMIT)
|
|
883
|
+
const baseCommit = text(input.baseCommit, 'review base', COMMIT); text(input.prEvidenceDigest, 'PR evidence digest', DIGEST)
|
|
884
|
+
if (remoteRef(gitValue, `refs/dsh-release/pulls/${prId}/head`) !== headCommit) fail('reviewed PR ref does not match the requested head')
|
|
885
|
+
const parents = git(gitValue, ['--git-dir', gitValue.remote, 'rev-list', '--parents', '-n', '1', headCommit], gitValue.remote).toString('utf8').trim().split(' ')
|
|
886
|
+
if (parents.length !== 2 || parents[1] !== baseCommit) fail('reviewed PR is not based on the exact authorized commit')
|
|
887
|
+
const decision = readPrivateJsonUnder(gitValue.reviewDecisionRoot, `${prId}.json`, 'review decision')
|
|
888
|
+
exactKeys(decision, ['schemaVersion', 'kind', 'prId', 'baseCommit', 'headCommit', 'prEvidenceDigest', 'decision', 'reviewerPrincipal'], 'review decision')
|
|
889
|
+
if (decision.schemaVersion !== 1 || decision.kind !== 'dsh-local-review-decision' || decision.decision !== 'approved'
|
|
890
|
+
|| decision.prId !== prId || decision.baseCommit !== baseCommit || decision.headCommit !== headCommit
|
|
891
|
+
|| decision.prEvidenceDigest !== input.prEvidenceDigest) fail('review decision is not bound to the exact PR evidence')
|
|
892
|
+
const reviewerPrincipal = text(decision.reviewerPrincipal, 'reviewer principal', /^.{1,500}$/u, 500)
|
|
893
|
+
const reviewId = `review-${sha256Bytes(request.operationId).slice(0, 32)}`
|
|
894
|
+
return { kind: 'review', prId, headCommit, reviewId, decision: 'approved',
|
|
895
|
+
reviewerPrincipalDigest: sha256Bytes(reviewerPrincipal), prEvidenceDigest: input.prEvidenceDigest }
|
|
896
|
+
}
|
|
897
|
+
function readReviewReceipt(gitValue, reviewId) {
|
|
898
|
+
if (gitValue.reviewStore === undefined || gitValue.reviewAuthority === undefined) fail('independent review verification is not configured')
|
|
899
|
+
const receipt = readPrivateJsonUnder(gitValue.reviewStore, `${reviewId}.json`, 'review receipt')
|
|
900
|
+
const { signature, ...unsigned } = receipt
|
|
901
|
+
if (receipt.authority !== gitValue.reviewAuthority.authority || receipt.keyId !== gitValue.reviewAuthority.keyId
|
|
902
|
+
|| !verify(null, Buffer.from(canonicalReleaseValue(unsigned)), gitValue.reviewAuthority.publicKey, Buffer.from(signature, 'base64'))) fail('independent review receipt signature is invalid')
|
|
903
|
+
return receipt
|
|
904
|
+
}
|
|
905
|
+
function mergePhase(request, config, authorization) {
|
|
906
|
+
const input = object(request.input, 'merge input'); const gitValue = gitConfig(config.git)
|
|
907
|
+
const prId = text(input.prId, 'PR id', ID); const headCommit = text(input.headCommit, 'merge head', COMMIT)
|
|
908
|
+
const reviewId = text(input.reviewId, 'review id', ID); const reviewEvidenceDigest = text(input.reviewEvidenceDigest, 'review evidence digest', DIGEST)
|
|
909
|
+
const targetBranch = text(input.targetBranch, 'target branch')
|
|
910
|
+
if (targetBranch !== gitValue.targetBranch) fail('merge target branch is not owner-configured')
|
|
911
|
+
const review = readReviewReceipt(gitValue, reviewId)
|
|
912
|
+
if (review.outcome !== 'passed' || review.phase !== 'review' || review.evidence?.reviewId !== reviewId
|
|
913
|
+
|| review.evidence.prId !== prId || review.evidence.headCommit !== headCommit || review.evidenceDigest !== reviewEvidenceDigest) fail('merge is not bound to the independent review receipt')
|
|
914
|
+
if (remoteRef(gitValue, `refs/dsh-release/pulls/${prId}/head`) !== headCommit) fail('PR head changed after review')
|
|
915
|
+
const baseCommit = text(authorization.baseCommit, 'authorized base', COMMIT); const targetRef = `refs/heads/${targetBranch}`
|
|
916
|
+
const target = remoteRef(gitValue, targetRef)
|
|
917
|
+
const tree = git(gitValue, ['--git-dir', gitValue.remote, 'rev-parse', `${headCommit}^{tree}`], gitValue.remote).toString('utf8').trim()
|
|
918
|
+
const mergeCommit = git(gitValue, ['--git-dir', gitValue.remote, 'commit-tree', tree, '-p', baseCommit, '-p', headCommit,
|
|
919
|
+
'-m', `Merge ${prId} after ${reviewId}`], gitValue.remote, commitEnvironment(gitValue, request.requestedAt)).toString('utf8').trim()
|
|
920
|
+
if (!COMMIT.test(mergeCommit)) fail('merge commit was not created')
|
|
921
|
+
assertUnexpired(request, 'merge')
|
|
922
|
+
if (target === baseCommit) git(gitValue, ['--git-dir', gitValue.remote, 'update-ref', targetRef, mergeCommit, baseCommit], gitValue.remote)
|
|
923
|
+
else if (target !== mergeCommit) fail('target branch moved before merge CAS')
|
|
924
|
+
if (remoteRef(gitValue, targetRef) !== mergeCommit) fail('merge ref did not become durable')
|
|
925
|
+
return { kind: 'merge', prId, reviewedHeadCommit: headCommit, reviewId, reviewEvidenceDigest, mergeCommit, targetBranch }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function copyBuildWorkspace(source, destination) {
|
|
929
|
+
cpSync(source, destination, { recursive: true, dereference: false, errorOnExist: true, filter: path => {
|
|
930
|
+
const name = basename(path)
|
|
931
|
+
return name !== '.git' && name !== 'node_modules'
|
|
932
|
+
} })
|
|
933
|
+
const inspect = path => {
|
|
934
|
+
const metadata = lstatSync(path)
|
|
935
|
+
if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) fail('build source contains an unsupported filesystem entry')
|
|
936
|
+
if (metadata.isDirectory()) for (const name of readdirSync(path)) inspect(join(path, name))
|
|
937
|
+
}
|
|
938
|
+
inspect(destination)
|
|
939
|
+
}
|
|
940
|
+
function fileInventory(root) {
|
|
941
|
+
const result = []
|
|
942
|
+
const visit = (directory, prefix = '') => {
|
|
943
|
+
for (const name of readdirSync(directory).sort()) {
|
|
944
|
+
const path = join(directory, name); const relativeName = prefix === '' ? name : `${prefix}/${name}`; const metadata = lstatSync(path)
|
|
945
|
+
if (metadata.isDirectory()) visit(path, relativeName)
|
|
946
|
+
else if (metadata.isFile()) { const bytes = readFileSync(path); result.push({ path: relativeName, bytes: bytes.length, sha256: sha256Bytes(bytes) }) }
|
|
947
|
+
else fail('package inventory contains an unsupported entry')
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
visit(root); return result
|
|
951
|
+
}
|
|
952
|
+
function prepareBuildSource(request, context, gitValue, hooks) {
|
|
953
|
+
const bundle = join(context.directory, 'source.bundle'); rmSync(bundle, { force: true })
|
|
954
|
+
const temporaryRef = `refs/dsh-release/build/${sha256Bytes(request.operationId).slice(0, 32)}`
|
|
955
|
+
git(gitValue, ['--git-dir', gitValue.remote, 'update-ref', temporaryRef, request.input.mergeCommit], gitValue.remote, {}, hooks)
|
|
956
|
+
try { git(gitValue, ['--git-dir', gitValue.remote, 'bundle', 'create', bundle, temporaryRef], gitValue.remote, {}, hooks) }
|
|
957
|
+
finally { git(gitValue, ['--git-dir', gitValue.remote, 'update-ref', '-d', temporaryRef], gitValue.remote, {}, hooks) }
|
|
958
|
+
chmodSync(bundle, 0o400); return bundle
|
|
959
|
+
}
|
|
960
|
+
function unpackPackage(build, tarball, staging, runRoot, hooks) {
|
|
961
|
+
mkdirSync(staging, { mode: 0o700 })
|
|
962
|
+
const commandHooks = { beforeSpawn: hooks.beforeTarSpawn, afterSpawn: hooks.afterTarSpawn, afterFinally: hooks.afterTarFinally }
|
|
963
|
+
const names = command(build.tarExecutable, ['-tzf', tarball], runRoot, {}, undefined, [], commandHooks).toString('utf8').split('\n').filter(Boolean)
|
|
964
|
+
if (names.length === 0 || names.some(name => name.startsWith('/') || name.includes('\0')
|
|
965
|
+
|| name.split('/').some(part => part === '..') || (name !== 'package' && !name.startsWith('package/')))) {
|
|
966
|
+
fail('packed archive contains an unsafe path')
|
|
967
|
+
}
|
|
968
|
+
command(build.tarExecutable, ['--no-same-owner', '--no-same-permissions', '-xzf', tarball, '-C', staging], runRoot, {}, undefined, [], commandHooks)
|
|
969
|
+
const packageRoot = join(staging, 'package')
|
|
970
|
+
if (!existsSync(packageRoot) || !lstatSync(packageRoot).isDirectory()) fail('packed archive has no package root')
|
|
971
|
+
const inventory = fileInventory(packageRoot)
|
|
972
|
+
return { packageRoot, inventory }
|
|
973
|
+
}
|
|
974
|
+
function runIsolatedBuild(request, context, runNumber, gitValue, build, packagePath, sourceBundle, hooks) {
|
|
975
|
+
const runRoot = join(context.directory, `build-${runNumber}`); rmSync(runRoot, { recursive: true, force: true }); mkdirSync(runRoot, { mode: 0o700 })
|
|
976
|
+
const checkout = join(runRoot, 'checkout'); mkdirSync(checkout, { mode: 0o700 }); git(gitValue, ['init'], checkout, {}, hooks)
|
|
977
|
+
git(gitValue, ['fetch', sourceBundle, temporaryBuildRef(request)], checkout, {}, hooks); git(gitValue, ['checkout', '--detach', 'FETCH_HEAD'], checkout, {}, hooks)
|
|
978
|
+
if (git(gitValue, ['rev-parse', 'HEAD'], checkout).toString('utf8').trim() !== request.input.mergeCommit
|
|
979
|
+
|| git(gitValue, ['status', '--porcelain=v1', '--untracked-files=all'], checkout).length !== 0) fail('exact build checkout is not clean')
|
|
980
|
+
const workspace = join(runRoot, 'workspace'); copyBuildWorkspace(checkout, workspace)
|
|
981
|
+
const packageRoot = within(workspace, packagePath, 'package path')
|
|
982
|
+
if (!lstatSync(packageRoot).isDirectory() || realpathSync(packageRoot) !== resolve(packageRoot)) fail('expected package directory is unavailable')
|
|
983
|
+
const output = join(runRoot, 'pack-output'); mkdirSync(output, { mode: 0o700 })
|
|
984
|
+
const sandbox = openSandboxContext(build, workspace, output, runRoot)
|
|
985
|
+
try {
|
|
986
|
+
sandboxCommand(build, ['install', '--offline', '--frozen-lockfile', '--frozen-store', '--ignore-scripts',
|
|
987
|
+
'--package-import-method=copy', '--store-dir=/store'], sandbox, hooks)
|
|
988
|
+
sandboxCommand(build, ['--dir', `/workspace/${packagePath}`, 'run', 'build'], sandbox, hooks)
|
|
989
|
+
sandboxCommand(build, ['--dir', `/workspace/${packagePath}`, 'pack', '--pack-destination', '/output'], sandbox, hooks)
|
|
990
|
+
} finally { closeSandboxContext(sandbox) }
|
|
991
|
+
const packed = readdirSync(output).filter(name => name.endsWith('.tgz'))
|
|
992
|
+
if (packed.length !== 1 || readdirSync(output).length !== 1) fail('pnpm pack must produce exactly one tarball')
|
|
993
|
+
const tarball = safeRegularFile(join(output, packed[0]), `isolated build ${runNumber} tarball`)
|
|
994
|
+
const bytes = readBounded(tarball, `isolated build ${runNumber} tarball`)
|
|
995
|
+
const unpacked = unpackPackage(build, tarball, join(runRoot, 'packed'), runRoot, hooks)
|
|
996
|
+
return { runRoot, checkout, workspace, packageRoot: unpacked.packageRoot, inventory: unpacked.inventory, tarball, bytes, sha256: sha256Bytes(bytes) }
|
|
997
|
+
}
|
|
998
|
+
function temporaryBuildRef(request) { return `refs/dsh-release/build/${sha256Bytes(request.operationId).slice(0, 32)}` }
|
|
999
|
+
function buildPhase(request, config, context, hooks) {
|
|
1000
|
+
const input = object(request.input, 'build input'); const gitValue = gitConfig(config.git); const build = buildConfig(config.build)
|
|
1001
|
+
const mergeCommit = text(input.mergeCommit, 'merge commit', COMMIT); text(input.mergeEvidenceDigest, 'merge evidence digest', DIGEST)
|
|
1002
|
+
const packageName = text(input.expectedPackageName, 'expected package name', PACKAGE)
|
|
1003
|
+
const packageVersion = text(input.expectedPackageVersion, 'expected package version', VERSION)
|
|
1004
|
+
const packagePath = relativePath(input.expectedPackagePath, 'expected package path')
|
|
1005
|
+
const sourceName = text(input.name, 'source name', /^[a-z0-9][a-z0-9-]{0,63}$/u)
|
|
1006
|
+
const candidateId = text(input.expectedCandidateId, 'expected candidate id', /^[a-z0-9][a-z0-9-]{0,63}$/u)
|
|
1007
|
+
const policy = request.authorization.releasePolicy
|
|
1008
|
+
if (candidateId !== sourceName || policy.candidateId !== candidateId || policy.packageName !== packageName
|
|
1009
|
+
|| policy.packageVersion !== packageVersion || policy.packagePath !== packagePath || policy.dshBaseline !== input.expectedDshBaseline
|
|
1010
|
+
|| digest(policy.capabilities) !== digest(input.expectedCapabilities) || digest(policy.authorities) !== digest(input.expectedAuthorities)
|
|
1011
|
+
|| digest(policy.requires) !== digest(input.expectedRequires) || policy.targetBranch !== gitValue.targetBranch
|
|
1012
|
+
|| !safeScope(request.authorization.scope).some(scope => pathAllowed(packagePath, [scope]))) {
|
|
1013
|
+
fail('build input does not match the owner-authorized release policy')
|
|
1014
|
+
}
|
|
1015
|
+
if (remoteRef(gitValue, `refs/heads/${gitValue.targetBranch}`, hooks) !== mergeCommit) fail('build commit is not the exact merged target')
|
|
1016
|
+
const sourceBundle = prepareBuildSource(request, context, gitValue, hooks)
|
|
1017
|
+
verifyBuildPins(build)
|
|
1018
|
+
const builds = [runIsolatedBuild(request, context, 1, gitValue, build, packagePath, sourceBundle, hooks)]
|
|
1019
|
+
for (let index = 2; index <= policy.minimumReproducibleBuilds; index += 1) {
|
|
1020
|
+
const repeated = runIsolatedBuild(request, context, index, gitValue, build, packagePath, sourceBundle, hooks)
|
|
1021
|
+
if (builds[0].sha256 !== repeated.sha256 || digest(builds[0].inventory) !== digest(repeated.inventory)) fail('isolated builds are not reproducible')
|
|
1022
|
+
builds.push(repeated)
|
|
1023
|
+
}
|
|
1024
|
+
verifyBuildPins(build)
|
|
1025
|
+
const first = builds[0]
|
|
1026
|
+
const manifest = readOwnerJson(join(first.packageRoot, 'package.json'), 'built package manifest')
|
|
1027
|
+
if (manifest.name !== packageName || manifest.version !== packageVersion || manifest.dsh?.bundle?.patch !== './cordis.patch.yml') {
|
|
1028
|
+
fail('built package identity or DSH bundle metadata does not match the request')
|
|
1029
|
+
}
|
|
1030
|
+
const packedPaths = new Set(first.inventory.map(file => file.path))
|
|
1031
|
+
for (const required of ['package.json', 'cordis.patch.yml', 'README.md', 'LICENSE']) if (!packedPaths.has(required)) fail(`packed package is missing ${required}`)
|
|
1032
|
+
if (![...packedPaths].some(path => path.startsWith('lib/')) || [...packedPaths].some(path => path === 'src' || path.startsWith('src/')
|
|
1033
|
+
|| path === 'tests' || path.startsWith('tests/') || path === 'tsconfig.json' || path.startsWith('tsconfig.'))) fail('packed package inventory is invalid')
|
|
1034
|
+
const outputs = ensurePrivateSubdirectory(context.directory, ['outputs'], 'build outputs directory')
|
|
1035
|
+
assertUnexpired(request, 'build artifact commit')
|
|
1036
|
+
const tarballPath = join(outputs, 'package.tgz'); immutableCopy(tarballPath, first.bytes, 0o600)
|
|
1037
|
+
const tarballBytes = first.bytes.length; const tarballSha256 = first.sha256; const tarballIntegrity = sha512Integrity(first.bytes)
|
|
1038
|
+
const sbom = { bomFormat: 'CycloneDX', specVersion: '1.5', serialNumber: `urn:uuid:${request.installationId}`, version: 1,
|
|
1039
|
+
metadata: { component: { type: 'library', name: packageName, version: packageVersion } },
|
|
1040
|
+
components: first.inventory.map(file => ({ type: 'file', name: file.path, hashes: [{ alg: 'SHA-256', content: file.sha256 }],
|
|
1041
|
+
properties: [{ name: 'dsh:file-bytes', value: String(file.bytes) }] })) }
|
|
1042
|
+
const sbomPath = join(outputs, 'sbom.cdx.json'); immutableCopy(sbomPath, Buffer.from(`${canonicalReleaseValue(sbom)}\n`), 0o600)
|
|
1043
|
+
const provenance = { _type: 'https://in-toto.io/Statement/v1', subject: [{ name: packageName, digest: { sha256: tarballSha256 } }],
|
|
1044
|
+
predicateType: 'https://slsa.dev/provenance/v1', predicate: { buildDefinition: { buildType: 'https://dsh-enhanced.dev/build/local-isolated/v1',
|
|
1045
|
+
externalParameters: { packagePath, packageVersion }, resolvedDependencies: [{ uri: pathToFileURL(gitValue.remote).href, digest: { gitCommit: mergeCommit } }] },
|
|
1046
|
+
runDetails: { builder: { id: `dsh-local-release-adapter:${config.authority}:${config.keyId}` }, metadata: { invocationId: request.operationId },
|
|
1047
|
+
byproducts: [{ name: 'sbom', digest: { sha256: sha256Bytes(readFileSync(sbomPath)) } }] } } }
|
|
1048
|
+
const provenancePath = join(outputs, 'provenance.intoto.jsonl'); immutableCopy(provenancePath, Buffer.from(`${canonicalReleaseValue(provenance)}\n`), 0o600)
|
|
1049
|
+
const metadata = { dshBaseline: input.expectedDshBaseline, capabilities: input.expectedCapabilities,
|
|
1050
|
+
authorities: input.expectedAuthorities, requires: input.expectedRequires }
|
|
1051
|
+
return { kind: 'build', isolated: true, reproducibleBuilds: policy.minimumReproducibleBuilds, firstBuildSha256: first.sha256, secondBuildSha256: builds[1].sha256,
|
|
1052
|
+
mergeEvidenceDigest: input.mergeEvidenceDigest, sourceName, candidateId, packagePath, packageName, packageVersion,
|
|
1053
|
+
tarballPath: realpathSync(tarballPath), tarballBytes, tarballSha256, tarballIntegrity,
|
|
1054
|
+
sbomPath: realpathSync(sbomPath), sbomSha256: sha256Bytes(readFileSync(sbomPath)), provenancePath: realpathSync(provenancePath),
|
|
1055
|
+
provenanceSha256: sha256Bytes(readFileSync(provenancePath)), mergedCommit: mergeCommit, ...metadata }
|
|
1056
|
+
}
|
|
1057
|
+
function artifactSigningPayload(artifact) { return canonicalReleaseValue({ schemaVersion: 1, kind: 'dsh-release-artifact', artifact }) }
|
|
1058
|
+
function verifyArtifactFiles(artifact) {
|
|
1059
|
+
const tarball = inheritedArtifactBytes('tarball'); const sbom = inheritedArtifactBytes('sbom'); const provenance = inheritedArtifactBytes('provenance')
|
|
1060
|
+
if (tarball.length !== artifact.tarballBytes || sha256Bytes(tarball) !== artifact.tarballSha256 || sha512Integrity(tarball) !== artifact.tarballIntegrity
|
|
1061
|
+
|| sha256Bytes(sbom) !== artifact.sbomSha256 || sha256Bytes(provenance) !== artifact.provenanceSha256) fail('inherited artifact files do not match build evidence')
|
|
1062
|
+
return tarball
|
|
1063
|
+
}
|
|
1064
|
+
function signPhase(request, config) {
|
|
1065
|
+
const input = object(request.input, 'sign input'); const artifact = object(input.artifact, 'release artifact')
|
|
1066
|
+
text(input.buildEvidenceDigest, 'build evidence digest', DIGEST); verifyArtifactFiles(artifact)
|
|
1067
|
+
assertUnexpired(request, 'artifact signing')
|
|
1068
|
+
const artifactStatementDigest = digest(artifact)
|
|
1069
|
+
const artifactSignature = sign(null, Buffer.from(artifactSigningPayload(artifact)), config.privateKey).toString('base64')
|
|
1070
|
+
return { kind: 'sign', artifactStatementDigest, artifactSignature, artifactSignatureDigest: sha256Bytes(Buffer.from(artifactSignature, 'base64')),
|
|
1071
|
+
buildEvidenceDigest: input.buildEvidenceDigest }
|
|
1072
|
+
}
|
|
1073
|
+
function verifySignedArtifact(input, registry, verifyFiles = true) {
|
|
1074
|
+
if (registry.signer === undefined) fail('artifact signer is not configured')
|
|
1075
|
+
const artifact = object(input.artifact, 'release artifact'); const statementDigest = text(input.artifactStatementDigest, 'artifact statement digest', DIGEST)
|
|
1076
|
+
const signature = text(input.artifactSignature, 'artifact signature', /^[A-Za-z0-9+/]+={0,2}$/u, 16_384)
|
|
1077
|
+
if (statementDigest !== digest(artifact)
|
|
1078
|
+
|| !verify(null, Buffer.from(artifactSigningPayload(artifact)), registry.signer.publicKey, Buffer.from(signature, 'base64'))) fail('artifact signature is invalid')
|
|
1079
|
+
return { artifact, statementDigest, signature, signatureDigest: sha256Bytes(Buffer.from(signature, 'base64')),
|
|
1080
|
+
...(verifyFiles ? { tarball: verifyArtifactFiles(artifact) } : {}) }
|
|
1081
|
+
}
|
|
1082
|
+
async function publishPhase(request, config, hooks) {
|
|
1083
|
+
const input = object(request.input, 'publish input'); const registry = registryConfig(config.registry)
|
|
1084
|
+
if (request.registry.id !== registry.id || request.registry.locator !== registry.locator) fail('publish request targets a different registry')
|
|
1085
|
+
text(input.signEvidenceDigest, 'sign evidence digest', DIGEST)
|
|
1086
|
+
const signed = verifySignedArtifact(input, registry); const artifact = signed.artifact
|
|
1087
|
+
const packagesRoot = ensurePrivateSubdirectory(registry.root, ['packages'], 'registry packages directory')
|
|
1088
|
+
const packageDirectory = ensurePrivateSubdirectory(packagesRoot, [encodeURIComponent(artifact.packageName)], 'registry package directory')
|
|
1089
|
+
const versionDirectory = join(packageDirectory, artifact.packageVersion)
|
|
1090
|
+
const objectPath = join(versionDirectory, 'package.tgz')
|
|
1091
|
+
assertUnexpired(request, 'registry publication')
|
|
1092
|
+
const recordPath = join(versionDirectory, 'publication.json')
|
|
1093
|
+
const registryReference = pathToFileURL(objectPath).href
|
|
1094
|
+
if (request.authorization.releasePolicy.registryReference !== registryReference) fail('published registry reference is not the owner-authorized immutable reference')
|
|
1095
|
+
const publication = { schemaVersion: 1, registryId: registry.id, packageName: artifact.packageName, packageVersion: artifact.packageVersion,
|
|
1096
|
+
tarballSha256: artifact.tarballSha256, tarballIntegrity: artifact.tarballIntegrity, artifactStatementDigest: signed.statementDigest,
|
|
1097
|
+
artifactSignatureDigest: signed.signatureDigest, registryReference }
|
|
1098
|
+
if (!existsSync(versionDirectory)) {
|
|
1099
|
+
const stagingName = `.pending-${artifact.packageVersion}-${sha256Bytes(request.operationId).slice(0, 32)}`
|
|
1100
|
+
const staging = ensurePrivateSubdirectory(packageDirectory, [stagingName], 'registry staging directory')
|
|
1101
|
+
const stagingObject = join(staging, 'package.tgz'); const stagingRecord = join(staging, 'publication.json')
|
|
1102
|
+
immutableCopy(stagingObject, signed.tarball, 0o400)
|
|
1103
|
+
await hooks.afterPublishObject?.({ request, config, objectPath: stagingObject, recordPath: stagingRecord, publication })
|
|
1104
|
+
assertUnexpired(request, 'registry publication record commit')
|
|
1105
|
+
if (!existsSync(stagingRecord)) immutableJson(stagingRecord, publication, 0o400)
|
|
1106
|
+
else if (digest(readOwnerJson(stagingRecord, 'staged registry publication')) !== digest(publication)) fail('staged publication differs')
|
|
1107
|
+
fsyncDirectory(staging)
|
|
1108
|
+
try { renameSync(staging, versionDirectory); fsyncDirectory(packageDirectory) } catch (error) {
|
|
1109
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error?.code)) throw error
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
privateDirectory(versionDirectory, 'registry version directory')
|
|
1113
|
+
if (digest(readOwnerJson(recordPath, 'registry publication')) !== digest(publication)
|
|
1114
|
+
|| sha256Bytes(readBounded(objectPath, 'registry object')) !== artifact.tarballSha256) fail('package version is already immutably published with different evidence')
|
|
1115
|
+
const hookResult = await hooks.afterPublishWrite?.({ request, config, objectPath, recordPath, publication })
|
|
1116
|
+
if (hookResult?.outcome === 'ambiguous') throw new PublishAmbiguity(hookResult.detail ?? 'publish outcome is ambiguous')
|
|
1117
|
+
return { kind: 'publish', registryId: registry.id, registryReference, packageName: artifact.packageName, packageVersion: artifact.packageVersion,
|
|
1118
|
+
tarballSha256: artifact.tarballSha256, tarballIntegrity: artifact.tarballIntegrity, artifactStatementDigest: signed.statementDigest,
|
|
1119
|
+
artifactSignatureDigest: signed.signatureDigest, signEvidenceDigest: input.signEvidenceDigest, immutable: true }
|
|
1120
|
+
}
|
|
1121
|
+
function registryVerifyPhase(request, config, context) {
|
|
1122
|
+
const input = object(request.input, 'registry verification input'); const registry = registryConfig(config.registry)
|
|
1123
|
+
if (registry.downloadRoot === undefined || request.registry.id !== registry.id || request.registry.locator !== registry.locator) fail('registry verifier is not independently configured')
|
|
1124
|
+
text(input.publishEvidenceDigest, 'publish evidence digest', DIGEST)
|
|
1125
|
+
const signed = verifySignedArtifact(input, registry, true); const reference = text(input.registryReference, 'registry reference')
|
|
1126
|
+
if (request.authorization.releasePolicy.registryReference !== reference) fail('registry verification reference is not owner-authorized')
|
|
1127
|
+
let source
|
|
1128
|
+
try { source = fileURLToPath(reference) } catch { fail('registry reference is not a local file URL') }
|
|
1129
|
+
const canonicalSource = safeRegularFile(source, 'registry object')
|
|
1130
|
+
if (!canonicalSource.startsWith(`${join(registry.root, 'packages')}${sep}`)) fail('registry object is outside the configured immutable store')
|
|
1131
|
+
const publication = readOwnerJson(registryPublicationPath(registry, signed.artifact.packageName, signed.artifact.packageVersion), 'registry publication')
|
|
1132
|
+
const expectedPublication = { schemaVersion: 1, registryId: registry.id, packageName: signed.artifact.packageName,
|
|
1133
|
+
packageVersion: signed.artifact.packageVersion, tarballSha256: signed.artifact.tarballSha256, tarballIntegrity: signed.artifact.tarballIntegrity,
|
|
1134
|
+
artifactStatementDigest: signed.statementDigest, artifactSignatureDigest: signed.signatureDigest, registryReference: reference }
|
|
1135
|
+
if (digest(publication) !== digest(expectedPublication)) fail('registry publication record does not bind the signed artifact')
|
|
1136
|
+
const destinationDirectory = ensurePrivateSubdirectory(registry.downloadRoot, [sha256Bytes(request.operationId)], 'registry download directory')
|
|
1137
|
+
const destination = join(destinationDirectory, 'package.tgz')
|
|
1138
|
+
assertUnexpired(request, 'registry verification download')
|
|
1139
|
+
if (!existsSync(destination)) {
|
|
1140
|
+
const temporary = join(destinationDirectory, `.package-${process.pid}.tmp`)
|
|
1141
|
+
const sourceBytes = readBounded(canonicalSource, 'registry object')
|
|
1142
|
+
writeSynced(temporary, sourceBytes, 0o600)
|
|
1143
|
+
try { linkSync(temporary, destination); fsyncDirectory(destinationDirectory) } catch (error) { if (error?.code !== 'EEXIST') throw error }
|
|
1144
|
+
finally { unlinkSync(temporary); fsyncDirectory(destinationDirectory) }
|
|
1145
|
+
}
|
|
1146
|
+
const downloaded = readBounded(destination, 'independent registry download')
|
|
1147
|
+
if (statSync(canonicalSource).ino === statSync(destination).ino || sha256Bytes(downloaded) !== signed.artifact.tarballSha256
|
|
1148
|
+
|| downloaded.length !== signed.artifact.tarballBytes || sha512Integrity(downloaded) !== signed.artifact.tarballIntegrity) fail('independent registry download does not match the artifact')
|
|
1149
|
+
immutableJson(join(context.directory, 'download.json'), { source: canonicalSource, destination: realpathSync(destination) }, 0o600)
|
|
1150
|
+
return { kind: 'registry-verify', registryId: registry.id, registryReference: reference, independentlyDownloaded: true,
|
|
1151
|
+
downloadedBytes: downloaded.length, downloadedSha256: sha256Bytes(downloaded), downloadedIntegrity: sha512Integrity(downloaded),
|
|
1152
|
+
artifactStatementDigest: signed.statementDigest, artifactSignatureDigest: signed.signatureDigest,
|
|
1153
|
+
publishEvidenceDigest: input.publishEvidenceDigest }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async function catalogAdmissionPhase(request, config, registryVerificationReceipt, hooks = {}) {
|
|
1157
|
+
const input = object(request.input, 'catalog admission input'); const catalog = catalogConfig(config.catalog)
|
|
1158
|
+
const { id, path } = catalog
|
|
1159
|
+
if (request.catalog.id !== id || request.catalog.path !== path) fail('catalog request targets a different owner catalog')
|
|
1160
|
+
const registry = registryConfig(config.registry); const signed = verifySignedArtifact(input, registry, true)
|
|
1161
|
+
const expectedBeforeDigest = text(input.expectedBeforeCatalogDigest, 'expected catalog digest', DIGEST)
|
|
1162
|
+
const expectedAfterDigest = text(input.expectedAfterCatalogDigest, 'expected after catalog digest', DIGEST)
|
|
1163
|
+
if (registryVerificationReceipt === undefined) fail('catalog admission has no verified registry receipt')
|
|
1164
|
+
const verificationEvidenceDigest = registryVerificationReceipt.evidenceDigest
|
|
1165
|
+
const candidate = object(input.candidate, 'catalog candidate')
|
|
1166
|
+
const expectedCandidate = { id: input.artifact.candidateId, package: input.artifact.packageName, version: input.artifact.packageVersion,
|
|
1167
|
+
integrity: input.artifact.tarballIntegrity, registry: { id: registry.id, locator: registry.locator,
|
|
1168
|
+
reference: input.registryReference }, requires: input.artifact.requires, dshBaseline: input.artifact.dshBaseline,
|
|
1169
|
+
capabilities: input.artifact.capabilities, authorities: input.artifact.authorities }
|
|
1170
|
+
if (digest(candidate) !== digest(expectedCandidate)) fail('catalog candidate does not match the signed artifact')
|
|
1171
|
+
const reference = text(input.registryReference, 'registry reference')
|
|
1172
|
+
if (request.authorization.releasePolicy.registryReference !== reference) fail('catalog registry reference is not owner-authorized')
|
|
1173
|
+
let registryPath
|
|
1174
|
+
try { registryPath = fileURLToPath(reference) } catch { fail('catalog registry reference is not a local file URL') }
|
|
1175
|
+
const registryBytes = readBounded(registryPath, 'catalog registry object')
|
|
1176
|
+
if (!realpathSync(registryPath).startsWith(`${join(registry.root, 'packages')}${sep}`)
|
|
1177
|
+
|| sha256Bytes(registryBytes) !== input.artifact.tarballSha256 || sha512Integrity(registryBytes) !== input.artifact.tarballIntegrity) {
|
|
1178
|
+
fail('catalog registry reference does not contain the signed artifact')
|
|
1179
|
+
}
|
|
1180
|
+
assertUnexpired(request, 'catalog admission')
|
|
1181
|
+
const helperInput = { catalog: { id, path }, registry: { id: registry.id, locator: registry.locator },
|
|
1182
|
+
installationId: request.installationId, operationId: request.operationId, plan: request.plan, release: request.release,
|
|
1183
|
+
expectedBeforeCatalogDigest: expectedBeforeDigest, expectedAfterCatalogDigest: expectedAfterDigest, registryReference: reference,
|
|
1184
|
+
artifactStatementDigest: signed.statementDigest, artifactSignature: signed.signature, verificationEvidenceDigest, candidate }
|
|
1185
|
+
const helperSource = `import { readFileSync } from 'node:fs';
|
|
1186
|
+
const helper=await import('file:///proc/self/fd/3');const input=JSON.parse(readFileSync(0,'utf8'));
|
|
1187
|
+
if(typeof helper.admitCatalogCandidate!=='function')throw new Error('catalog admission helper is unavailable');
|
|
1188
|
+
process.stdout.write(JSON.stringify(await helper.admitCatalogCandidate(input))+'\\n');`
|
|
1189
|
+
let result
|
|
1190
|
+
const stdout = runPinnedNodeModule(catalog.interpreter, catalog.helper, helperSource, dirname(path), Buffer.from(JSON.stringify(helperInput)), {
|
|
1191
|
+
beforeSpawn: hooks.beforeCatalogHelperSpawn, afterSpawn: hooks.afterCatalogHelperSpawn, afterFinally: hooks.afterCatalogHelperFinally,
|
|
1192
|
+
})
|
|
1193
|
+
result = object(JSON.parse(stdout.toString('utf8')), 'catalog helper result')
|
|
1194
|
+
if (result.evidence.afterCatalogDigest !== expectedAfterDigest) fail('catalog admission produced an unauthorized after digest')
|
|
1195
|
+
return { ...result.evidence, registryReference: reference, artifactStatementDigest: signed.statementDigest,
|
|
1196
|
+
artifactSignatureDigest: signed.signatureDigest, verificationEvidenceDigest, candidate }
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function executePhase(request, config, context, authorization, hooks, registryVerificationReceipt) {
|
|
1200
|
+
if (request.phase === 'pr') return prPhase(request, config, context, authorization)
|
|
1201
|
+
if (request.phase === 'review') return reviewPhase(request, config)
|
|
1202
|
+
if (request.phase === 'merge') return mergePhase(request, config, authorization)
|
|
1203
|
+
if (request.phase === 'build') return buildPhase(request, config, context, hooks)
|
|
1204
|
+
if (request.phase === 'sign') return signPhase(request, config)
|
|
1205
|
+
if (request.phase === 'publish') return publishPhase(request, config, hooks)
|
|
1206
|
+
if (request.phase === 'registry-verify') return registryVerifyPhase(request, config, context)
|
|
1207
|
+
if (request.phase === 'catalog-admission') return catalogAdmissionPhase(request, config, registryVerificationReceipt, hooks)
|
|
1208
|
+
fail('unsupported release phase')
|
|
1209
|
+
}
|
|
1210
|
+
function signedReceipt(request, config, requestDigest, evidence, outcome = 'passed') {
|
|
1211
|
+
const observedAt = request.requestedAt
|
|
1212
|
+
if (Date.now() < observedAt || Date.now() > request.authorization.expiresAt) fail('request is outside its authorization interval')
|
|
1213
|
+
const expiresAt = Math.min(observedAt + request.receiptTtlMs, request.authorization.expiresAt)
|
|
1214
|
+
if (expiresAt <= observedAt) fail('source release authorization expired before receipt issuance')
|
|
1215
|
+
const unsigned = { schemaVersion: 1, receiptId: `receipt-${sha256Bytes(request.operationId).slice(0, 32)}`,
|
|
1216
|
+
authority: config.authority, keyId: config.keyId, installationId: request.installationId, planId: request.plan.id,
|
|
1217
|
+
planDigest: request.plan.digest, releaseId: request.release.id, fence: request.release.fence, operationId: request.operationId,
|
|
1218
|
+
requestDigest, phase: request.phase, outcome, evidence, evidenceDigest: digest(evidence), observedAt, expiresAt }
|
|
1219
|
+
return { ...unsigned, signature: sign(null, Buffer.from(canonicalReleaseValue(unsigned)), config.privateKey).toString('base64') }
|
|
1220
|
+
}
|
|
1221
|
+
function signedReconciliationReceipt(request, config, requestDigest, evidence) {
|
|
1222
|
+
const observedAt = request.requestedAt
|
|
1223
|
+
if (Date.now() < observedAt || Date.now() > request.authorization.expiresAt) fail('reconciliation request is outside its authorization interval')
|
|
1224
|
+
const expiresAt = Math.min(observedAt + request.receiptTtlMs, request.authorization.expiresAt)
|
|
1225
|
+
if (expiresAt <= observedAt) fail('source release authorization expired before reconciliation receipt issuance')
|
|
1226
|
+
const unsigned = { schemaVersion: 1, kind: 'dsh-source-publish-reconciliation-receipt',
|
|
1227
|
+
receiptId: `reconciliation-${sha256Bytes(request.operationId).slice(0, 32)}`, authority: config.authority, keyId: config.keyId,
|
|
1228
|
+
installationId: request.installationId, planId: request.plan.id, planDigest: request.plan.digest, releaseId: request.release.id,
|
|
1229
|
+
fence: request.release.fence, operationId: request.operationId, requestDigest, evidence, evidenceDigest: digest(evidence),
|
|
1230
|
+
observedAt, expiresAt }
|
|
1231
|
+
return { ...unsigned, signature: sign(null, Buffer.from(canonicalReleaseValue(unsigned)), config.privateKey).toString('base64') }
|
|
1232
|
+
}
|
|
1233
|
+
function validateReconciliationRequest(request, config) {
|
|
1234
|
+
const expected = ['schemaVersion', 'kind', 'operationId', 'attempt', 'requestedAt', 'receiptTtlMs', 'installationId', 'ledger', 'plan',
|
|
1235
|
+
'release', 'authorization', 'adapter', 'registry', 'ambiguousPublish', 'artifact', 'expectedRegistryReference',
|
|
1236
|
+
'expectedArtifactStatementDigest', 'expectedArtifactSignatureDigest']
|
|
1237
|
+
exactKeys(object(request, 'reconciliation request'), expected, 'reconciliation request')
|
|
1238
|
+
if (request.schemaVersion !== 1 || request.kind !== 'dsh-source-publish-reconciliation-request' || !ID.test(request.operationId)
|
|
1239
|
+
|| !Number.isSafeInteger(request.attempt) || request.attempt < 1
|
|
1240
|
+
|| !Number.isSafeInteger(request.requestedAt) || !Number.isSafeInteger(request.receiptTtlMs) || request.receiptTtlMs < 1_000
|
|
1241
|
+
|| request.receiptTtlMs > 300_000) fail('reconciliation request envelope is invalid')
|
|
1242
|
+
const authorization = validateAuthorization(request); const adapter = object(request.adapter, 'adapter identity')
|
|
1243
|
+
const ledger = object(request.ledger, 'reconciliation ledger'); exactKeys(ledger, ['id', 'path'], 'reconciliation ledger')
|
|
1244
|
+
const plan = object(request.plan, 'reconciliation plan'); exactKeys(plan, ['id', 'digest', 'revision'], 'reconciliation plan')
|
|
1245
|
+
const release = object(request.release, 'reconciliation release'); exactKeys(release, ['id', 'fence'], 'reconciliation release')
|
|
1246
|
+
const registryInput = object(request.registry, 'reconciliation registry'); exactKeys(registryInput, ['id', 'locator'], 'reconciliation registry')
|
|
1247
|
+
exactKeys(adapter, ['id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'adapter identity')
|
|
1248
|
+
const interpreter = object(adapter.interpreter, 'adapter interpreter')
|
|
1249
|
+
exactKeys(interpreter, ['path', 'sha256'], 'adapter interpreter')
|
|
1250
|
+
if (config.phase !== 'registry-verify' || adapter.id !== config.id || adapter.version !== LOCAL_RELEASE_ADAPTER_VERSION
|
|
1251
|
+
|| adapter.authority !== config.authority || adapter.keyId !== config.keyId || adapter.path !== config.executablePath
|
|
1252
|
+
|| runningAdapterDigest() !== adapter.sha256 || runningInterpreterDigest() !== interpreter.sha256) {
|
|
1253
|
+
fail('reconciliation request is not bound to this verifier')
|
|
1254
|
+
}
|
|
1255
|
+
const registry = registryConfig(config.registry)
|
|
1256
|
+
if (registry.id !== request.registry.id || registry.locator !== request.registry.locator
|
|
1257
|
+
|| request.expectedRegistryReference !== authorization.releasePolicy.registryReference) fail('reconciliation request targets a different registry')
|
|
1258
|
+
const ambiguous = object(request.ambiguousPublish, 'ambiguous publish'); exactKeys(ambiguous, ['operationId', 'receiptId', 'receiptDigest', 'evidenceDigest'], 'ambiguous publish')
|
|
1259
|
+
const artifact = object(request.artifact, 'reconciliation artifact'); exactKeys(artifact, ['packageName', 'packageVersion', 'tarballSha256', 'tarballIntegrity'], 'reconciliation artifact')
|
|
1260
|
+
for (const value of [ambiguous.receiptDigest, ambiguous.evidenceDigest, artifact.tarballSha256]) text(value, 'reconciliation digest', DIGEST)
|
|
1261
|
+
text(request.expectedArtifactStatementDigest, 'expected artifact statement digest', DIGEST)
|
|
1262
|
+
text(request.expectedArtifactSignatureDigest, 'expected artifact signature digest', DIGEST)
|
|
1263
|
+
text(artifact.packageName, 'reconciliation package', PACKAGE); text(artifact.packageVersion, 'reconciliation version', VERSION)
|
|
1264
|
+
text(artifact.tarballIntegrity, 'reconciliation integrity', /^sha512-[A-Za-z0-9+/]+={0,2}$/u)
|
|
1265
|
+
if (artifact.packageName !== authorization.releasePolicy.packageName || artifact.packageVersion !== authorization.releasePolicy.packageVersion) {
|
|
1266
|
+
fail('reconciliation artifact is not owner-authorized')
|
|
1267
|
+
}
|
|
1268
|
+
verifyAuthorizationSignature(authorization, config)
|
|
1269
|
+
return { authorization, registry }
|
|
1270
|
+
}
|
|
1271
|
+
function reconcileRegistry(request, registry) {
|
|
1272
|
+
let registryPath
|
|
1273
|
+
try { registryPath = fileURLToPath(request.expectedRegistryReference) } catch { fail('expected registry reference is not a local file URL') }
|
|
1274
|
+
const expectedPrefix = `${join(registry.root, 'packages')}${sep}`
|
|
1275
|
+
const ambiguous = request.ambiguousPublish; const artifact = request.artifact
|
|
1276
|
+
let outcome = 'absent'; let registryReference = null; let observedTarballSha256 = null; let observedTarballIntegrity = null
|
|
1277
|
+
let observedArtifactStatementDigest = null; let observedArtifactSignatureDigest = null
|
|
1278
|
+
const recordPath = registryPublicationPath(registry, artifact.packageName, artifact.packageVersion)
|
|
1279
|
+
const objectExists = existsSync(registryPath); const recordExists = existsSync(recordPath)
|
|
1280
|
+
const pendingPath = join(registry.root, 'packages', encodeURIComponent(artifact.packageName),
|
|
1281
|
+
`.pending-${artifact.packageVersion}-${sha256Bytes(ambiguous.operationId).slice(0, 32)}`)
|
|
1282
|
+
if (objectExists !== recordExists || (!objectExists && existsSync(pendingPath))) outcome = 'unknown'
|
|
1283
|
+
else try {
|
|
1284
|
+
const path = safeRegularFile(registryPath, 'reconciled registry object')
|
|
1285
|
+
if (!path.startsWith(expectedPrefix)) fail('reconciled registry object is outside the owner registry')
|
|
1286
|
+
const bytes = readBounded(path, 'reconciled registry object'); registryReference = request.expectedRegistryReference
|
|
1287
|
+
observedTarballSha256 = sha256Bytes(bytes); observedTarballIntegrity = sha512Integrity(bytes)
|
|
1288
|
+
if (recordExists) {
|
|
1289
|
+
const publication = readOwnerJson(recordPath, 'reconciled registry publication')
|
|
1290
|
+
observedArtifactStatementDigest = typeof publication.artifactStatementDigest === 'string' ? publication.artifactStatementDigest : null
|
|
1291
|
+
observedArtifactSignatureDigest = typeof publication.artifactSignatureDigest === 'string' ? publication.artifactSignatureDigest : null
|
|
1292
|
+
const recordMatches = publication.schemaVersion === 1 && publication.registryId === registry.id
|
|
1293
|
+
&& publication.packageName === artifact.packageName && publication.packageVersion === artifact.packageVersion
|
|
1294
|
+
&& publication.tarballSha256 === artifact.tarballSha256 && publication.tarballIntegrity === artifact.tarballIntegrity
|
|
1295
|
+
&& publication.registryReference === request.expectedRegistryReference
|
|
1296
|
+
&& observedArtifactStatementDigest === request.expectedArtifactStatementDigest
|
|
1297
|
+
&& observedArtifactSignatureDigest === request.expectedArtifactSignatureDigest
|
|
1298
|
+
outcome = observedTarballSha256 === artifact.tarballSha256 && observedTarballIntegrity === artifact.tarballIntegrity && recordMatches
|
|
1299
|
+
? 'exists-match' : 'digest-conflict'
|
|
1300
|
+
} else outcome = 'absent'
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
if (error?.code !== 'ENOENT') throw error
|
|
1303
|
+
}
|
|
1304
|
+
const detailDigest = digest({ outcome, registryId: registry.id, registryReference, observedTarballSha256, observedTarballIntegrity,
|
|
1305
|
+
observedArtifactStatementDigest, observedArtifactSignatureDigest,
|
|
1306
|
+
ambiguousPublishOperationId: ambiguous.operationId, ambiguousPublishReceiptDigest: ambiguous.receiptDigest })
|
|
1307
|
+
return { kind: 'publish-reconciliation', outcome, registryId: registry.id, registryReference, packageName: artifact.packageName,
|
|
1308
|
+
packageVersion: artifact.packageVersion, expectedTarballSha256: artifact.tarballSha256, expectedTarballIntegrity: artifact.tarballIntegrity,
|
|
1309
|
+
expectedArtifactStatementDigest: request.expectedArtifactStatementDigest, expectedArtifactSignatureDigest: request.expectedArtifactSignatureDigest,
|
|
1310
|
+
observedTarballSha256, observedTarballIntegrity, observedArtifactStatementDigest, observedArtifactSignatureDigest, ambiguousPublishOperationId: ambiguous.operationId,
|
|
1311
|
+
ambiguousPublishReceiptDigest: ambiguous.receiptDigest, detailDigest }
|
|
1312
|
+
}
|
|
1313
|
+
function persistReviewReceipt(receipt, config) {
|
|
1314
|
+
if (receipt.phase !== 'review') return
|
|
1315
|
+
const gitValue = gitConfig(config.git)
|
|
1316
|
+
if (gitValue.reviewStore === undefined) fail('review store is not configured')
|
|
1317
|
+
const path = join(gitValue.reviewStore, `${receipt.evidence.reviewId}.json`)
|
|
1318
|
+
if (existsSync(path)) {
|
|
1319
|
+
if (digest(readOwnerJson(path, 'review receipt')) !== digest(receipt)) fail('review id already contains a different receipt')
|
|
1320
|
+
} else immutableJson(path, receipt, 0o600)
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
export async function runLocalReleaseAdapter(argv = process.argv.slice(2), environment = process.env, hooks = {}) {
|
|
1324
|
+
if (argv.length === 1 && argv[0] === '--version') { process.stdout.write(`${LOCAL_RELEASE_ADAPTER_VERSION}\n`); return }
|
|
1325
|
+
if (argv.length === 1 && argv[0] === '--capabilities') {
|
|
1326
|
+
process.stdout.write('{"schemaVersion":1,"artifactInput":"inherited-fd-v1"}\n'); return
|
|
1327
|
+
}
|
|
1328
|
+
if (argv.length !== 1 || !['release', 'reconcile'].includes(argv[0])) fail('usage: dsh-local-release-adapter <--version|release|reconcile>')
|
|
1329
|
+
const input = readFileSync(0)
|
|
1330
|
+
if (input.length < 2 || input.length > MAX_INPUT_BYTES) fail('release request size is invalid')
|
|
1331
|
+
let request
|
|
1332
|
+
try { request = JSON.parse(input.toString('utf8')) } catch { fail('release request is not valid JSON') }
|
|
1333
|
+
if (argv[0] === 'reconcile') {
|
|
1334
|
+
const config = loadConfig(environment, 'registry-verify'); const { registry } = validateReconciliationRequest(request, config)
|
|
1335
|
+
const context = reconciliationContext(request, config)
|
|
1336
|
+
if (context.cached !== undefined) { process.stdout.write(`${JSON.stringify(context.cached)}\n`); return }
|
|
1337
|
+
try {
|
|
1338
|
+
const evidence = reconcileRegistry(request, registry); const receipt = signedReconciliationReceipt(request, config, context.requestDigest, evidence)
|
|
1339
|
+
immutableJson(context.receiptPath, { requestDigest: context.requestDigest, receipt }, 0o600)
|
|
1340
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`); return
|
|
1341
|
+
} finally { context.releaseLock?.() }
|
|
1342
|
+
}
|
|
1343
|
+
if (!PHASES.has(request?.phase)) fail('release request phase is invalid')
|
|
1344
|
+
const config = loadConfig(environment, request.phase)
|
|
1345
|
+
const { authorization } = validateRequest(request, config)
|
|
1346
|
+
validatePhasePolicy(request, config)
|
|
1347
|
+
const registryVerificationReceipt = validateCatalogRegistryVerificationReceipt(request, config)
|
|
1348
|
+
const context = operationContext(request, config)
|
|
1349
|
+
if (context.cached !== undefined) { process.stdout.write(`${JSON.stringify(context.cached)}\n`); return }
|
|
1350
|
+
try {
|
|
1351
|
+
if (Date.now() > authorization.expiresAt) fail('source release authorization expired before execution')
|
|
1352
|
+
let evidence; let outcome = 'passed'
|
|
1353
|
+
try { evidence = await executePhase(request, config, context, authorization, hooks, registryVerificationReceipt) } catch (error) {
|
|
1354
|
+
if (!(error instanceof PublishAmbiguity) || request.phase !== 'publish') throw error
|
|
1355
|
+
outcome = 'ambiguous'
|
|
1356
|
+
evidence = { kind: 'publish-ambiguity', registryId: request.registry.id, packageName: request.input.artifact.packageName,
|
|
1357
|
+
packageVersion: request.input.artifact.packageVersion, tarballSha256: request.input.artifact.tarballSha256,
|
|
1358
|
+
detailDigest: sha256Bytes(error.message) }
|
|
1359
|
+
}
|
|
1360
|
+
const receipt = signedReceipt(request, config, context.requestDigest, evidence, outcome)
|
|
1361
|
+
persistReviewReceipt(receipt, config)
|
|
1362
|
+
immutableJson(context.receiptPath, { requestDigest: context.requestDigest, receipt }, 0o600)
|
|
1363
|
+
const cached = readOwnerJson(context.receiptPath, 'operation receipt')
|
|
1364
|
+
if (cached.requestDigest !== context.requestDigest || digest(cached.receipt) !== digest(receipt)) fail('operation receipt persistence raced')
|
|
1365
|
+
process.stdout.write(`${JSON.stringify(receipt)}\n`)
|
|
1366
|
+
} finally { context.releaseLock?.() }
|
|
1367
|
+
}
|
|
1368
|
+
export function runPinnedCommandForTest(executable, args, hooks = {}) {
|
|
1369
|
+
return command(executable, args, process.cwd(), {}, undefined, [], hooks).toString('utf8')
|
|
1370
|
+
}
|
|
1371
|
+
export function importPinnedHelperForTest(interpreter, helper, hooks = {}) {
|
|
1372
|
+
return runPinnedNodeModule(interpreter, helper, `const h=await import('file:///proc/self/fd/3');
|
|
1373
|
+
if(typeof h.admitCatalogCandidate!=='function')throw new Error('missing helper');process.stdout.write('ok\\n')`, process.cwd(), undefined, hooks).toString('utf8')
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const invokedDirectly = process.argv[1] !== undefined
|
|
1377
|
+
&& (/^\/proc\/self\/fd\/\d+$/u.test(process.argv[1]) || realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
1378
|
+
if (invokedDirectly) void runLocalReleaseAdapter().catch(error => {
|
|
1379
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'local release adapter failed'}\n`)
|
|
1380
|
+
process.exitCode = 1
|
|
1381
|
+
})
|