@dsh-enhanced/plugin-control-plane 0.1.48 → 0.1.49
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 +183 -15
- package/bin/dsh-live-qualification-authority.js +15 -0
- package/bin/dsh-local-release-adapter.js +328 -8
- package/bin/dsh-systemd-host-attestor.js +359 -9
- package/bin/dsh-systemd-host-authority.js +12 -0
- package/lib/adoption-coordinator.d.ts +3 -1
- package/lib/adoption-coordinator.d.ts.map +1 -1
- package/lib/adoption-coordinator.js +24 -9
- package/lib/adoption-coordinator.js.map +1 -1
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +33 -14
- package/lib/cli.js.map +1 -1
- package/lib/creation-capability-feedback.d.ts +23 -0
- package/lib/creation-capability-feedback.d.ts.map +1 -0
- package/lib/creation-capability-feedback.js +132 -0
- package/lib/creation-capability-feedback.js.map +1 -0
- package/lib/creation-capability-journal.d.ts +66 -0
- package/lib/creation-capability-journal.d.ts.map +1 -0
- package/lib/creation-capability-journal.js +751 -0
- package/lib/creation-capability-journal.js.map +1 -0
- package/lib/creation-capability-runtime.d.ts +63 -0
- package/lib/creation-capability-runtime.d.ts.map +1 -0
- package/lib/creation-capability-runtime.js +564 -0
- package/lib/creation-capability-runtime.js.map +1 -0
- package/lib/creation-capability-source.d.ts +37 -0
- package/lib/creation-capability-source.d.ts.map +1 -0
- package/lib/creation-capability-source.js +328 -0
- package/lib/creation-capability-source.js.map +1 -0
- package/lib/creation-capability-types.d.ts +252 -0
- package/lib/creation-capability-types.d.ts.map +1 -0
- package/lib/creation-capability-types.js +2 -0
- package/lib/creation-capability-types.js.map +1 -0
- package/lib/deployment-readiness.d.ts +16 -0
- package/lib/deployment-readiness.d.ts.map +1 -1
- package/lib/deployment-readiness.js +48 -1
- package/lib/deployment-readiness.js.map +1 -1
- package/lib/effect-blocked-replay.js +2 -2
- package/lib/effect-blocked-replay.js.map +1 -1
- package/lib/foreground-deployment-runtime.d.ts +3 -0
- package/lib/foreground-deployment-runtime.d.ts.map +1 -1
- package/lib/foreground-deployment-runtime.js +55 -13
- package/lib/foreground-deployment-runtime.js.map +1 -1
- package/lib/foreground-deployment.d.ts.map +1 -1
- package/lib/foreground-deployment.js +5 -0
- package/lib/foreground-deployment.js.map +1 -1
- package/lib/host-attestor.d.ts +2 -0
- package/lib/host-attestor.d.ts.map +1 -1
- package/lib/host-attestor.js +8 -1
- package/lib/host-attestor.js.map +1 -1
- package/lib/host-inputs.d.ts +13 -0
- package/lib/host-inputs.d.ts.map +1 -0
- package/lib/host-inputs.js +162 -0
- package/lib/host-inputs.js.map +1 -0
- package/lib/host-maintenance.d.ts +61 -0
- package/lib/host-maintenance.d.ts.map +1 -0
- package/lib/host-maintenance.js +158 -0
- package/lib/host-maintenance.js.map +1 -0
- package/lib/host-readiness.d.ts +5 -0
- package/lib/host-readiness.d.ts.map +1 -0
- package/lib/host-readiness.js +22 -0
- package/lib/host-readiness.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -0
- package/lib/index.js.map +1 -1
- package/lib/live-qualification-authority.d.ts +32 -0
- package/lib/live-qualification-authority.d.ts.map +1 -0
- package/lib/live-qualification-authority.js +280 -0
- package/lib/live-qualification-authority.js.map +1 -0
- package/lib/live-qualification-client.d.ts +4 -0
- package/lib/live-qualification-client.d.ts.map +1 -0
- package/lib/live-qualification-client.js +22 -0
- package/lib/live-qualification-client.js.map +1 -0
- package/lib/live-qualification-runtime.d.ts +78 -0
- package/lib/live-qualification-runtime.d.ts.map +1 -0
- package/lib/live-qualification-runtime.js +367 -0
- package/lib/live-qualification-runtime.js.map +1 -0
- package/lib/live-qualification.d.ts +73 -0
- package/lib/live-qualification.d.ts.map +1 -0
- package/lib/live-qualification.js +164 -0
- package/lib/live-qualification.js.map +1 -0
- package/lib/owner-task-gaps.d.ts +2 -0
- package/lib/owner-task-gaps.d.ts.map +1 -1
- package/lib/owner-task-gaps.js +9 -1
- package/lib/owner-task-gaps.js.map +1 -1
- package/lib/runtime-epoch.d.ts +69 -0
- package/lib/runtime-epoch.d.ts.map +1 -0
- package/lib/runtime-epoch.js +139 -0
- package/lib/runtime-epoch.js.map +1 -0
- package/lib/runtime-observer.d.ts.map +1 -1
- package/lib/runtime-observer.js +42 -6
- package/lib/runtime-observer.js.map +1 -1
- package/lib/service.d.ts +247 -4
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1332 -27
- package/lib/service.js.map +1 -1
- package/lib/source-adoption-authority.d.ts +3 -0
- package/lib/source-adoption-authority.d.ts.map +1 -1
- package/lib/source-adoption-authority.js +13 -3
- package/lib/source-adoption-authority.js.map +1 -1
- package/lib/source-adoption-runner.d.ts +3 -0
- package/lib/source-adoption-runner.d.ts.map +1 -1
- package/lib/source-adoption-runner.js +16 -4
- package/lib/source-adoption-runner.js.map +1 -1
- package/lib/source-baseline.d.ts +37 -0
- package/lib/source-baseline.d.ts.map +1 -0
- package/lib/source-baseline.js +250 -0
- package/lib/source-baseline.js.map +1 -0
- package/lib/source-build.d.ts +17 -1
- package/lib/source-build.d.ts.map +1 -1
- package/lib/source-build.js +501 -7
- package/lib/source-build.js.map +1 -1
- package/lib/source-context.d.ts +18 -0
- package/lib/source-context.d.ts.map +1 -1
- package/lib/source-context.js +85 -11
- package/lib/source-context.js.map +1 -1
- package/lib/source-creation.d.ts +106 -0
- package/lib/source-creation.d.ts.map +1 -0
- package/lib/source-creation.js +664 -0
- package/lib/source-creation.js.map +1 -0
- package/lib/source-job-types.d.ts +19 -0
- package/lib/source-job-types.d.ts.map +1 -1
- package/lib/source-jobs.d.ts +30 -3
- package/lib/source-jobs.d.ts.map +1 -1
- package/lib/source-jobs.js +382 -40
- package/lib/source-jobs.js.map +1 -1
- package/lib/source-maintenance.d.ts +66 -0
- package/lib/source-maintenance.d.ts.map +1 -0
- package/lib/source-maintenance.js +177 -0
- package/lib/source-maintenance.js.map +1 -0
- package/lib/source-revision.d.ts +57 -0
- package/lib/source-revision.d.ts.map +1 -0
- package/lib/source-revision.js +175 -0
- package/lib/source-revision.js.map +1 -0
- package/lib/source-workspace.d.ts +19 -14
- package/lib/source-workspace.d.ts.map +1 -1
- package/lib/source-workspace.js +90 -17
- package/lib/source-workspace.js.map +1 -1
- package/lib/sqlite.d.ts +13 -3
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +362 -1
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +258 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2211 -95
- package/lib/store.js.map +1 -1
- package/lib/systemd-host-authority.d.ts +68 -0
- package/lib/systemd-host-authority.d.ts.map +1 -0
- package/lib/systemd-host-authority.js +424 -0
- package/lib/systemd-host-authority.js.map +1 -0
- package/lib/task-observation-evidence.d.ts +32 -0
- package/lib/task-observation-evidence.d.ts.map +1 -0
- package/lib/task-observation-evidence.js +62 -0
- package/lib/task-observation-evidence.js.map +1 -0
- package/lib/task-observation-runtime.d.ts +2 -3
- package/lib/task-observation-runtime.d.ts.map +1 -1
- package/lib/task-observation-runtime.js +7 -39
- package/lib/task-observation-runtime.js.map +1 -1
- package/lib/task-observation-store.js +1 -1
- package/lib/trust.d.ts.map +1 -1
- package/lib/trust.js +0 -0
- package/lib/trust.js.map +1 -1
- package/lib/types.d.ts +43 -6
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +21 -14
package/lib/source-build.js
CHANGED
|
@@ -4,12 +4,439 @@ import { chmod, lstat, mkdtemp, readFile, realpath, rm, writeFile } from 'node:f
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { ControlPlaneCliError } from './errors.js';
|
|
7
|
-
import {
|
|
7
|
+
import { verifyRevisedPluginWorkspace } from './source-revision.js';
|
|
8
|
+
import { verifyCreatedPluginWorkspace } from './source-creation.js';
|
|
9
|
+
import { checkedSourceSnapshot, runLocalBuffer, runLocalCommand } from './source-workspace.js';
|
|
8
10
|
const imageDigest = /^(?:[a-z0-9][a-z0-9._:/-]*@)?sha256:[a-f0-9]{64}$/u;
|
|
9
11
|
const marker = /^DSH_PREPARED_PACK\t([^\t\n]+)\t([0-9]+)\t([a-f0-9]{64})\t([^\t\n]+)\t([^\t\n]+)$/mu;
|
|
12
|
+
const packFrame = 'DSH_PREPARED_PACK_BYTES_V1\t';
|
|
13
|
+
const maximumPackBytes = 32 * 1024 * 1024;
|
|
14
|
+
const maximumPackBase64Bytes = Math.ceil(maximumPackBytes / 3) * 4;
|
|
10
15
|
const repositorySeccompSha256 = 'b1e4b5b709578785bd2aff4a3a344301997571ad0e8ae5747aec176571ddc342';
|
|
11
16
|
/** Exact in-container command recorded in prepared source evidence. */
|
|
12
|
-
export const PREPARED_SOURCE_BUILD_SCRIPT =
|
|
17
|
+
export const PREPARED_SOURCE_BUILD_SCRIPT = String.raw `set -eu
|
|
18
|
+
checked() {
|
|
19
|
+
phase="$1"; shift
|
|
20
|
+
if "$@" >"/tmp/$phase.log" 2>&1; then return 0; else
|
|
21
|
+
code=$?
|
|
22
|
+
printf 'source build phase failed: %s\n' "$phase" >&2
|
|
23
|
+
tail -c 8192 "/tmp/$phase.log" >&2
|
|
24
|
+
return "$code"
|
|
25
|
+
fi
|
|
26
|
+
}
|
|
27
|
+
umask 077
|
|
28
|
+
mkdir -p /workspace
|
|
29
|
+
archive="$(mktemp /tmp/dsh-source-input.XXXXXX)"
|
|
30
|
+
cat >"$archive"
|
|
31
|
+
archive_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
32
|
+
tar -xf "$archive" -C /workspace --no-same-owner --no-same-permissions
|
|
33
|
+
verify_input() {
|
|
34
|
+
current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
35
|
+
if [ "$current_sha" != "$archive_sha" ]; then
|
|
36
|
+
printf 'source build input archive changed\n' >&2
|
|
37
|
+
return 1
|
|
38
|
+
fi
|
|
39
|
+
NODE_OPTIONS= NODE_PATH= node - "$archive" /workspace "$1" <<'DSH_SOURCE_VERIFY'
|
|
40
|
+
const fs = require('node:fs')
|
|
41
|
+
const path = require('node:path')
|
|
42
|
+
const archive = fs.openSync(process.argv[2], 'r')
|
|
43
|
+
const root = path.resolve(process.argv[3])
|
|
44
|
+
const initializing = process.argv[4] === 'init'
|
|
45
|
+
if (!initializing && process.argv[4] !== 'verify') throw new Error('invalid source verification phase')
|
|
46
|
+
const length = fs.fstatSync(archive).size
|
|
47
|
+
const fail = reason => { console.error('source build input changed: ' + reason); process.exit(1) }
|
|
48
|
+
const block = (position, size) => {
|
|
49
|
+
const out = Buffer.alloc(size)
|
|
50
|
+
if (fs.readSync(archive, out, 0, size, position) !== size) fail('archive truncated')
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
const text = bytes => {
|
|
54
|
+
const end = bytes.indexOf(0)
|
|
55
|
+
const slice = end < 0 ? bytes : bytes.subarray(0, end)
|
|
56
|
+
const result = slice.toString('utf8')
|
|
57
|
+
if (!Buffer.from(result, 'utf8').equals(slice)) fail('archive path is not UTF-8')
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
const octal = bytes => {
|
|
61
|
+
const value = text(bytes).trim()
|
|
62
|
+
if (!/^[0-7]+$/.test(value)) fail('unsupported archive number')
|
|
63
|
+
const parsed = Number.parseInt(value, 8)
|
|
64
|
+
if (!Number.isSafeInteger(parsed)) fail('archive number exceeds safe bound')
|
|
65
|
+
return parsed
|
|
66
|
+
}
|
|
67
|
+
const pax = bytes => {
|
|
68
|
+
const values = {}
|
|
69
|
+
for (let at = 0; at < bytes.length;) {
|
|
70
|
+
const space = bytes.indexOf(32, at)
|
|
71
|
+
if (space < 0) fail('malformed archive extension')
|
|
72
|
+
const size = Number(bytes.subarray(at, space).toString('ascii'))
|
|
73
|
+
if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed archive extension')
|
|
74
|
+
const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
|
|
75
|
+
const equal = value.indexOf('=')
|
|
76
|
+
if (equal < 1) fail('malformed archive extension')
|
|
77
|
+
values[value.slice(0, equal)] = value.slice(equal + 1)
|
|
78
|
+
at += size
|
|
79
|
+
}
|
|
80
|
+
return values
|
|
81
|
+
}
|
|
82
|
+
const archiveKey = raw => {
|
|
83
|
+
const name = raw.startsWith('./') ? raw.slice(2) : raw
|
|
84
|
+
const parts = name.replace(/\/$/, '').split('/')
|
|
85
|
+
if (parts.length === 0 || parts.some(part => part === '' || part === '.' || part === '..' || part.includes('\\') || part.includes('\0'))) fail('unsafe archive path')
|
|
86
|
+
return parts.join('/')
|
|
87
|
+
}
|
|
88
|
+
const checkedPath = raw => {
|
|
89
|
+
const parts = archiveKey(raw).split('/')
|
|
90
|
+
let current = root
|
|
91
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
92
|
+
current = path.join(current, parts[index])
|
|
93
|
+
const stat = fs.lstatSync(current)
|
|
94
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('archive parent replaced by a link')
|
|
95
|
+
}
|
|
96
|
+
const full = path.resolve(root, ...parts)
|
|
97
|
+
if (!full.startsWith(root + path.sep)) fail('archive path escaped workspace')
|
|
98
|
+
return full
|
|
99
|
+
}
|
|
100
|
+
let at = 0
|
|
101
|
+
let extension = {}
|
|
102
|
+
const members = []
|
|
103
|
+
const byName = new Map()
|
|
104
|
+
while (at + 512 <= length) {
|
|
105
|
+
const header = block(at, 512)
|
|
106
|
+
if (header.every(byte => byte === 0)) break
|
|
107
|
+
const size = octal(header.subarray(124, 136))
|
|
108
|
+
const mode = octal(header.subarray(100, 108)) & 0o7777
|
|
109
|
+
const kind = String.fromCharCode(header[156] || 48)
|
|
110
|
+
const end = at + 512 + size
|
|
111
|
+
if (!Number.isSafeInteger(end) || end > length) fail('archive member truncated')
|
|
112
|
+
if (kind === 'g' || kind === 'x') {
|
|
113
|
+
if (size > 1_048_576) fail('archive extension exceeds bound')
|
|
114
|
+
const parsed = pax(block(at + 512, size))
|
|
115
|
+
if (kind === 'x') extension = parsed
|
|
116
|
+
} else {
|
|
117
|
+
const prefix = text(header.subarray(345, 500))
|
|
118
|
+
const headerName = text(header.subarray(0, 100))
|
|
119
|
+
const name = extension.path || (prefix ? prefix + '/' + headerName : headerName)
|
|
120
|
+
const key = archiveKey(name)
|
|
121
|
+
if (byName.has(key)) fail('duplicate archive member: ' + name)
|
|
122
|
+
const member = { name, key, kind, size, mode, offset: at + 512,
|
|
123
|
+
link: extension.linkpath || text(header.subarray(157, 257)) }
|
|
124
|
+
members.push(member)
|
|
125
|
+
byName.set(key, member)
|
|
126
|
+
extension = {}
|
|
127
|
+
}
|
|
128
|
+
at = Math.ceil(end / 512) * 512
|
|
129
|
+
}
|
|
130
|
+
if (members.length === 0) fail('archive had no source files')
|
|
131
|
+
// Only immutable package manifests can authorize pnpm 11's pre-install bin
|
|
132
|
+
// normalization. A bin must already have Git's owner executable bit.
|
|
133
|
+
const bins = new Set()
|
|
134
|
+
for (const member of members) {
|
|
135
|
+
if (member.key !== 'package.json' && !/^(?:plugins|packages)\/[a-z0-9][a-z0-9-]*\/package\.json$/.test(member.key)) continue
|
|
136
|
+
if ((member.kind !== '0' && member.kind !== '7') || member.size > 1_048_576) fail('unsafe source package manifest')
|
|
137
|
+
const bytes = block(member.offset, member.size)
|
|
138
|
+
const content = bytes.toString('utf8')
|
|
139
|
+
if (!Buffer.from(content, 'utf8').equals(bytes)) fail('source package manifest is not UTF-8')
|
|
140
|
+
let manifest
|
|
141
|
+
try { manifest = JSON.parse(content) } catch { fail('invalid source package manifest') }
|
|
142
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || manifest.directories?.bin !== undefined) fail('unsupported source package bin')
|
|
143
|
+
const declared = manifest.bin
|
|
144
|
+
if (declared === undefined) continue
|
|
145
|
+
if (typeof declared !== 'string' && (!declared || typeof declared !== 'object' || Array.isArray(declared))) fail('invalid source package bin')
|
|
146
|
+
const paths = typeof declared === 'string' ? [declared] : Object.values(declared)
|
|
147
|
+
const packageRoot = member.key === 'package.json' ? '' : member.key.slice(0, -'/package.json'.length) + '/'
|
|
148
|
+
for (const raw of paths) {
|
|
149
|
+
if (typeof raw !== 'string' || raw.endsWith('/')) fail('invalid source package bin path')
|
|
150
|
+
const relative = archiveKey(raw)
|
|
151
|
+
const targetKey = packageRoot + relative
|
|
152
|
+
const parts = targetKey.split('/')
|
|
153
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
154
|
+
const parent = byName.get(parts.slice(0, index).join('/'))
|
|
155
|
+
if (parent && parent.kind !== '5') fail('source package bin parent is not a directory: ' + raw)
|
|
156
|
+
}
|
|
157
|
+
const target = byName.get(targetKey)
|
|
158
|
+
if (target === undefined) {
|
|
159
|
+
if (relative.startsWith('lib/')) continue
|
|
160
|
+
fail('source package bin target is missing: ' + raw)
|
|
161
|
+
}
|
|
162
|
+
if ((target.kind !== '0' && target.kind !== '7') || !(target.mode & 0o100)) fail('source package bin target is not an executable regular input: ' + raw)
|
|
163
|
+
bins.add(target.key)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const verifiedBins = new Map()
|
|
167
|
+
for (const member of members) {
|
|
168
|
+
const { name, key, kind, size, mode, link } = member
|
|
169
|
+
const full = checkedPath(name)
|
|
170
|
+
const before = fs.lstatSync(full)
|
|
171
|
+
if (kind === '0' || kind === '7') {
|
|
172
|
+
const expectedMode = !initializing && bins.has(key) ? 0o755 : mode & 0o700
|
|
173
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== size || (before.mode & 0o7777) !== expectedMode) fail('original file replaced: ' + name)
|
|
174
|
+
const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
175
|
+
try {
|
|
176
|
+
const opened = fs.fstatSync(input)
|
|
177
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== size || opened.nlink !== 1 || (opened.mode & 0o7777) !== expectedMode) fail('original file changed while opened: ' + name)
|
|
178
|
+
for (let offset = 0; offset < size; offset += 65_536) {
|
|
179
|
+
const count = Math.min(65_536, size - offset)
|
|
180
|
+
const expected = block(member.offset + offset, count)
|
|
181
|
+
const actual = Buffer.alloc(count)
|
|
182
|
+
if (fs.readSync(input, actual, 0, count, offset) !== count || !actual.equals(expected)) fail('original file content changed: ' + name)
|
|
183
|
+
}
|
|
184
|
+
const after = fs.fstatSync(input)
|
|
185
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || after.nlink !== 1 || (after.mode & 0o7777) !== expectedMode) fail('original file changed while compared: ' + name)
|
|
186
|
+
if (bins.has(key)) verifiedBins.set(key, { dev: after.dev, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs, ctimeMs: after.ctimeMs })
|
|
187
|
+
} finally { fs.closeSync(input) }
|
|
188
|
+
} else if (kind === '5') {
|
|
189
|
+
if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o7777) !== (mode & 0o700)) fail('original directory replaced: ' + name)
|
|
190
|
+
} else if (kind === '2') {
|
|
191
|
+
if (!before.isSymbolicLink() || fs.readlinkSync(full) !== link) fail('original link replaced: ' + name)
|
|
192
|
+
} else fail('unsupported archive member type: ' + name)
|
|
193
|
+
}
|
|
194
|
+
if (initializing) {
|
|
195
|
+
for (const key of bins) {
|
|
196
|
+
const member = byName.get(key)
|
|
197
|
+
const full = checkedPath(member.name)
|
|
198
|
+
const input = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
199
|
+
try {
|
|
200
|
+
const before = fs.fstatSync(input)
|
|
201
|
+
const verified = verifiedBins.get(key)
|
|
202
|
+
if (!verified || !before.isFile() || before.nlink !== 1 || before.dev !== verified.dev || before.ino !== verified.ino
|
|
203
|
+
|| before.size !== verified.size || before.mtimeMs !== verified.mtimeMs || before.ctimeMs !== verified.ctimeMs
|
|
204
|
+
|| (before.mode & 0o7777) !== (member.mode & 0o700)) fail('source package bin changed before normalization: ' + key)
|
|
205
|
+
fs.fchmodSync(input, 0o755)
|
|
206
|
+
if ((fs.fstatSync(input).mode & 0o7777) !== 0o755) fail('source package bin normalization failed: ' + key)
|
|
207
|
+
} finally { fs.closeSync(input) }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
fs.closeSync(archive)
|
|
211
|
+
DSH_SOURCE_VERIFY
|
|
212
|
+
current_sha="$(sha256sum "$archive" | cut -d ' ' -f1)"
|
|
213
|
+
if [ "$current_sha" != "$archive_sha" ]; then
|
|
214
|
+
printf 'source build input archive changed\n' >&2
|
|
215
|
+
return 1
|
|
216
|
+
fi
|
|
217
|
+
}
|
|
218
|
+
cd /workspace
|
|
219
|
+
verify_input init
|
|
220
|
+
if [ "$(printenv DSH_SOURCE_TRUST_LOCKFILE 2>/dev/null || true)" = true ]; then
|
|
221
|
+
test "$(pnpm --version)" = 11.7.0 || { printf 'source creation requires pinned pnpm\n' >&2; exit 1; }
|
|
222
|
+
test -f /opt/dsh-source-baseline/pnpm-lock.yaml || { printf 'source creation baseline lock is absent\n' >&2; exit 1; }
|
|
223
|
+
test "$(sha256sum /opt/dsh-source-baseline/pnpm-lock.yaml | cut -d ' ' -f1)" = "$(printenv DSH_SOURCE_BASE_LOCK_SHA256 2>/dev/null || true)" || { printf 'source creation baseline lock differs\n' >&2; exit 1; }
|
|
224
|
+
checked install pnpm install --offline --frozen-lockfile --ignore-scripts --trust-lockfile
|
|
225
|
+
else
|
|
226
|
+
checked install pnpm install --offline --frozen-lockfile --ignore-scripts
|
|
227
|
+
fi
|
|
228
|
+
checked check /bin/sh -ceu 'umask 022; exec pnpm check'
|
|
229
|
+
verify_input verify
|
|
230
|
+
mkdir -p /workspace/.dsh-pack
|
|
231
|
+
cd "$PLUGIN_ROOT"
|
|
232
|
+
checked pack pnpm pack --pack-destination /workspace/.dsh-pack
|
|
233
|
+
verify_input verify
|
|
234
|
+
set -- /workspace/.dsh-pack/*.tgz
|
|
235
|
+
test "$#" = 1
|
|
236
|
+
pack="$1"
|
|
237
|
+
NODE_OPTIONS= NODE_PATH= node - "$archive" "$pack" "$PLUGIN_ROOT" "$archive_sha" <<'DSH_SOURCE_PACK_VERIFY'
|
|
238
|
+
const fs = require('node:fs')
|
|
239
|
+
const crypto = require('node:crypto')
|
|
240
|
+
const childProcess = require('node:child_process')
|
|
241
|
+
const path = require('node:path')
|
|
242
|
+
const zlib = require('node:zlib')
|
|
243
|
+
const fail = reason => { console.error('source packed artifact changed: ' + reason); process.exit(1) }
|
|
244
|
+
if (fs.statSync(process.argv[2]).size > 64 * 1024 * 1024) fail('source archive exceeds verification bound')
|
|
245
|
+
const source = fs.readFileSync(process.argv[2])
|
|
246
|
+
if (crypto.createHash('sha256').update(source).digest('hex') !== process.argv[5]) fail('immutable source archive changed')
|
|
247
|
+
const packedPath = process.argv[3]
|
|
248
|
+
const pluginRoot = process.argv[4]
|
|
249
|
+
if (!/^plugins\/[a-z0-9][a-z0-9-]*$/.test(pluginRoot)) fail('invalid plugin root')
|
|
250
|
+
const packedStat = fs.lstatSync(packedPath)
|
|
251
|
+
if (!packedStat.isFile() || packedStat.isSymbolicLink() || packedStat.nlink !== 1) fail('packed artifact is not a regular file')
|
|
252
|
+
if (packedStat.size > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
|
|
253
|
+
const packedFd = fs.openSync(packedPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
254
|
+
const opened = fs.fstatSync(packedFd)
|
|
255
|
+
if (opened.dev !== packedStat.dev || opened.ino !== packedStat.ino || opened.size !== packedStat.size) fail('packed artifact changed while opened')
|
|
256
|
+
const packed = fs.readFileSync(packedFd)
|
|
257
|
+
fs.closeSync(packedFd)
|
|
258
|
+
if (packed.length > 32 * 1024 * 1024) fail('compressed artifact exceeds bound')
|
|
259
|
+
let tar
|
|
260
|
+
try { tar = zlib.gunzipSync(packed, { maxOutputLength: 128 * 1024 * 1024 }) }
|
|
261
|
+
catch { fail('invalid or oversized gzip artifact') }
|
|
262
|
+
const string = value => {
|
|
263
|
+
const end = value.indexOf(0)
|
|
264
|
+
const bytes = end < 0 ? value : value.subarray(0, end)
|
|
265
|
+
const result = bytes.toString('utf8')
|
|
266
|
+
if (!Buffer.from(result, 'utf8').equals(bytes)) fail('invalid UTF-8 tar name')
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
const octal = value => {
|
|
270
|
+
const raw = string(value).trim()
|
|
271
|
+
if (!/^[0-7]+$/.test(raw)) fail('invalid tar size')
|
|
272
|
+
const parsed = Number.parseInt(raw, 8)
|
|
273
|
+
if (!Number.isSafeInteger(parsed)) fail('oversized tar member')
|
|
274
|
+
return parsed
|
|
275
|
+
}
|
|
276
|
+
const pax = bytes => {
|
|
277
|
+
const values = {}
|
|
278
|
+
for (let at = 0; at < bytes.length;) {
|
|
279
|
+
const space = bytes.indexOf(32, at)
|
|
280
|
+
if (space < 0) fail('malformed tar extension')
|
|
281
|
+
const size = Number(bytes.subarray(at, space).toString('ascii'))
|
|
282
|
+
if (!Number.isSafeInteger(size) || size <= space - at + 2 || at + size > bytes.length || bytes[at + size - 1] !== 10) fail('malformed tar extension')
|
|
283
|
+
const value = bytes.subarray(space + 1, at + size - 1).toString('utf8')
|
|
284
|
+
const equal = value.indexOf('=')
|
|
285
|
+
if (equal < 1 || Object.hasOwn(values, value.slice(0, equal))) fail('malformed tar extension')
|
|
286
|
+
values[value.slice(0, equal)] = value.slice(equal + 1)
|
|
287
|
+
at += size
|
|
288
|
+
}
|
|
289
|
+
return values
|
|
290
|
+
}
|
|
291
|
+
const entries = (buffer, prefix) => {
|
|
292
|
+
const result = new Map()
|
|
293
|
+
let at = 0; let extension = {}; let global = {}
|
|
294
|
+
while (at + 512 <= buffer.length) {
|
|
295
|
+
const header = buffer.subarray(at, at + 512)
|
|
296
|
+
if (header.every(byte => byte === 0)) break
|
|
297
|
+
const checksum = octal(header.subarray(148, 156))
|
|
298
|
+
let actualChecksum = 0
|
|
299
|
+
for (let index = 0; index < 512; index += 1) actualChecksum += index >= 148 && index < 156 ? 32 : header[index]
|
|
300
|
+
if (checksum !== actualChecksum) fail('invalid tar header checksum')
|
|
301
|
+
const size = octal(header.subarray(124, 136))
|
|
302
|
+
const end = at + 512 + size
|
|
303
|
+
if (!Number.isSafeInteger(end) || end > buffer.length) fail('truncated tar member')
|
|
304
|
+
const kind = String.fromCharCode(header[156] || 48)
|
|
305
|
+
if (kind === 'g' || kind === 'x') {
|
|
306
|
+
if (size > 1_048_576) fail('oversized tar extension')
|
|
307
|
+
const parsed = pax(buffer.subarray(at + 512, end))
|
|
308
|
+
if (kind === 'g') global = { ...global, ...parsed }; else extension = parsed
|
|
309
|
+
} else {
|
|
310
|
+
const attributes = { ...global, ...extension }
|
|
311
|
+
if (Object.keys(attributes).some(key => !['path', 'mtime', 'atime', 'ctime', 'comment', 'uid', 'gid', 'uname', 'gname', 'mode'].includes(key))) fail('unsupported tar extension')
|
|
312
|
+
const headerName = string(header.subarray(0, 100))
|
|
313
|
+
const pathPrefix = string(header.subarray(345, 500))
|
|
314
|
+
const raw = attributes.path || (pathPrefix ? pathPrefix + '/' + headerName : headerName)
|
|
315
|
+
const name = raw.endsWith('/') ? raw.slice(0, -1) : raw
|
|
316
|
+
if (!name || name.startsWith('/') || name.includes('\\') || name.includes('\0') || name.split('/').some(part => !part || part === '.' || part === '..')) fail('unsafe tar member path')
|
|
317
|
+
if (result.has(name)) fail('duplicate tar member: ' + name)
|
|
318
|
+
if (kind !== '0' && kind !== '5' && kind !== '7') fail('unsupported tar member type: ' + name)
|
|
319
|
+
if (prefix === 'package' && name !== 'package' && !name.startsWith('package/')) fail('tar member outside package')
|
|
320
|
+
result.set(name, { kind, bytes: buffer.subarray(at + 512, end) })
|
|
321
|
+
extension = {}
|
|
322
|
+
}
|
|
323
|
+
at = Math.ceil(end / 512) * 512
|
|
324
|
+
}
|
|
325
|
+
if (at + 1024 > buffer.length || !buffer.subarray(at).every(byte => byte === 0)) fail('tar has no valid trailer')
|
|
326
|
+
return result
|
|
327
|
+
}
|
|
328
|
+
const original = entries(source, 'source')
|
|
329
|
+
const artifact = entries(tar, 'package')
|
|
330
|
+
const required = ['package.json', 'README.md', 'LICENSE', 'cordis.patch.yml']
|
|
331
|
+
for (const relative of required) {
|
|
332
|
+
const expected = original.get(pluginRoot + '/' + relative)
|
|
333
|
+
const actual = artifact.get('package/' + relative)
|
|
334
|
+
if (!expected || expected.kind !== '0' || !actual || actual.kind !== '0') fail('missing fixed package file: ' + relative)
|
|
335
|
+
}
|
|
336
|
+
const json = bytes => { try { return JSON.parse(bytes.toString('utf8')) } catch { fail('invalid package manifest') } }
|
|
337
|
+
const originalManifest = json(original.get(pluginRoot + '/package.json').bytes)
|
|
338
|
+
const actualManifest = json(artifact.get('package/package.json').bytes)
|
|
339
|
+
const expectedName = '@dsh-enhanced/' + pluginRoot.slice('plugins/'.length)
|
|
340
|
+
if (originalManifest.name !== expectedName || actualManifest.name !== expectedName
|
|
341
|
+
|| originalManifest.dsh?.bundle?.patch !== './cordis.patch.yml'
|
|
342
|
+
|| actualManifest.dsh?.bundle?.patch !== './cordis.patch.yml') fail('package identity or bundle patch changed')
|
|
343
|
+
const catalogs = new Map()
|
|
344
|
+
const workspaceYaml = original.get('pnpm-workspace.yaml')
|
|
345
|
+
if (!workspaceYaml || workspaceYaml.kind !== '0') fail('immutable workspace catalog is missing')
|
|
346
|
+
let catalogSection = ''
|
|
347
|
+
let catalogName = ''
|
|
348
|
+
for (const line of workspaceYaml.bytes.toString('utf8').split(/\r?\n/)) {
|
|
349
|
+
if (line === 'catalog:') { catalogSection = 'default'; catalogName = ''; continue }
|
|
350
|
+
if (line === 'catalogs:') { catalogSection = 'named'; catalogName = ''; continue }
|
|
351
|
+
if (/^[^\s#][^:]*:/u.test(line)) { catalogSection = ''; catalogName = ''; continue }
|
|
352
|
+
if (catalogSection === 'named') {
|
|
353
|
+
const heading = /^ ([a-z][a-z0-9-]*):\s*$/u.exec(line)
|
|
354
|
+
if (heading) { catalogName = heading[1]; continue }
|
|
355
|
+
}
|
|
356
|
+
const pattern = catalogSection === 'default' ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u
|
|
357
|
+
: catalogSection === 'named' && catalogName ? /^ (?:'([^']+)'|"([^"]+)"|([^\s:]+)):\s+(.+?)\s*$/u : null
|
|
358
|
+
const matched = pattern?.exec(line)
|
|
359
|
+
if (!matched) continue
|
|
360
|
+
const name = matched[1] || matched[2] || matched[3]
|
|
361
|
+
const raw = matched[4]
|
|
362
|
+
const value = /^(['"])(.*)\1$/u.exec(raw)?.[2] ?? raw
|
|
363
|
+
const key = (catalogSection === 'named' ? catalogName : '') + '\0' + name
|
|
364
|
+
if (catalogs.has(key) || !value || value.includes('#')) fail('ambiguous immutable workspace catalog')
|
|
365
|
+
catalogs.set(key, value)
|
|
366
|
+
}
|
|
367
|
+
const workspaceVersions = new Map()
|
|
368
|
+
for (const [name, member] of original) {
|
|
369
|
+
if (!/^(?:plugins|packages)\/[^/]+\/package\.json$/u.test(name) || member.kind !== '0') continue
|
|
370
|
+
const manifest = json(member.bytes)
|
|
371
|
+
if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || workspaceVersions.has(manifest.name)) fail('ambiguous workspace package identity')
|
|
372
|
+
workspaceVersions.set(manifest.name, manifest.version)
|
|
373
|
+
}
|
|
374
|
+
const dependencies = new Set(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'])
|
|
375
|
+
for (const key of dependencies) {
|
|
376
|
+
const originalDependencies = originalManifest[key] || {}
|
|
377
|
+
const packedDependencies = actualManifest[key] || {}
|
|
378
|
+
if (!originalDependencies || typeof originalDependencies !== 'object' || Array.isArray(originalDependencies)
|
|
379
|
+
|| !packedDependencies || typeof packedDependencies !== 'object' || Array.isArray(packedDependencies)
|
|
380
|
+
|| JSON.stringify(Object.keys(originalDependencies).sort()) !== JSON.stringify(Object.keys(packedDependencies).sort())) fail('package dependency names differ')
|
|
381
|
+
for (const [name, value] of Object.entries(originalDependencies)) {
|
|
382
|
+
const packedValue = packedDependencies[name]
|
|
383
|
+
if (typeof value !== 'string' || typeof packedValue !== 'string' || packedValue.length > 256) fail('package dependency value differs')
|
|
384
|
+
let expected = value
|
|
385
|
+
if (value.startsWith('catalog:')) {
|
|
386
|
+
const catalogName = value.slice('catalog:'.length)
|
|
387
|
+
expected = catalogs.get(catalogName + '\0' + name)
|
|
388
|
+
if (typeof expected !== 'string') fail('unresolved immutable workspace catalog')
|
|
389
|
+
} else if (value.startsWith('workspace:')) {
|
|
390
|
+
const selector = value.slice('workspace:'.length)
|
|
391
|
+
const version = workspaceVersions.get(name)
|
|
392
|
+
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(version)
|
|
393
|
+
|| !['*', '^', '~'].includes(selector)) fail('unsupported workspace dependency locator')
|
|
394
|
+
expected = selector === '*' ? version : selector + version
|
|
395
|
+
}
|
|
396
|
+
if (packedValue !== expected) fail('package dependency value differs')
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// pnpm 11.7.0 omits these fields, then appends its filtered scripts map.
|
|
400
|
+
// Derive that exact published order from the immutable archive, not the pack workspace.
|
|
401
|
+
const expectedManifest = {}
|
|
402
|
+
for (const [key, value] of Object.entries(originalManifest)) {
|
|
403
|
+
if (!['scripts', 'packageManager', 'pnpm'].includes(key)) expectedManifest[key] = value
|
|
404
|
+
}
|
|
405
|
+
if (originalManifest.scripts != null) {
|
|
406
|
+
if (typeof originalManifest.scripts !== 'object' || Array.isArray(originalManifest.scripts)) fail('invalid source package scripts')
|
|
407
|
+
const scripts = {}
|
|
408
|
+
const omitted = new Set(['prepublishOnly', 'prepack', 'prepare', 'postpack', 'publish', 'postpublish'])
|
|
409
|
+
for (const [key, value] of Object.entries(originalManifest.scripts)) {
|
|
410
|
+
if (!omitted.has(key)) scripts[key] = value
|
|
411
|
+
}
|
|
412
|
+
expectedManifest.scripts = scripts
|
|
413
|
+
}
|
|
414
|
+
if (!actualManifest || typeof actualManifest !== 'object' || Array.isArray(actualManifest)) fail('invalid package manifest')
|
|
415
|
+
for (const key of dependencies) { delete expectedManifest[key]; delete actualManifest[key] }
|
|
416
|
+
if (JSON.stringify(expectedManifest) !== JSON.stringify(actualManifest)) fail('fixed manifest fields differ')
|
|
417
|
+
const requirePublished = (entry, field) => {
|
|
418
|
+
if (typeof entry === 'string') {
|
|
419
|
+
if (!entry.startsWith('./') || entry.includes('*') || entry.split('/').some(part => part === '..')
|
|
420
|
+
|| !artifact.has('package/' + entry.slice(2)) || artifact.get('package/' + entry.slice(2)).kind !== '0') fail('published entry is missing: ' + field)
|
|
421
|
+
} else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
422
|
+
for (const [key, nested] of Object.entries(entry)) requirePublished(nested, field + '.' + key)
|
|
423
|
+
} else fail('unsupported published entry: ' + field)
|
|
424
|
+
}
|
|
425
|
+
for (const field of ['main', 'types', 'exports', 'imports', 'bin']) {
|
|
426
|
+
if (originalManifest[field] !== undefined) requirePublished(originalManifest[field], field)
|
|
427
|
+
}
|
|
428
|
+
for (const [name, actual] of artifact) {
|
|
429
|
+
if (actual.kind !== '0' && actual.kind !== '7') continue
|
|
430
|
+
const expected = original.get(pluginRoot + name.slice('package'.length))
|
|
431
|
+
if (expected && name !== 'package/package.json' && !actual.bytes.equals(expected.bytes)) fail('fixed package file differs: ' + name)
|
|
432
|
+
if (!expected && !name.startsWith('package/lib/')) fail('unexpected package file: ' + name)
|
|
433
|
+
}
|
|
434
|
+
const pnpmVersion = childProcess.execFileSync('pnpm', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim()
|
|
435
|
+
const digest = crypto.createHash('sha256').update(packed).digest('hex')
|
|
436
|
+
console.log(['DSH_PREPARED_PACK', path.basename(packedPath), packed.length, digest, process.version, pnpmVersion].join('\t'))
|
|
437
|
+
if (process.env.DSH_SOURCE_CAPTURE_PACK === 'true') process.stdout.write('DSH_PREPARED_PACK_BYTES_V1\t' + packed.toString('base64') + '\n')
|
|
438
|
+
DSH_SOURCE_PACK_VERIFY
|
|
439
|
+
`;
|
|
13
440
|
function sourceBuildLimits(config) {
|
|
14
441
|
const profile = config.profile ?? 'standard';
|
|
15
442
|
return { profile, temporaryMiB: config.temporaryMiB ?? (profile === 'repository' ? 2_048 : 32) };
|
|
@@ -106,6 +533,8 @@ async function sourceTree(worktree, baseCommit, scope, environment) {
|
|
|
106
533
|
* value, or Host path crosses the boundary: the sole input is tar on stdin.
|
|
107
534
|
*/
|
|
108
535
|
export async function runDockerPreparedChecks(input) {
|
|
536
|
+
if (input.capturePack !== undefined && input.capturePack !== true)
|
|
537
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source pack capture request is invalid');
|
|
109
538
|
if (input.sourceJob !== undefined && (!/^source-job-[a-f0-9]{64}$/u.test(input.sourceJob.id)
|
|
110
539
|
|| input.sourceJob.containerName !== `dsh-${input.sourceJob.id}`))
|
|
111
540
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'invalid durable container identity');
|
|
@@ -119,6 +548,35 @@ export async function runDockerPreparedChecks(input) {
|
|
|
119
548
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'repository seccomp requires approved Docker runtime 29.4.1/linux/amd64');
|
|
120
549
|
await input.assertCurrent();
|
|
121
550
|
}
|
|
551
|
+
let creationSnapshot;
|
|
552
|
+
let baseLockDigest;
|
|
553
|
+
const verifyCreation = async () => {
|
|
554
|
+
if (input.creation === undefined && input.revision === undefined)
|
|
555
|
+
return;
|
|
556
|
+
if (input.creation !== undefined && input.revision !== undefined)
|
|
557
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source build has conflicting preparation authorities');
|
|
558
|
+
if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
|
|
559
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
560
|
+
const verified = input.revision === undefined ? await verifyCreatedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
|
|
561
|
+
name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
|
|
562
|
+
creation: input.creation }) : await verifyRevisedPluginWorkspace({ worktree: input.worktree, baseCommit: input.baseCommit,
|
|
563
|
+
name: input.name, environment: input.environment, signal: input.signal, assertCurrent: input.assertCurrent,
|
|
564
|
+
revision: input.revision.binding, parentSource: input.revision.parentSource, parentCertificate: input.revision.parentCertificate, files: input.revision.files });
|
|
565
|
+
const expected = [...input.scope].sort();
|
|
566
|
+
const actual = [...verified.scope].sort();
|
|
567
|
+
if (expected.length !== actual.length || expected.some((path, index) => path !== actual[index])) {
|
|
568
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation build scope differs from verified scope');
|
|
569
|
+
}
|
|
570
|
+
await input.assertCurrent();
|
|
571
|
+
if ((input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now())
|
|
572
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
573
|
+
};
|
|
574
|
+
if (input.creation !== undefined || input.revision !== undefined) {
|
|
575
|
+
await verifyCreation();
|
|
576
|
+
creationSnapshot = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
|
|
577
|
+
const baseLock = await runLocalBuffer('git', ['show', `${input.baseCommit}:pnpm-lock.yaml`], input.worktree, input.environment, { maximumOutput: 8_388_608, signal: input.signal });
|
|
578
|
+
baseLockDigest = createHash('sha256').update(baseLock).digest('hex');
|
|
579
|
+
}
|
|
122
580
|
const snapshot = await sourceTree(input.worktree, input.baseCommit, input.scope, input.environment);
|
|
123
581
|
let before;
|
|
124
582
|
let packageVersion;
|
|
@@ -126,6 +584,16 @@ export async function runDockerPreparedChecks(input) {
|
|
|
126
584
|
try {
|
|
127
585
|
seccomp = await snapshotRepositorySeccomp(input.config, snapshot.temporary);
|
|
128
586
|
before = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, snapshot.tree);
|
|
587
|
+
if (creationSnapshot !== undefined) {
|
|
588
|
+
if (before.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || before.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
|
|
589
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation staged tree differs from verified workspace');
|
|
590
|
+
}
|
|
591
|
+
await verifyCreation();
|
|
592
|
+
const current = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment, undefined, input.signal);
|
|
593
|
+
if (current.checkedTreeDigest !== creationSnapshot.checkedTreeDigest || current.checkedPatchDigest !== creationSnapshot.checkedPatchDigest) {
|
|
594
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation changed after verification');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
129
597
|
const packageJson = await runLocalCommand('git', ['show', `${snapshot.tree}:plugins/${input.name}/package.json`], input.worktree, input.environment, { capture: true, maximumOutput: 65_536 });
|
|
130
598
|
const value = JSON.parse(packageJson)?.version;
|
|
131
599
|
if (typeof value !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)) {
|
|
@@ -151,13 +619,19 @@ export async function runDockerPreparedChecks(input) {
|
|
|
151
619
|
throw error;
|
|
152
620
|
}
|
|
153
621
|
}
|
|
622
|
+
if ((input.creation !== undefined || input.revision !== undefined) && (input.creation?.grant ?? input.revision.binding.grant).expiresAt <= Date.now()) {
|
|
623
|
+
await snapshot.cleanup();
|
|
624
|
+
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'source creation grant expired before build');
|
|
625
|
+
}
|
|
154
626
|
const script = PREPARED_SOURCE_BUILD_SCRIPT;
|
|
155
627
|
const args = ['run', '-i', '--pull', 'never', '--name', container, '--label', `dsh.source.tree=${snapshot.tree}`, ...(input.sourceJob === undefined ? [] : ['--label', `dsh.source.job=${input.sourceJob.id}`]), ...(seccomp === undefined ? [] : ['--label', `dsh.source.seccomp.sha256=${seccomp.digest}`]), '--network', 'none', '--read-only', '--cap-drop', 'ALL',
|
|
156
628
|
'--security-opt', 'no-new-privileges', '--user', '65534:65534', '--pids-limit', String(input.config.pidsLimit),
|
|
157
629
|
'--memory', `${input.config.memoryMiB}m`, '--memory-swap', `${input.config.memoryMiB}m`, '--cpus', String(input.config.cpus),
|
|
158
630
|
'--tmpfs', `/workspace:rw,nosuid,nodev,mode=1777,size=${input.config.workspaceMiB}m${limits.profile === 'repository' ? ',exec' : ''}`, '--tmpfs', `/tmp:rw,nosuid,nodev,mode=1777,size=${limits.temporaryMiB}m${limits.profile === 'repository' ? ',exec' : ''}`,
|
|
159
631
|
'--workdir', '/workspace', '--env', `PLUGIN_ROOT=plugins/${input.name}`, '--env', 'HOME=/tmp', '--env', 'PATH=/usr/local/bin:/usr/bin:/bin',
|
|
160
|
-
...(
|
|
632
|
+
...(input.capturePack ? ['--env', 'DSH_SOURCE_CAPTURE_PACK=true'] : []),
|
|
633
|
+
...(baseLockDigest === undefined ? [] : ['--env', 'DSH_SOURCE_TRUST_LOCKFILE=true', '--env', `DSH_SOURCE_BASE_LOCK_SHA256=${baseLockDigest}`]),
|
|
634
|
+
...(limits.profile === 'repository' ? ['--env', 'CI=true', '--env', `VITEST_MAX_WORKERS=${Math.max(1, Math.min(4, Math.floor(input.config.cpus)))}`] : []),
|
|
161
635
|
// Docker's masked proc submounts prevent procfs mounts in a child user
|
|
162
636
|
// namespace. This opt-in removes those system-path masks; an empty /sys
|
|
163
637
|
// compensates for sysfs exposure, but does not restore the /proc masks.
|
|
@@ -171,6 +645,7 @@ export async function runDockerPreparedChecks(input) {
|
|
|
171
645
|
git.stdout.pipe(docker.stdin);
|
|
172
646
|
const dockerDone = new Promise((resolvePromise, reject) => { docker.once('error', reject); docker.once('close', resolvePromise); });
|
|
173
647
|
const gitDone = new Promise((resolvePromise, reject) => { git.once('error', reject); git.once('close', code => code === 0 ? resolvePromise() : reject(new ControlPlaneCliError('EXECUTOR_FAILED', `source archive exited ${code}`))); });
|
|
648
|
+
const stdoutLimit = input.config.outputBytes + (input.capturePack ? packFrame.length + maximumPackBase64Bytes + 1 : 0);
|
|
174
649
|
let stdout = '';
|
|
175
650
|
let stdoutBytes = 0;
|
|
176
651
|
let stderr = '';
|
|
@@ -181,11 +656,11 @@ export async function runDockerPreparedChecks(input) {
|
|
|
181
656
|
stderr = (stderr + chunk.toString('utf8')).slice(-4096);
|
|
182
657
|
return;
|
|
183
658
|
}
|
|
184
|
-
const remaining =
|
|
659
|
+
const remaining = stdoutLimit - stdoutBytes;
|
|
185
660
|
if (remaining > 0)
|
|
186
661
|
stdout += chunk.subarray(0, remaining).toString('utf8');
|
|
187
662
|
stdoutBytes += chunk.length;
|
|
188
|
-
if (stdoutBytes >
|
|
663
|
+
if (stdoutBytes > stdoutLimit) {
|
|
189
664
|
overflow = true;
|
|
190
665
|
stop();
|
|
191
666
|
}
|
|
@@ -209,10 +684,28 @@ export async function runDockerPreparedChecks(input) {
|
|
|
209
684
|
if (code !== 0)
|
|
210
685
|
throw new ControlPlaneCliError('EXECUTOR_FAILED', `isolated source build failed (${code}): ${stderr.trimEnd()}`);
|
|
211
686
|
const match = marker.exec(stdout);
|
|
212
|
-
if (match !== null && stdout.trim() !== match[0])
|
|
213
|
-
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted unexpected evidence output');
|
|
214
687
|
if (match === null)
|
|
215
688
|
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build did not emit its pack evidence marker');
|
|
689
|
+
let packArtifact;
|
|
690
|
+
if (input.capturePack) {
|
|
691
|
+
const prefix = `${match[0]}\n${packFrame}`;
|
|
692
|
+
if (!stdout.startsWith(prefix) || !stdout.endsWith('\n'))
|
|
693
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
|
|
694
|
+
const encoded = stdout.slice(prefix.length, -1);
|
|
695
|
+
const expectedSize = Number(match[2]);
|
|
696
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize < 1 || expectedSize > maximumPackBytes
|
|
697
|
+
|| encoded.length !== Math.ceil(expectedSize / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) {
|
|
698
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted an invalid pack capture frame');
|
|
699
|
+
}
|
|
700
|
+
packArtifact = Buffer.from(encoded, 'base64');
|
|
701
|
+
if (packArtifact.length !== expectedSize || packArtifact.toString('base64') !== encoded
|
|
702
|
+
|| createHash('sha256').update(packArtifact).digest('hex') !== match[3]) {
|
|
703
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build pack capture differs from verified evidence');
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else if (stdout.trim() !== match[0]) {
|
|
707
|
+
throw new ControlPlaneCliError('EXECUTOR_FAILED', 'isolated source build emitted unexpected evidence output');
|
|
708
|
+
}
|
|
216
709
|
const checked = await checkedSourceSnapshot(input.worktree, input.baseCommit, input.scope, input.environment);
|
|
217
710
|
if (checked.checkedTreeDigest !== before.checkedTreeDigest || checked.checkedPatchDigest !== before.checkedPatchDigest) {
|
|
218
711
|
throw new ControlPlaneCliError('SOURCE_BOUNDARY', 'prepared source changed while its isolated build was running');
|
|
@@ -220,6 +713,7 @@ export async function runDockerPreparedChecks(input) {
|
|
|
220
713
|
const logDigest = createHash('sha256').update(stdout).update('\0').update(stderr).digest('hex');
|
|
221
714
|
const checkedAt = Date.now();
|
|
222
715
|
return { treeDigest: checked.checkedTreeDigest, patchDigest: checked.checkedPatchDigest, checkedAt,
|
|
716
|
+
...(packArtifact === undefined ? {} : { packArtifact }),
|
|
223
717
|
evidence: Object.freeze({ schemaVersion: 1, kind: 'dsh-source-prepared-evidence',
|
|
224
718
|
environment: Object.freeze({ npmConfigIgnoreScripts: true, frozenLockfile: true, offline: true, nodeVersion: match[4], pnpmVersion: match[5] }),
|
|
225
719
|
commands: Object.freeze([{ command: 'docker', args: Object.freeze([...args]), exitCode: 0, durationMs: Date.now() - started, logDigest }]),
|