semantifold 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1
- package/build/src/publication.d.ts +154 -0
- package/build/src/publication.d.ts.map +1 -0
- package/build/src/publication.js +1643 -0
- package/build/src/semantic/types.d.ts +312 -0
- package/build/src/semantic/types.d.ts.map +1 -1
- package/build/src/semantic/types.js +86 -1
- package/docs/generated-artifact-publication.md +95 -0
- package/docs/watch-build-pipeline.md +154 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/publication.js +1854 -0
- package/src/semantic/types.js +98 -0
|
@@ -0,0 +1,1854 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createHash, randomUUID} from "node:crypto"
|
|
4
|
+
import {constants} from "node:fs"
|
|
5
|
+
import {lstat, mkdir, open, readFile, readdir, realpath, rename, rm} from "node:fs/promises"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import {findPortableArtifactPathConflict, isSafeArtifactPath} from "./artifact-path.js"
|
|
8
|
+
import {createGeneratedArtifactSet} from "./artifacts.js"
|
|
9
|
+
import {finalizeByteMapping} from "./binary-mapping.js"
|
|
10
|
+
import {SemantifoldDiagnostic} from "./diagnostic.js"
|
|
11
|
+
import {finalizeMapping, toSourceMapV3} from "./mapping.js"
|
|
12
|
+
|
|
13
|
+
const activePointerName = "active-generation.json"
|
|
14
|
+
const generationsName = "generations"
|
|
15
|
+
const journalName = ".semantifold-publication-journal"
|
|
16
|
+
const identityPattern = /^[a-z][a-z0-9-]*$/u
|
|
17
|
+
const generationPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
|
|
18
|
+
const buildRolePattern = /^[a-z][a-z0-9-]*$/u
|
|
19
|
+
const mediaTypePattern = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:;[\u0020-\u007e]+)?$/u
|
|
20
|
+
const targetRoles = new Set(["application", "binary", "text"])
|
|
21
|
+
const publicationBoundaries = new Set([
|
|
22
|
+
"after-stage",
|
|
23
|
+
"before-pointer-replace",
|
|
24
|
+
"after-pointer-replace",
|
|
25
|
+
"before-cleanup",
|
|
26
|
+
"before-journal-remove"
|
|
27
|
+
])
|
|
28
|
+
/** @type {WeakMap<GeneratedArtifactPublisher, {boundary: string, error: Error}>} */
|
|
29
|
+
const injectedPublicationFailures = new WeakMap()
|
|
30
|
+
/** @type {WeakMap<GeneratedArtifactPublisher, (operation: string) => void>} */
|
|
31
|
+
const publicationFilesystemObservers = new WeakMap()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Installs one internal, one-shot deterministic failure used by real-filesystem interruption specs.
|
|
35
|
+
* This helper is intentionally not part of the package root API.
|
|
36
|
+
* @param {GeneratedArtifactPublisher} publisher - Publisher instance.
|
|
37
|
+
* @param {"after-stage" | "before-pointer-replace" | "after-pointer-replace" | "before-cleanup" | "before-journal-remove"} boundary - Exact lifecycle boundary.
|
|
38
|
+
* @param {Error} error - Injected failure.
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
export function injectPublicationFailure(publisher, boundary, error) {
|
|
42
|
+
if (!(publisher instanceof GeneratedArtifactPublisher) || !publicationBoundaries.has(boundary) || !(error instanceof Error)) {
|
|
43
|
+
throw new TypeError("Invalid publication failure injection.")
|
|
44
|
+
}
|
|
45
|
+
injectedPublicationFailures.set(publisher, {boundary, error})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Installs an internal deterministic filesystem-operation observer for durability specs.
|
|
50
|
+
* This helper is intentionally not part of the package root API.
|
|
51
|
+
* @param {GeneratedArtifactPublisher} publisher - Publisher instance.
|
|
52
|
+
* @param {(operation: string) => void} observer - Synchronous operation observer.
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
55
|
+
export function observePublicationFilesystem(publisher, observer) {
|
|
56
|
+
if (!(publisher instanceof GeneratedArtifactPublisher) || typeof observer != "function") {
|
|
57
|
+
throw new TypeError("Invalid publication filesystem observer.")
|
|
58
|
+
}
|
|
59
|
+
publicationFilesystemObservers.set(publisher, observer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Owns transactional publication in one dedicated directory beneath a canonical project root.
|
|
64
|
+
*/
|
|
65
|
+
export class GeneratedArtifactPublisher {
|
|
66
|
+
/** @type {string} */
|
|
67
|
+
#projectId
|
|
68
|
+
/** @type {string} */
|
|
69
|
+
#projectRoot
|
|
70
|
+
/** @type {string} */
|
|
71
|
+
#publicationRoot
|
|
72
|
+
/** @type {readonly string[]} */
|
|
73
|
+
#sourceRoots
|
|
74
|
+
/** @type {Promise<void>} */
|
|
75
|
+
#publicationTail = Promise.resolve()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a project-scoped publisher without touching the filesystem.
|
|
79
|
+
* @param {object} options - Publisher identity and owned paths.
|
|
80
|
+
* @param {string} options.projectId - Stable lowercase project identity.
|
|
81
|
+
* @param {string} options.projectRoot - Canonical absolute project root.
|
|
82
|
+
* @param {string} options.publicationRoot - Canonical absolute publisher-owned directory beneath the project root.
|
|
83
|
+
* @param {readonly string[]} options.sourceRoots - Canonical absolute project source roots kept disjoint from publication.
|
|
84
|
+
*/
|
|
85
|
+
constructor(options) {
|
|
86
|
+
if (!isPlainObject(options)) {
|
|
87
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", "publication", "Publisher options must be a plain object.")
|
|
88
|
+
}
|
|
89
|
+
const {projectId, projectRoot, publicationRoot, sourceRoots} = options
|
|
90
|
+
|
|
91
|
+
if (!identityPattern.test(projectId ?? "")) {
|
|
92
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", "publication", "Project identity must be a stable lowercase ID.")
|
|
93
|
+
}
|
|
94
|
+
if (!isCanonicalAbsolutePath(projectRoot) || !isCanonicalAbsolutePath(publicationRoot) ||
|
|
95
|
+
!isStrictPathDescendant(projectRoot, publicationRoot)) {
|
|
96
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
97
|
+
"Project and publication roots must be canonical absolute paths with publication strictly beneath the project root.")
|
|
98
|
+
}
|
|
99
|
+
if (!isDenseArray(sourceRoots) || sourceRoots.length == 0) {
|
|
100
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId, "Source roots must be a non-empty array of canonical absolute paths.")
|
|
101
|
+
}
|
|
102
|
+
/** @type {string[]} */
|
|
103
|
+
const validatedSourceRoots = []
|
|
104
|
+
|
|
105
|
+
for (let index = 0; index < sourceRoots.length; index += 1) {
|
|
106
|
+
const sourceRoot = sourceRoots[index]
|
|
107
|
+
|
|
108
|
+
if (!isCanonicalAbsolutePath(sourceRoot)) {
|
|
109
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
110
|
+
"Source roots must be a non-empty array of canonical absolute paths.")
|
|
111
|
+
}
|
|
112
|
+
if (!isPathWithin(projectRoot, sourceRoot)) {
|
|
113
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
114
|
+
"Source roots must remain within the canonical project root.")
|
|
115
|
+
}
|
|
116
|
+
if (pathsOverlap(publicationRoot, sourceRoot)) {
|
|
117
|
+
publicationFailure("PUBLICATION_SOURCE_OVERLAP", projectId,
|
|
118
|
+
"Publication root and source roots must be disjoint.")
|
|
119
|
+
}
|
|
120
|
+
validatedSourceRoots.push(sourceRoot)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.#projectId = projectId
|
|
124
|
+
this.#projectRoot = projectRoot
|
|
125
|
+
this.#publicationRoot = publicationRoot
|
|
126
|
+
this.#sourceRoots = Object.freeze(validatedSourceRoots)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stages, validates, and publishes one complete immutable project generation.
|
|
131
|
+
* Calls on one publisher instance are serialized in invocation order.
|
|
132
|
+
* @param {import("./semantic/types.js").PublicationRequest} request - Complete publication request.
|
|
133
|
+
* @returns {Promise<import("./semantic/types.js").PublishedGeneration>} Committed immutable snapshot.
|
|
134
|
+
*/
|
|
135
|
+
publish(request) {
|
|
136
|
+
/** @type {(value: import("./semantic/types.js").PublishedGeneration) => void} */
|
|
137
|
+
let resolveResult
|
|
138
|
+
/** @type {(reason?: unknown) => void} */
|
|
139
|
+
let rejectResult
|
|
140
|
+
const result = new Promise((resolve, reject) => {
|
|
141
|
+
resolveResult = resolve
|
|
142
|
+
rejectResult = reject
|
|
143
|
+
})
|
|
144
|
+
const operation = this.#publicationTail.then(async () => {
|
|
145
|
+
try {
|
|
146
|
+
resolveResult(await this.#publish(request))
|
|
147
|
+
} catch (error) {
|
|
148
|
+
rejectResult(error)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
this.#publicationTail = operation.catch(() => {})
|
|
153
|
+
|
|
154
|
+
return result
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolves the active pointer exactly once and verifies the referenced immutable generation.
|
|
159
|
+
* @returns {Promise<import("./semantic/types.js").PublishedGeneration>} Verified pointer-once snapshot.
|
|
160
|
+
*/
|
|
161
|
+
async resolveActive() {
|
|
162
|
+
await this.#prepareRoot()
|
|
163
|
+
const pointerPath = path.join(this.#publicationRoot, activePointerName)
|
|
164
|
+
const pointer = await readActivePointer(pointerPath, this.#projectId, false)
|
|
165
|
+
|
|
166
|
+
if (!pointer) publicationFailure("ACTIVE_GENERATION_MISSING", this.#projectId, "No active generation has been published.")
|
|
167
|
+
const generation = await verifyPublishedGeneration(this.#publicationRoot, this.#projectId, pointer, false)
|
|
168
|
+
const cleanupPending = await hasCommittedCleanupJournal(this.#publicationRoot, this.#projectId, pointer.generationId)
|
|
169
|
+
|
|
170
|
+
return publishedGeneration(this.#publicationRoot, generation.manifest, cleanupPending)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Performs one serialized publication.
|
|
175
|
+
* @param {import("./semantic/types.js").PublicationRequest} request - Complete request.
|
|
176
|
+
* @returns {Promise<import("./semantic/types.js").PublishedGeneration>} Publication result.
|
|
177
|
+
*/
|
|
178
|
+
async #publish(request) {
|
|
179
|
+
const validated = validatePublicationRequest(request, this.#projectId)
|
|
180
|
+
|
|
181
|
+
await this.#prepareRoot()
|
|
182
|
+
await reconcilePublication(this.#publicationRoot, this.#projectId, this)
|
|
183
|
+
await verifyAtomicReplacement(this.#publicationRoot, this.#projectId)
|
|
184
|
+
await validateExistingPointer(this.#publicationRoot, this.#projectId)
|
|
185
|
+
|
|
186
|
+
const generationPath = path.join(this.#publicationRoot, generationsName, validated.generationId)
|
|
187
|
+
const manifestPath = path.join(generationPath, "manifest.json")
|
|
188
|
+
const tempPointerName = `.active-generation.${validated.generationId}.tmp`
|
|
189
|
+
const tempPointerPath = path.join(this.#publicationRoot, tempPointerName)
|
|
190
|
+
const journalPath = path.join(this.#publicationRoot, journalName, `${validated.generationId}.json`)
|
|
191
|
+
let committed = false
|
|
192
|
+
let journalCreated = false
|
|
193
|
+
let generationCreated = false
|
|
194
|
+
let tempPointerCreated = false
|
|
195
|
+
let failureCode = "PUBLICATION_STAGE_WRITE_FAILED"
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await assertAbsent(generationPath, "PUBLICATION_UNOWNED_CONFLICT", this.#projectId,
|
|
199
|
+
"Generation identity is already owned or ambiguous.")
|
|
200
|
+
await assertAbsent(tempPointerPath, "PUBLICATION_UNOWNED_CONFLICT", this.#projectId,
|
|
201
|
+
"Temporary active pointer path is already owned or ambiguous.")
|
|
202
|
+
await assertAbsent(journalPath, "PUBLICATION_UNOWNED_CONFLICT", this.#projectId,
|
|
203
|
+
"Publication journal identity is already owned or ambiguous.")
|
|
204
|
+
const journal = {
|
|
205
|
+
generationId: validated.generationId,
|
|
206
|
+
projectId: this.#projectId,
|
|
207
|
+
schema: "SemantifoldPublicationJournal",
|
|
208
|
+
tempPointer: tempPointerName,
|
|
209
|
+
version: 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await writeSyncedFile(journalPath, serializeJson(journal), "wx")
|
|
213
|
+
journalCreated = true
|
|
214
|
+
await syncDirectory(path.dirname(journalPath))
|
|
215
|
+
await mkdir(generationPath, {mode: 0o700})
|
|
216
|
+
generationCreated = true
|
|
217
|
+
await syncDirectory(path.dirname(generationPath))
|
|
218
|
+
|
|
219
|
+
/** @type {import("./semantic/types.js").GenerationTargetManifest[]} */
|
|
220
|
+
const targetManifests = []
|
|
221
|
+
|
|
222
|
+
for (const target of validated.targets) {
|
|
223
|
+
targetManifests.push(await stageTarget(generationPath, target, this.#projectId))
|
|
224
|
+
}
|
|
225
|
+
reachPublicationBoundary(this, "after-stage")
|
|
226
|
+
const manifestCandidate = /** @type {import("./semantic/types.js").GenerationManifest} */ ({
|
|
227
|
+
generationId: validated.generationId,
|
|
228
|
+
projectId: this.#projectId,
|
|
229
|
+
schema: /** @type {const} */ ("SemantifoldGenerationManifest"),
|
|
230
|
+
targets: targetManifests,
|
|
231
|
+
version: /** @type {const} */ (1)
|
|
232
|
+
})
|
|
233
|
+
const manifestBytes = serializeJson(manifestCandidate)
|
|
234
|
+
const manifest = validateGenerationManifest(JSON.parse(manifestBytes.toString("utf8")), this.#projectId,
|
|
235
|
+
validated.generationId)
|
|
236
|
+
|
|
237
|
+
await writeSyncedFile(manifestPath, manifestBytes, "wx")
|
|
238
|
+
await syncTreeDirectories(generationPath)
|
|
239
|
+
await verifyManifestFiles(generationPath, manifest, this.#projectId)
|
|
240
|
+
failureCode = "PUBLICATION_POINTER_FAILED"
|
|
241
|
+
|
|
242
|
+
const pointer = {
|
|
243
|
+
generationId: validated.generationId,
|
|
244
|
+
manifestHash: sha256(manifestBytes),
|
|
245
|
+
projectId: this.#projectId,
|
|
246
|
+
schema: "SemantifoldActiveGeneration",
|
|
247
|
+
version: 1
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await writeSyncedFile(tempPointerPath, serializeJson(pointer), "wx", () => {
|
|
251
|
+
tempPointerCreated = true
|
|
252
|
+
})
|
|
253
|
+
reachPublicationBoundary(this, "before-pointer-replace")
|
|
254
|
+
await rename(tempPointerPath, path.join(this.#publicationRoot, activePointerName))
|
|
255
|
+
tempPointerCreated = false
|
|
256
|
+
committed = true
|
|
257
|
+
reachPublicationBoundary(this, "after-pointer-replace")
|
|
258
|
+
await syncDirectory(this.#publicationRoot)
|
|
259
|
+
|
|
260
|
+
let cleanupPending = false
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
reachPublicationBoundary(this, "before-cleanup")
|
|
264
|
+
await removeJournalOwnedState({
|
|
265
|
+
journalPath,
|
|
266
|
+
projectId: this.#projectId,
|
|
267
|
+
publicationRoot: this.#publicationRoot,
|
|
268
|
+
publisher: this
|
|
269
|
+
})
|
|
270
|
+
} catch (_error) {
|
|
271
|
+
cleanupPending = true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return publishedGeneration(this.#publicationRoot, deepFreeze(manifest), cleanupPending)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!committed) {
|
|
277
|
+
try {
|
|
278
|
+
await removeJournalOwnedState({
|
|
279
|
+
...(generationCreated ? {candidatePath: generationPath} : {}),
|
|
280
|
+
...(journalCreated ? {journalPath} : {}),
|
|
281
|
+
projectId: this.#projectId,
|
|
282
|
+
publicationRoot: this.#publicationRoot,
|
|
283
|
+
publisher: this,
|
|
284
|
+
...(tempPointerCreated ? {temporaryPointerPath: tempPointerPath} : {})
|
|
285
|
+
})
|
|
286
|
+
} catch (cleanupError) {
|
|
287
|
+
if (cleanupError instanceof SemantifoldDiagnostic) throw cleanupError
|
|
288
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", this.#projectId,
|
|
289
|
+
"Failed publication left state that requires recovery.", cleanupError)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
293
|
+
|
|
294
|
+
publicationFailure(failureCode, this.#projectId,
|
|
295
|
+
committed
|
|
296
|
+
? "Active generation changed, but durable pointer publication did not complete."
|
|
297
|
+
: "Candidate generation could not be staged.", error)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Creates and validates owned root directories and source-root separation.
|
|
303
|
+
* @returns {Promise<void>} Completion.
|
|
304
|
+
*/
|
|
305
|
+
async #prepareRoot() {
|
|
306
|
+
await ensureDirectoryWithoutSymlinks(this.#projectRoot, false, this.#projectId)
|
|
307
|
+
await ensureDirectoryWithoutSymlinks(this.#publicationRoot, true, this.#projectId)
|
|
308
|
+
let exactProject
|
|
309
|
+
let exactPublication
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
exactProject = await realpath(this.#projectRoot)
|
|
313
|
+
exactPublication = await realpath(this.#publicationRoot)
|
|
314
|
+
} catch (error) {
|
|
315
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", this.#projectId,
|
|
316
|
+
"Publication root could not be resolved safely.", error)
|
|
317
|
+
}
|
|
318
|
+
if (!isStrictPathDescendant(exactProject, exactPublication)) {
|
|
319
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", this.#projectId,
|
|
320
|
+
"Publication root must resolve strictly beneath the project root.")
|
|
321
|
+
}
|
|
322
|
+
for (const sourceRoot of this.#sourceRoots) {
|
|
323
|
+
await ensureDirectoryWithoutSymlinks(sourceRoot, false, this.#projectId)
|
|
324
|
+
let exactSource
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
exactSource = await realpath(sourceRoot)
|
|
328
|
+
} catch (error) {
|
|
329
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", this.#projectId,
|
|
330
|
+
"Source root could not be resolved safely.", error)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!isPathWithin(exactProject, exactSource)) {
|
|
334
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", this.#projectId,
|
|
335
|
+
"Source roots must resolve within the project root.")
|
|
336
|
+
}
|
|
337
|
+
if (pathsOverlap(exactPublication, exactSource)) {
|
|
338
|
+
publicationFailure("PUBLICATION_SOURCE_OVERLAP", this.#projectId,
|
|
339
|
+
"Publication root and source roots resolve to overlapping paths.")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
await ensureOwnedDirectory(path.join(this.#publicationRoot, generationsName), this.#projectId)
|
|
343
|
+
await ensureOwnedDirectory(path.join(this.#publicationRoot, journalName), this.#projectId)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Throws and consumes a configured internal failure at an exact lifecycle boundary.
|
|
349
|
+
* @param {GeneratedArtifactPublisher} publisher - Active publisher.
|
|
350
|
+
* @param {string} boundary - Reached boundary.
|
|
351
|
+
* @returns {void}
|
|
352
|
+
*/
|
|
353
|
+
function reachPublicationBoundary(publisher, boundary) {
|
|
354
|
+
const injection = injectedPublicationFailures.get(publisher)
|
|
355
|
+
|
|
356
|
+
if (injection?.boundary == boundary) {
|
|
357
|
+
injectedPublicationFailures.delete(publisher)
|
|
358
|
+
throw injection.error
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Reports one completed filesystem operation to the internal deterministic seam.
|
|
364
|
+
* @param {GeneratedArtifactPublisher} publisher - Active publisher.
|
|
365
|
+
* @param {string} operation - Completed operation.
|
|
366
|
+
* @returns {void}
|
|
367
|
+
*/
|
|
368
|
+
function observePublicationFilesystemOperation(publisher, operation) {
|
|
369
|
+
publicationFilesystemObservers.get(publisher)?.(operation)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Reconciles only candidates and temporary pointers whose ownership is proven by a strict journal.
|
|
374
|
+
* The active pointer remains the sole commit authority.
|
|
375
|
+
* @param {string} publicationRoot - Publication root.
|
|
376
|
+
* @param {string} projectId - Project identity.
|
|
377
|
+
* @param {GeneratedArtifactPublisher} publisher - Active publisher.
|
|
378
|
+
* @returns {Promise<void>} Completion.
|
|
379
|
+
*/
|
|
380
|
+
async function reconcilePublication(publicationRoot, projectId, publisher) {
|
|
381
|
+
const journals = await readPublicationJournals(publicationRoot, projectId)
|
|
382
|
+
|
|
383
|
+
if (journals.length == 0) return
|
|
384
|
+
const pointer = await readActivePointer(path.join(publicationRoot, activePointerName), projectId, true)
|
|
385
|
+
|
|
386
|
+
if (pointer) await verifyPublishedGeneration(publicationRoot, projectId, pointer, true)
|
|
387
|
+
for (const {journal, journalPath} of journals) {
|
|
388
|
+
const candidatePath = ownedPath(path.join(publicationRoot, generationsName), journal.generationId, projectId)
|
|
389
|
+
const temporaryPointerPath = path.join(publicationRoot, journal.tempPointer)
|
|
390
|
+
const committed = pointer?.generationId == journal.generationId
|
|
391
|
+
|
|
392
|
+
if (!committed) {
|
|
393
|
+
const candidateStatus = await optionalStatus(candidatePath, projectId, "candidate generation")
|
|
394
|
+
|
|
395
|
+
if (candidateStatus) {
|
|
396
|
+
if (!candidateStatus.isDirectory() || candidateStatus.isSymbolicLink()) {
|
|
397
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
398
|
+
"Journal-owned candidate is not a real directory.")
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const temporaryStatus = await optionalStatus(temporaryPointerPath, projectId, "temporary pointer")
|
|
403
|
+
|
|
404
|
+
if (temporaryStatus) {
|
|
405
|
+
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
|
406
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
407
|
+
"Journal-owned temporary pointer is not a regular file.")
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
await removeJournalOwnedState({
|
|
411
|
+
...(!committed ? {candidatePath} : {}),
|
|
412
|
+
journalPath,
|
|
413
|
+
projectId,
|
|
414
|
+
publicationRoot,
|
|
415
|
+
publisher,
|
|
416
|
+
temporaryPointerPath
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Reads and validates every recovery journal without changing publication state.
|
|
423
|
+
* @param {string} publicationRoot - Publication root.
|
|
424
|
+
* @param {string} projectId - Project identity.
|
|
425
|
+
* @returns {Promise<readonly {journal: PublicationJournal, journalPath: string}[]>} Ordered journals.
|
|
426
|
+
*/
|
|
427
|
+
async function readPublicationJournals(publicationRoot, projectId) {
|
|
428
|
+
const journalDirectory = path.join(publicationRoot, journalName)
|
|
429
|
+
let entries
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
entries = await readdir(journalDirectory)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
435
|
+
"Publication journal directory could not be inspected.", error)
|
|
436
|
+
}
|
|
437
|
+
entries.sort(compareStrings)
|
|
438
|
+
/** @type {{journal: PublicationJournal, journalPath: string}[]} */
|
|
439
|
+
const journals = []
|
|
440
|
+
|
|
441
|
+
for (const entry of entries) {
|
|
442
|
+
if (!entry.endsWith(".json") || !generationPattern.test(entry.slice(0, -5))) {
|
|
443
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
444
|
+
"Publication journal contains an unrecognized entry.")
|
|
445
|
+
}
|
|
446
|
+
const journalPath = path.join(journalDirectory, entry)
|
|
447
|
+
let journal
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const status = await lstat(journalPath)
|
|
451
|
+
|
|
452
|
+
if (!status.isFile() || status.isSymbolicLink()) {
|
|
453
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
454
|
+
"Publication journal entry must be a regular file.")
|
|
455
|
+
}
|
|
456
|
+
journal = JSON.parse(await readFile(journalPath, "utf8"))
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
459
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
460
|
+
"Publication journal entry is not valid JSON.", error)
|
|
461
|
+
}
|
|
462
|
+
if (!isPublicationJournal(journal) || journal.projectId != projectId || `${journal.generationId}.json` != entry) {
|
|
463
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
464
|
+
"Publication journal entry does not match the versioned project contract.")
|
|
465
|
+
}
|
|
466
|
+
journals.push({journal, journalPath})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return Object.freeze(journals)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Reports committed cleanup state while failing closed on every journal entry.
|
|
474
|
+
* @param {string} publicationRoot - Publication root.
|
|
475
|
+
* @param {string} projectId - Project identity.
|
|
476
|
+
* @param {string} generationId - Pointer-selected generation identity.
|
|
477
|
+
* @returns {Promise<boolean>} Whether committed cleanup remains.
|
|
478
|
+
*/
|
|
479
|
+
async function hasCommittedCleanupJournal(publicationRoot, projectId, generationId) {
|
|
480
|
+
const journals = await readPublicationJournals(publicationRoot, projectId)
|
|
481
|
+
|
|
482
|
+
return journals.some(({journal}) => journal.generationId == generationId)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Removes proven journal-owned state and durably synchronizes each parent before discarding its ownership proof.
|
|
487
|
+
* @param {object} options - Exact cleanup state.
|
|
488
|
+
* @param {string} [options.candidatePath] - Proven candidate generation path.
|
|
489
|
+
* @param {string} [options.temporaryPointerPath] - Proven temporary pointer path.
|
|
490
|
+
* @param {string} [options.journalPath] - Ownership journal path.
|
|
491
|
+
* @param {string} options.projectId - Project identity.
|
|
492
|
+
* @param {string} options.publicationRoot - Publication root.
|
|
493
|
+
* @param {GeneratedArtifactPublisher} options.publisher - Active publisher.
|
|
494
|
+
* @returns {Promise<void>} Completion.
|
|
495
|
+
*/
|
|
496
|
+
async function removeJournalOwnedState({candidatePath, journalPath, projectId, publicationRoot, publisher, temporaryPointerPath}) {
|
|
497
|
+
try {
|
|
498
|
+
if (candidatePath) {
|
|
499
|
+
await rm(candidatePath, {force: true, recursive: true})
|
|
500
|
+
observePublicationFilesystemOperation(publisher, "remove-candidate")
|
|
501
|
+
await syncDirectory(path.join(publicationRoot, generationsName))
|
|
502
|
+
observePublicationFilesystemOperation(publisher, "sync-candidate-parent")
|
|
503
|
+
}
|
|
504
|
+
if (temporaryPointerPath) {
|
|
505
|
+
await rm(temporaryPointerPath, {force: true})
|
|
506
|
+
observePublicationFilesystemOperation(publisher, "remove-temporary-pointer")
|
|
507
|
+
await syncDirectory(publicationRoot)
|
|
508
|
+
observePublicationFilesystemOperation(publisher, "sync-temporary-pointer-parent")
|
|
509
|
+
}
|
|
510
|
+
if (journalPath) {
|
|
511
|
+
reachPublicationBoundary(publisher, "before-journal-remove")
|
|
512
|
+
await rm(journalPath)
|
|
513
|
+
observePublicationFilesystemOperation(publisher, "remove-journal")
|
|
514
|
+
await syncDirectory(path.dirname(journalPath))
|
|
515
|
+
observePublicationFilesystemOperation(publisher, "sync-journal-parent")
|
|
516
|
+
}
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
519
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
520
|
+
"Journal-owned publication state could not be durably reconciled.", error)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Reads optional metadata without following symbolic links.
|
|
526
|
+
* @param {string} filename - Exact owned path.
|
|
527
|
+
* @param {string} projectId - Project identity.
|
|
528
|
+
* @param {string} subject - Stable subject description.
|
|
529
|
+
* @returns {Promise<import("node:fs").Stats | null>} Status or absence.
|
|
530
|
+
*/
|
|
531
|
+
async function optionalStatus(filename, projectId, subject) {
|
|
532
|
+
try {
|
|
533
|
+
return await lstat(filename)
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (isErrorCode(error, "ENOENT")) return null
|
|
536
|
+
publicationFailure("PUBLICATION_RECOVERY_FAILED", projectId,
|
|
537
|
+
`Journal-owned ${subject} could not be inspected.`, error)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Validates and snapshots one publication request before staging.
|
|
543
|
+
* @param {unknown} request - Request candidate.
|
|
544
|
+
* @param {string} projectId - Project diagnostic identity.
|
|
545
|
+
* @returns {{generationId: string, targets: readonly ValidatedPublicationTarget[]}} Validated request.
|
|
546
|
+
*/
|
|
547
|
+
function validatePublicationRequest(request, projectId) {
|
|
548
|
+
if (!isPlainObject(request)) {
|
|
549
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
550
|
+
"Publication requires a unique generation ID and a non-empty ordered target array.")
|
|
551
|
+
}
|
|
552
|
+
const generationId = request.generationId
|
|
553
|
+
const requestedTargets = request.targets
|
|
554
|
+
|
|
555
|
+
if (typeof generationId != "string" || !generationPattern.test(generationId) ||
|
|
556
|
+
!isDenseArray(requestedTargets) || requestedTargets.length == 0) {
|
|
557
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
558
|
+
"Publication requires a unique generation ID and a non-empty ordered target array.")
|
|
559
|
+
}
|
|
560
|
+
/** @type {ValidatedPublicationTarget[]} */
|
|
561
|
+
const targets = []
|
|
562
|
+
const targetIds = new Set()
|
|
563
|
+
/** @type {string[]} */
|
|
564
|
+
const projections = []
|
|
565
|
+
/** @type {string[]} */
|
|
566
|
+
const completeArtifactPaths = []
|
|
567
|
+
|
|
568
|
+
for (let index = 0; index < requestedTargets.length; index += 1) {
|
|
569
|
+
const target = requestedTargets[index]
|
|
570
|
+
|
|
571
|
+
if (!isPlainObject(target)) {
|
|
572
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
573
|
+
`Target ${index} requires a stable lowercase identity.`)
|
|
574
|
+
}
|
|
575
|
+
const targetId = target.id
|
|
576
|
+
const role = target.role
|
|
577
|
+
const sourceProjection = target.sourceProjection
|
|
578
|
+
const buildProjection = target.buildProjection
|
|
579
|
+
const artifactSetCandidate = target.artifactSet
|
|
580
|
+
const validatorsCandidate = target.validators
|
|
581
|
+
|
|
582
|
+
if (typeof targetId != "string" || !identityPattern.test(targetId)) {
|
|
583
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
584
|
+
`Target ${index} requires a stable lowercase identity.`)
|
|
585
|
+
}
|
|
586
|
+
if (typeof role != "string" || !targetRoles.has(role)) {
|
|
587
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
588
|
+
`Target '${targetId}' requires an explicit language-neutral role.`)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (targetIds.has(targetId)) {
|
|
592
|
+
publicationFailure("PUBLICATION_PATH_COLLISION", projectId, `Duplicate target identity '${targetId}'.`)
|
|
593
|
+
}
|
|
594
|
+
targetIds.add(targetId)
|
|
595
|
+
if (!isSafeArtifactPath(sourceProjection) || !isSafeArtifactPath(buildProjection)) {
|
|
596
|
+
publicationFailure("INVALID_PUBLICATION_PROJECTION", projectId,
|
|
597
|
+
`Target '${targetId}' requires canonical relative source and build projections.`)
|
|
598
|
+
}
|
|
599
|
+
projections.push(sourceProjection, buildProjection)
|
|
600
|
+
let artifactSet
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
artifactSet = createGeneratedArtifactSet(artifactSetCandidate)
|
|
604
|
+
} catch (error) {
|
|
605
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
606
|
+
`Target '${targetId}' does not contain a valid generated artifact set.`, error)
|
|
607
|
+
}
|
|
608
|
+
const validators = validatorsCandidate === undefined ? [] : validatorsCandidate
|
|
609
|
+
|
|
610
|
+
if (!isDenseArray(validators)) {
|
|
611
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
612
|
+
`Target '${targetId}' validators must be a dense function array.`)
|
|
613
|
+
}
|
|
614
|
+
/** @type {import("./semantic/types.js").PublicationValidator[]} */
|
|
615
|
+
const validatedValidators = []
|
|
616
|
+
|
|
617
|
+
for (let validatorIndex = 0; validatorIndex < validators.length; validatorIndex += 1) {
|
|
618
|
+
const validator = validators[validatorIndex]
|
|
619
|
+
|
|
620
|
+
if (typeof validator != "function") {
|
|
621
|
+
publicationFailure("INVALID_PUBLICATION_REQUEST", projectId,
|
|
622
|
+
`Target '${targetId}' validators must be a dense function array.`)
|
|
623
|
+
}
|
|
624
|
+
validatedValidators.push(/** @type {import("./semantic/types.js").PublicationValidator} */ (validator))
|
|
625
|
+
}
|
|
626
|
+
const artifactConflict = findPortableArtifactPathConflict(artifactSet.artifacts.map(({path: artifactPath}) => artifactPath))
|
|
627
|
+
|
|
628
|
+
if (artifactConflict) {
|
|
629
|
+
publicationFailure("PUBLICATION_PATH_COLLISION", projectId,
|
|
630
|
+
`Target '${targetId}' artifact paths have a portable ${artifactConflict.kind} conflict.`)
|
|
631
|
+
}
|
|
632
|
+
for (const artifact of artifactSet.artifacts) {
|
|
633
|
+
completeArtifactPaths.push(`${sourceProjection}/${artifact.path}`)
|
|
634
|
+
}
|
|
635
|
+
targets.push(Object.freeze({
|
|
636
|
+
artifactSet,
|
|
637
|
+
buildProjection,
|
|
638
|
+
id: targetId,
|
|
639
|
+
role: /** @type {import("./semantic/types.js").PublicationTargetRole} */ (role),
|
|
640
|
+
sourceProjection,
|
|
641
|
+
validators: Object.freeze(validatedValidators)
|
|
642
|
+
}))
|
|
643
|
+
}
|
|
644
|
+
const projectionConflict = findPortableArtifactPathConflict(projections)
|
|
645
|
+
|
|
646
|
+
if (projectionConflict) {
|
|
647
|
+
publicationFailure("PUBLICATION_PROJECTION_OVERLAP", projectId,
|
|
648
|
+
"Target source/build projections must be distinct and non-nested under portable comparison.")
|
|
649
|
+
}
|
|
650
|
+
if (findPortableArtifactPathConflict([...projections, "manifest.json"])) {
|
|
651
|
+
publicationFailure("INVALID_PUBLICATION_PROJECTION", projectId,
|
|
652
|
+
"Target source/build projections must not overlap the generation manifest path.")
|
|
653
|
+
}
|
|
654
|
+
const artifactConflict = findPortableArtifactPathConflict(completeArtifactPaths)
|
|
655
|
+
|
|
656
|
+
if (artifactConflict) {
|
|
657
|
+
publicationFailure("PUBLICATION_PATH_COLLISION", projectId,
|
|
658
|
+
"Projected artifact paths collide under portable comparison.")
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {generationId, targets: Object.freeze(targets)}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Stages one validated target and invokes its ordered validators.
|
|
666
|
+
* @param {string} generationPath - Candidate generation path.
|
|
667
|
+
* @param {ValidatedPublicationTarget} target - Validated target.
|
|
668
|
+
* @param {string} projectId - Project diagnostic identity.
|
|
669
|
+
* @returns {Promise<import("./semantic/types.js").GenerationTargetManifest>} Target manifest.
|
|
670
|
+
*/
|
|
671
|
+
async function stageTarget(generationPath, target, projectId) {
|
|
672
|
+
const sourcePath = ownedPath(generationPath, target.sourceProjection, projectId)
|
|
673
|
+
const buildPath = ownedPath(generationPath, target.buildProjection, projectId)
|
|
674
|
+
|
|
675
|
+
await mkdir(sourcePath, {mode: 0o700, recursive: true})
|
|
676
|
+
await mkdir(buildPath, {mode: 0o700, recursive: true})
|
|
677
|
+
/** @type {import("./semantic/types.js").GenerationArtifactManifest[]} */
|
|
678
|
+
const artifacts = []
|
|
679
|
+
|
|
680
|
+
for (const artifact of target.artifactSet.artifacts) {
|
|
681
|
+
const filename = ownedPath(sourcePath, artifact.path, projectId)
|
|
682
|
+
const bytes = artifact.contentKind == "text"
|
|
683
|
+
? Buffer.from(/** @type {string} */ (artifact.content), "utf8")
|
|
684
|
+
: Buffer.from(/** @type {Uint8Array} */ (artifact.content))
|
|
685
|
+
|
|
686
|
+
await mkdir(path.dirname(filename), {mode: 0o700, recursive: true})
|
|
687
|
+
try {
|
|
688
|
+
await writeSyncedFile(filename, bytes, "wx")
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
691
|
+
publicationFailure("PUBLICATION_STAGE_WRITE_FAILED", projectId,
|
|
692
|
+
`Generated artifact '${artifact.path}' could not be staged.`, error)
|
|
693
|
+
}
|
|
694
|
+
artifacts.push(Object.freeze({
|
|
695
|
+
byteLength: bytes.byteLength,
|
|
696
|
+
contentKind: artifact.contentKind,
|
|
697
|
+
hash: Object.freeze({algorithm: /** @type {const} */ ("sha256"), value: sha256(bytes)}),
|
|
698
|
+
mediaType: artifact.mediaType,
|
|
699
|
+
path: artifact.path,
|
|
700
|
+
provenance: artifact.provenance,
|
|
701
|
+
role: artifact.role
|
|
702
|
+
}))
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** @type {import("./semantic/types.js").PublicationBuildArtifactInput[]} */
|
|
706
|
+
const buildDescriptors = []
|
|
707
|
+
const context = Object.freeze({buildPath, sourcePath, targetId: target.id})
|
|
708
|
+
|
|
709
|
+
for (let index = 0; index < target.validators.length; index += 1) {
|
|
710
|
+
let produced
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
produced = await target.validators[index](context)
|
|
714
|
+
} catch (error) {
|
|
715
|
+
publicationFailure("PUBLICATION_VALIDATION_FAILED", projectId,
|
|
716
|
+
`Validator ${index} failed for target '${target.id}'.`, error)
|
|
717
|
+
}
|
|
718
|
+
if (produced === undefined) continue
|
|
719
|
+
if (!isDenseArray(produced)) {
|
|
720
|
+
publicationFailure("PUBLICATION_VALIDATION_FAILED", projectId,
|
|
721
|
+
`Validator ${index} returned invalid build-artifact metadata for target '${target.id}'.`)
|
|
722
|
+
}
|
|
723
|
+
for (const descriptor of produced) {
|
|
724
|
+
buildDescriptors.push(validateBuildArtifactDescriptor(descriptor, target.id, projectId))
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const descriptorConflict = findPortableArtifactPathConflict(buildDescriptors.map(({path: artifactPath}) => artifactPath))
|
|
729
|
+
|
|
730
|
+
if (descriptorConflict) {
|
|
731
|
+
publicationFailure("PUBLICATION_PATH_COLLISION", projectId,
|
|
732
|
+
`Target '${target.id}' build artifacts have a portable ${descriptorConflict.kind} conflict.`)
|
|
733
|
+
}
|
|
734
|
+
const stagedBuildFiles = await listRegularFiles(buildPath, projectId)
|
|
735
|
+
const declared = new Set(buildDescriptors.map(({path: artifactPath}) => artifactPath))
|
|
736
|
+
|
|
737
|
+
if (stagedBuildFiles.length != declared.size || stagedBuildFiles.some(filename => !declared.has(filename))) {
|
|
738
|
+
publicationFailure("PUBLICATION_VALIDATION_FAILED", projectId,
|
|
739
|
+
`Target '${target.id}' validators must declare every staged build artifact exactly once.`)
|
|
740
|
+
}
|
|
741
|
+
/** @type {import("./semantic/types.js").GenerationBuildArtifactManifest[]} */
|
|
742
|
+
const finalizedBuildArtifacts = []
|
|
743
|
+
|
|
744
|
+
for (const descriptor of buildDescriptors) {
|
|
745
|
+
const bytes = await readAndSyncRegularFile(ownedPath(buildPath, descriptor.path, projectId), projectId,
|
|
746
|
+
"PUBLICATION_STAGE_HASH_MISMATCH", `Build artifact '${descriptor.path}' is not a stable regular file.`)
|
|
747
|
+
|
|
748
|
+
finalizedBuildArtifacts.push(Object.freeze({
|
|
749
|
+
byteLength: bytes.byteLength,
|
|
750
|
+
hash: Object.freeze({algorithm: /** @type {const} */ ("sha256"), value: sha256(bytes)}),
|
|
751
|
+
mediaType: descriptor.mediaType,
|
|
752
|
+
path: descriptor.path,
|
|
753
|
+
role: descriptor.role
|
|
754
|
+
}))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return Object.freeze({
|
|
758
|
+
artifacts: Object.freeze(artifacts),
|
|
759
|
+
buildArtifacts: Object.freeze(finalizedBuildArtifacts),
|
|
760
|
+
id: target.id,
|
|
761
|
+
...(target.artifactSet.metadata === undefined ? {} : {metadata: target.artifactSet.metadata}),
|
|
762
|
+
projections: Object.freeze({build: target.buildProjection, source: target.sourceProjection}),
|
|
763
|
+
role: target.role,
|
|
764
|
+
target: target.artifactSet.target
|
|
765
|
+
})
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Validates validator/compiler output metadata.
|
|
770
|
+
* @param {unknown} descriptor - Metadata candidate.
|
|
771
|
+
* @param {string} targetId - Target identity.
|
|
772
|
+
* @param {string} projectId - Project diagnostic identity.
|
|
773
|
+
* @returns {import("./semantic/types.js").PublicationBuildArtifactInput} Validated metadata.
|
|
774
|
+
*/
|
|
775
|
+
function validateBuildArtifactDescriptor(descriptor, targetId, projectId) {
|
|
776
|
+
if (!isPlainObject(descriptor) || !hasExactKeys(descriptor, ["mediaType", "path", "role"])) {
|
|
777
|
+
publicationFailure("PUBLICATION_VALIDATION_FAILED", projectId,
|
|
778
|
+
`Target '${targetId}' validator returned invalid build-artifact metadata.`)
|
|
779
|
+
}
|
|
780
|
+
const descriptorPath = descriptor.path
|
|
781
|
+
const mediaType = descriptor.mediaType
|
|
782
|
+
const role = descriptor.role
|
|
783
|
+
|
|
784
|
+
if (!isSafeArtifactPath(descriptorPath) || typeof mediaType != "string" ||
|
|
785
|
+
!mediaTypePattern.test(mediaType) || /[\r\n]/u.test(mediaType) ||
|
|
786
|
+
typeof role != "string" || !buildRolePattern.test(role)) {
|
|
787
|
+
publicationFailure("PUBLICATION_VALIDATION_FAILED", projectId,
|
|
788
|
+
`Target '${targetId}' validator returned invalid build-artifact metadata.`)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return Object.freeze({mediaType, path: descriptorPath, role})
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Verifies every manifest reference and staged byte before pointer reachability.
|
|
796
|
+
* @param {string} generationPath - Candidate generation path.
|
|
797
|
+
* @param {import("./semantic/types.js").GenerationManifest} manifest - Candidate manifest.
|
|
798
|
+
* @param {string} projectId - Project diagnostic identity.
|
|
799
|
+
* @returns {Promise<void>} Completion.
|
|
800
|
+
*/
|
|
801
|
+
async function verifyManifestFiles(generationPath, manifest, projectId) {
|
|
802
|
+
for (const target of manifest.targets) {
|
|
803
|
+
const sourcePath = ownedPath(generationPath, target.projections.source, projectId)
|
|
804
|
+
const buildPath = ownedPath(generationPath, target.projections.build, projectId)
|
|
805
|
+
const sourceFiles = await listRegularFiles(sourcePath, projectId)
|
|
806
|
+
|
|
807
|
+
if (sourceFiles.length != target.artifacts.length ||
|
|
808
|
+
sourceFiles.some(filename => !target.artifacts.some(artifact => artifact.path == filename))) {
|
|
809
|
+
publicationFailure("PUBLICATION_STAGE_HASH_MISMATCH", projectId,
|
|
810
|
+
`Target '${target.id}' generated-source files do not match its manifest.`)
|
|
811
|
+
}
|
|
812
|
+
for (const artifact of target.artifacts) {
|
|
813
|
+
await verifyManifestFile(sourcePath, artifact, projectId)
|
|
814
|
+
}
|
|
815
|
+
const buildFiles = await listRegularFiles(buildPath, projectId)
|
|
816
|
+
|
|
817
|
+
if (buildFiles.length != target.buildArtifacts.length ||
|
|
818
|
+
buildFiles.some(filename => !target.buildArtifacts.some(artifact => artifact.path == filename))) {
|
|
819
|
+
publicationFailure("PUBLICATION_STAGE_HASH_MISMATCH", projectId,
|
|
820
|
+
`Target '${target.id}' build files do not match its manifest.`)
|
|
821
|
+
}
|
|
822
|
+
for (const artifact of target.buildArtifacts) {
|
|
823
|
+
await verifyManifestFile(buildPath, artifact, projectId)
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
const artifacts = []
|
|
827
|
+
|
|
828
|
+
for (const artifact of target.artifacts) {
|
|
829
|
+
const bytes = await readAndSyncRegularFile(ownedPath(sourcePath, artifact.path, projectId), projectId,
|
|
830
|
+
"PUBLICATION_STAGE_HASH_MISMATCH", `Manifest artifact '${artifact.path}' is not a stable regular file.`)
|
|
831
|
+
let content
|
|
832
|
+
|
|
833
|
+
if (artifact.contentKind == "text") {
|
|
834
|
+
try {
|
|
835
|
+
content = new TextDecoder("utf-8", {fatal: true}).decode(bytes)
|
|
836
|
+
} catch (error) {
|
|
837
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
838
|
+
`Text artifact '${artifact.path}' is not valid UTF-8.`, error)
|
|
839
|
+
}
|
|
840
|
+
} else content = new Uint8Array(bytes)
|
|
841
|
+
artifacts.push({
|
|
842
|
+
content,
|
|
843
|
+
contentKind: artifact.contentKind,
|
|
844
|
+
mediaType: artifact.mediaType,
|
|
845
|
+
ownership: /** @type {const} */ ("generated"),
|
|
846
|
+
path: artifact.path,
|
|
847
|
+
provenance: rehydrateProvenance(artifact.provenance, projectId),
|
|
848
|
+
role: artifact.role
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
createGeneratedArtifactSet({
|
|
852
|
+
artifacts,
|
|
853
|
+
...(target.metadata === undefined ? {} : {metadata: target.metadata}),
|
|
854
|
+
target: target.target
|
|
855
|
+
})
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (error instanceof SemantifoldDiagnostic && error.code == "MALFORMED_GENERATION_MANIFEST") throw error
|
|
858
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
859
|
+
`Target '${target.id}' artifact provenance does not match its staged bytes.`, error)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
await verifyGenerationInventory(generationPath, manifest, projectId)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Restores JSON-omitted optional mapping fields and verifies persisted mapping projections.
|
|
867
|
+
* @param {import("./semantic/types.js").ArtifactProvenance} provenance - Persisted provenance.
|
|
868
|
+
* @param {string} projectId - Project identity.
|
|
869
|
+
* @returns {import("./semantic/types.js").ArtifactProvenance} Canonical provenance.
|
|
870
|
+
*/
|
|
871
|
+
function rehydrateProvenance(provenance, projectId) {
|
|
872
|
+
if (provenance.kind == "text") {
|
|
873
|
+
const mapping = finalizeMapping(provenance.mapping)
|
|
874
|
+
const sourceMap = toSourceMapV3(mapping)
|
|
875
|
+
const jsonSourceMap = JSON.parse(JSON.stringify(sourceMap))
|
|
876
|
+
|
|
877
|
+
if (!sameJsonValue(jsonSourceMap, provenance.sourceMap)) {
|
|
878
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
879
|
+
"Persisted Source Map projection contradicts its rich mapping.")
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return Object.freeze({
|
|
883
|
+
kind: /** @type {const} */ ("text"),
|
|
884
|
+
mapping,
|
|
885
|
+
sourceMap,
|
|
886
|
+
...(provenance.sourceMapFilename === undefined ? {} : {sourceMapFilename: provenance.sourceMapFilename})
|
|
887
|
+
})
|
|
888
|
+
}
|
|
889
|
+
if (provenance.kind == "bytes") {
|
|
890
|
+
return Object.freeze({kind: /** @type {const} */ ("bytes"), mapping: finalizeByteMapping(provenance.mapping)})
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return provenance
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Verifies one referenced file.
|
|
898
|
+
* @param {string} projectionPath - Owning projection path.
|
|
899
|
+
* @param {{byteLength: number, hash: import("./semantic/types.js").GenerationContentHash, path: string}} artifact - Manifest record.
|
|
900
|
+
* @param {string} projectId - Project diagnostic identity.
|
|
901
|
+
* @returns {Promise<Buffer>} Exact verified bytes.
|
|
902
|
+
*/
|
|
903
|
+
async function verifyManifestFile(projectionPath, artifact, projectId) {
|
|
904
|
+
const bytes = await readAndSyncRegularFile(ownedPath(projectionPath, artifact.path, projectId), projectId,
|
|
905
|
+
"PUBLICATION_STAGE_HASH_MISMATCH", `Manifest artifact '${artifact.path}' is not a stable regular file.`)
|
|
906
|
+
|
|
907
|
+
if (bytes.byteLength != artifact.byteLength || sha256(bytes) != artifact.hash.value) {
|
|
908
|
+
publicationFailure("PUBLICATION_STAGE_HASH_MISMATCH", projectId,
|
|
909
|
+
`Manifest artifact '${artifact.path}' bytes do not match its recorded hash.`)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return bytes
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Validates an existing active pointer before a new candidate is staged.
|
|
917
|
+
* @param {string} publicationRoot - Publication root.
|
|
918
|
+
* @param {string} projectId - Project identity.
|
|
919
|
+
* @returns {Promise<void>} Completion.
|
|
920
|
+
*/
|
|
921
|
+
async function validateExistingPointer(publicationRoot, projectId) {
|
|
922
|
+
const pointerPath = path.join(publicationRoot, activePointerName)
|
|
923
|
+
const pointer = await readActivePointer(pointerPath, projectId, true)
|
|
924
|
+
|
|
925
|
+
if (pointer) await verifyPublishedGeneration(publicationRoot, projectId, pointer, false)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Reads and strictly validates one active pointer.
|
|
930
|
+
* @param {string} pointerPath - Pointer filename.
|
|
931
|
+
* @param {string} projectId - Expected project identity.
|
|
932
|
+
* @param {boolean} optional - Whether absence is allowed.
|
|
933
|
+
* @returns {Promise<ActivePointer | null>} Pointer or absent value.
|
|
934
|
+
*/
|
|
935
|
+
async function readActivePointer(pointerPath, projectId, optional) {
|
|
936
|
+
let pointerBytes
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
const status = await lstat(pointerPath)
|
|
940
|
+
|
|
941
|
+
if (!status.isFile() || status.isSymbolicLink()) {
|
|
942
|
+
publicationFailure("MALFORMED_ACTIVE_GENERATION", projectId,
|
|
943
|
+
"Active-generation pointer must be a regular file.")
|
|
944
|
+
}
|
|
945
|
+
pointerBytes = await readFile(pointerPath)
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (optional && isErrorCode(error, "ENOENT")) return null
|
|
948
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
949
|
+
publicationFailure(optional ? "MALFORMED_ACTIVE_GENERATION" : "ACTIVE_GENERATION_MISSING", projectId,
|
|
950
|
+
optional ? "Active-generation pointer could not be read." : "No active generation has been published.", error)
|
|
951
|
+
}
|
|
952
|
+
let value
|
|
953
|
+
|
|
954
|
+
try {
|
|
955
|
+
value = JSON.parse(pointerBytes.toString("utf8"))
|
|
956
|
+
} catch (error) {
|
|
957
|
+
publicationFailure("MALFORMED_ACTIVE_GENERATION", projectId,
|
|
958
|
+
"Active-generation pointer is not valid JSON.", error)
|
|
959
|
+
}
|
|
960
|
+
if (!isActivePointer(value) || value.projectId != projectId) {
|
|
961
|
+
publicationFailure("MALFORMED_ACTIVE_GENERATION", projectId,
|
|
962
|
+
"Active-generation pointer does not match the versioned project contract.")
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return value
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Verifies a pointer-selected generation without consulting the pointer again.
|
|
970
|
+
* @param {string} publicationRoot - Publication root.
|
|
971
|
+
* @param {string} projectId - Project identity.
|
|
972
|
+
* @param {ActivePointer} pointer - Pointer snapshot.
|
|
973
|
+
* @param {boolean} cleanupPending - Recovery state.
|
|
974
|
+
* @returns {Promise<import("./semantic/types.js").PublishedGeneration>} Verified generation.
|
|
975
|
+
*/
|
|
976
|
+
async function verifyPublishedGeneration(publicationRoot, projectId, pointer, cleanupPending) {
|
|
977
|
+
const generationPath = ownedPath(path.join(publicationRoot, generationsName), pointer.generationId, projectId)
|
|
978
|
+
const manifestPath = path.join(generationPath, "manifest.json")
|
|
979
|
+
let manifestBytes
|
|
980
|
+
|
|
981
|
+
try {
|
|
982
|
+
manifestBytes = await readAndSyncRegularFile(manifestPath, projectId, "MALFORMED_GENERATION_MANIFEST",
|
|
983
|
+
"Generation manifest must be a regular file.")
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
986
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
987
|
+
"Generation manifest could not be read.", error)
|
|
988
|
+
}
|
|
989
|
+
if (sha256(manifestBytes) != pointer.manifestHash) {
|
|
990
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
991
|
+
"Generation manifest hash does not match the active pointer.")
|
|
992
|
+
}
|
|
993
|
+
let value
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
value = JSON.parse(manifestBytes.toString("utf8"))
|
|
997
|
+
} catch (error) {
|
|
998
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
999
|
+
"Generation manifest is not valid JSON.", error)
|
|
1000
|
+
}
|
|
1001
|
+
const manifest = validateGenerationManifest(value, projectId, pointer.generationId)
|
|
1002
|
+
|
|
1003
|
+
await verifyManifestFiles(generationPath, manifest, projectId)
|
|
1004
|
+
|
|
1005
|
+
return publishedGeneration(publicationRoot, manifest, cleanupPending)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Strictly validates a persisted generation manifest.
|
|
1010
|
+
* @param {unknown} value - Parsed manifest value.
|
|
1011
|
+
* @param {string} projectId - Expected project identity.
|
|
1012
|
+
* @param {string} generationId - Expected generation identity.
|
|
1013
|
+
* @returns {import("./semantic/types.js").GenerationManifest} Frozen manifest.
|
|
1014
|
+
*/
|
|
1015
|
+
function validateGenerationManifest(value, projectId, generationId) {
|
|
1016
|
+
if (!isPlainObject(value) || !hasExactKeys(value, ["generationId", "projectId", "schema", "targets", "version"]) ||
|
|
1017
|
+
value.schema != "SemantifoldGenerationManifest" || value.version != 1 || value.projectId != projectId ||
|
|
1018
|
+
value.generationId != generationId || !isDenseArray(value.targets) || value.targets.length == 0) {
|
|
1019
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
1020
|
+
"Generation manifest does not match the versioned project contract.")
|
|
1021
|
+
}
|
|
1022
|
+
/** @type {import("./semantic/types.js").GenerationTargetManifest[]} */
|
|
1023
|
+
const targets = []
|
|
1024
|
+
/** @type {string[]} */
|
|
1025
|
+
const projections = []
|
|
1026
|
+
const ids = new Set()
|
|
1027
|
+
|
|
1028
|
+
for (const target of value.targets) {
|
|
1029
|
+
const targetKeys = target && typeof target == "object" && "metadata" in target
|
|
1030
|
+
? ["artifacts", "buildArtifacts", "id", "metadata", "projections", "role", "target"]
|
|
1031
|
+
: ["artifacts", "buildArtifacts", "id", "projections", "role", "target"]
|
|
1032
|
+
|
|
1033
|
+
if (!isPlainObject(target) || !hasExactKeys(target, targetKeys) ||
|
|
1034
|
+
!identityPattern.test(typeof target.id == "string" ? target.id : "") || ids.has(target.id) ||
|
|
1035
|
+
typeof target.role != "string" || !targetRoles.has(target.role) ||
|
|
1036
|
+
typeof target.target != "string" || !identityPattern.test(target.target) ||
|
|
1037
|
+
!isPlainObject(target.projections) || !hasExactKeys(target.projections, ["build", "source"]) ||
|
|
1038
|
+
!isSafeArtifactPath(target.projections.source) || !isSafeArtifactPath(target.projections.build) ||
|
|
1039
|
+
!isDenseArray(target.artifacts) || target.artifacts.length == 0 || !isDenseArray(target.buildArtifacts)) {
|
|
1040
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
1041
|
+
"Generation manifest contains an invalid target record.")
|
|
1042
|
+
}
|
|
1043
|
+
const targetId = /** @type {string} */ (target.id)
|
|
1044
|
+
|
|
1045
|
+
ids.add(targetId)
|
|
1046
|
+
projections.push(target.projections.source, target.projections.build)
|
|
1047
|
+
const artifacts = target.artifacts.map(artifact => validatePersistedArtifact(artifact, true, projectId))
|
|
1048
|
+
const buildArtifacts = target.buildArtifacts.map(artifact => validatePersistedArtifact(artifact, false, projectId))
|
|
1049
|
+
|
|
1050
|
+
if (findPortableArtifactPathConflict(artifacts.map(({path: artifactPath}) => artifactPath)) ||
|
|
1051
|
+
findPortableArtifactPathConflict(buildArtifacts.map(({path: artifactPath}) => artifactPath))) {
|
|
1052
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
1053
|
+
"Generation manifest contains colliding artifact paths.")
|
|
1054
|
+
}
|
|
1055
|
+
targets.push(Object.freeze({
|
|
1056
|
+
artifacts: Object.freeze(/** @type {import("./semantic/types.js").GenerationArtifactManifest[]} */ (artifacts)),
|
|
1057
|
+
buildArtifacts: Object.freeze(/** @type {import("./semantic/types.js").GenerationBuildArtifactManifest[]} */ (buildArtifacts)),
|
|
1058
|
+
id: targetId,
|
|
1059
|
+
...(target.metadata === undefined ? {} : {metadata: deepFreeze(/** @type {Record<string, unknown>} */ (target.metadata))}),
|
|
1060
|
+
projections: Object.freeze({build: target.projections.build, source: target.projections.source}),
|
|
1061
|
+
role: /** @type {import("./semantic/types.js").PublicationTargetRole} */ (target.role),
|
|
1062
|
+
target: target.target
|
|
1063
|
+
}))
|
|
1064
|
+
}
|
|
1065
|
+
if (findPortableArtifactPathConflict(projections) ||
|
|
1066
|
+
findPortableArtifactPathConflict([...projections, "manifest.json"])) {
|
|
1067
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
1068
|
+
"Generation manifest contains overlapping projections.")
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return Object.freeze({
|
|
1072
|
+
generationId,
|
|
1073
|
+
projectId,
|
|
1074
|
+
schema: /** @type {const} */ ("SemantifoldGenerationManifest"),
|
|
1075
|
+
targets: Object.freeze(targets),
|
|
1076
|
+
version: /** @type {const} */ (1)
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Validates one persisted artifact record.
|
|
1082
|
+
* @param {unknown} value - Record candidate.
|
|
1083
|
+
* @param {boolean} generated - Whether this is a generated-source artifact.
|
|
1084
|
+
* @param {string} projectId - Project identity.
|
|
1085
|
+
* @returns {import("./semantic/types.js").GenerationArtifactManifest | import("./semantic/types.js").GenerationBuildArtifactManifest} Frozen record.
|
|
1086
|
+
*/
|
|
1087
|
+
function validatePersistedArtifact(value, generated, projectId) {
|
|
1088
|
+
const keys = generated
|
|
1089
|
+
? ["byteLength", "contentKind", "hash", "mediaType", "path", "provenance", "role"]
|
|
1090
|
+
: ["byteLength", "hash", "mediaType", "path", "role"]
|
|
1091
|
+
|
|
1092
|
+
if (!isPlainObject(value) || !hasExactKeys(value, keys) || !isSafeArtifactPath(value.path) ||
|
|
1093
|
+
typeof value.byteLength != "number" || !Number.isSafeInteger(value.byteLength) || value.byteLength < 0 || typeof value.mediaType != "string" ||
|
|
1094
|
+
!mediaTypePattern.test(value.mediaType) || typeof value.role != "string" ||
|
|
1095
|
+
!(generated ? ["entry", "source", "manifest", "support", "mapping", "resource", "loader"].includes(value.role) :
|
|
1096
|
+
buildRolePattern.test(value.role)) ||
|
|
1097
|
+
(generated && value.contentKind != "text" && value.contentKind != "binary") ||
|
|
1098
|
+
(generated && !isPlainObject(value.provenance)) ||
|
|
1099
|
+
!isPlainObject(value.hash) || !hasExactKeys(value.hash, ["algorithm", "value"]) || value.hash.algorithm != "sha256" ||
|
|
1100
|
+
typeof value.hash.value != "string" || !/^[a-f0-9]{64}$/u.test(value.hash.value)) {
|
|
1101
|
+
publicationFailure("MALFORMED_GENERATION_MANIFEST", projectId,
|
|
1102
|
+
"Generation manifest contains an invalid artifact record.")
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return Object.freeze(/** @type {import("./semantic/types.js").GenerationArtifactManifest | import("./semantic/types.js").GenerationBuildArtifactManifest} */ ({
|
|
1106
|
+
byteLength: value.byteLength,
|
|
1107
|
+
...(generated ? {contentKind: value.contentKind} : {}),
|
|
1108
|
+
hash: Object.freeze({algorithm: /** @type {const} */ ("sha256"), value: value.hash.value}),
|
|
1109
|
+
mediaType: value.mediaType,
|
|
1110
|
+
path: value.path,
|
|
1111
|
+
...(generated ? {provenance: deepFreeze(/** @type {import("./semantic/types.js").ArtifactProvenance} */ (value.provenance))} : {}),
|
|
1112
|
+
role: value.role
|
|
1113
|
+
}))
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Constructs a frozen resolved-generation result.
|
|
1118
|
+
* @param {string} publicationRoot - Publication root.
|
|
1119
|
+
* @param {import("./semantic/types.js").GenerationManifest} manifest - Verified manifest.
|
|
1120
|
+
* @param {boolean} cleanupPending - Whether cleanup remains.
|
|
1121
|
+
* @returns {import("./semantic/types.js").PublishedGeneration} Frozen result.
|
|
1122
|
+
*/
|
|
1123
|
+
function publishedGeneration(publicationRoot, manifest, cleanupPending) {
|
|
1124
|
+
const generationPath = path.join(publicationRoot, generationsName, manifest.generationId)
|
|
1125
|
+
const targets = manifest.targets.map(target => Object.freeze({
|
|
1126
|
+
buildPath: path.join(generationPath, target.projections.build),
|
|
1127
|
+
id: target.id,
|
|
1128
|
+
role: target.role,
|
|
1129
|
+
sourcePath: path.join(generationPath, target.projections.source)
|
|
1130
|
+
}))
|
|
1131
|
+
|
|
1132
|
+
return Object.freeze({
|
|
1133
|
+
cleanupPending,
|
|
1134
|
+
generationId: manifest.generationId,
|
|
1135
|
+
generationPath,
|
|
1136
|
+
manifest,
|
|
1137
|
+
manifestPath: path.join(generationPath, "manifest.json"),
|
|
1138
|
+
targets: Object.freeze(targets)
|
|
1139
|
+
})
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Probes the exact same-directory regular-file replacement primitive used by the active pointer.
|
|
1144
|
+
* @param {string} publicationRoot - Publication root.
|
|
1145
|
+
* @param {string} projectId - Project identity.
|
|
1146
|
+
* @returns {Promise<void>} Completion.
|
|
1147
|
+
*/
|
|
1148
|
+
async function verifyAtomicReplacement(publicationRoot, projectId) {
|
|
1149
|
+
const identity = randomUUID()
|
|
1150
|
+
const source = path.join(publicationRoot, `.semantifold-atomic-${identity}.source`)
|
|
1151
|
+
const destination = path.join(publicationRoot, `.semantifold-atomic-${identity}.destination`)
|
|
1152
|
+
const expected = Buffer.from("candidate\n")
|
|
1153
|
+
let sourceCreated = false
|
|
1154
|
+
let destinationCreated = false
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
await writeSyncedFile(source, Buffer.from("candidate\n"), "wx")
|
|
1158
|
+
sourceCreated = true
|
|
1159
|
+
await writeSyncedFile(destination, Buffer.from("previous\n"), "wx")
|
|
1160
|
+
destinationCreated = true
|
|
1161
|
+
await rename(source, destination)
|
|
1162
|
+
sourceCreated = false
|
|
1163
|
+
await syncDirectory(publicationRoot)
|
|
1164
|
+
const destinationStatus = await lstat(destination)
|
|
1165
|
+
const replaced = await readFile(destination)
|
|
1166
|
+
|
|
1167
|
+
if (!destinationStatus.isFile() || destinationStatus.isSymbolicLink() || !replaced.equals(expected)) {
|
|
1168
|
+
publicationFailure("PUBLICATION_ATOMIC_REPLACE_UNSUPPORTED", projectId,
|
|
1169
|
+
"Filesystem did not provide same-directory regular-file replacement.")
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
await lstat(source)
|
|
1173
|
+
publicationFailure("PUBLICATION_ATOMIC_REPLACE_UNSUPPORTED", projectId,
|
|
1174
|
+
"Filesystem did not remove the replaced temporary name atomically.")
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (!isErrorCode(error, "ENOENT")) throw error
|
|
1177
|
+
}
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1180
|
+
publicationFailure("PUBLICATION_ATOMIC_REPLACE_UNSUPPORTED", projectId,
|
|
1181
|
+
"Filesystem did not provide same-directory regular-file replacement.", error)
|
|
1182
|
+
} finally {
|
|
1183
|
+
await Promise.allSettled([
|
|
1184
|
+
...(sourceCreated ? [rm(source, {force: true})] : []),
|
|
1185
|
+
...(destinationCreated ? [rm(destination, {force: true})] : [])
|
|
1186
|
+
])
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Writes and synchronizes one newly owned file.
|
|
1192
|
+
* @param {string} filename - Exact filename.
|
|
1193
|
+
* @param {Uint8Array} bytes - Exact bytes.
|
|
1194
|
+
* @param {"wx"} flag - Exclusive creation mode.
|
|
1195
|
+
* @param {() => void} [onCreated] - Called immediately after exclusive creation succeeds.
|
|
1196
|
+
* @returns {Promise<void>} Completion.
|
|
1197
|
+
*/
|
|
1198
|
+
async function writeSyncedFile(filename, bytes, flag, onCreated) {
|
|
1199
|
+
const handle = await open(filename, flag, 0o600)
|
|
1200
|
+
|
|
1201
|
+
onCreated?.()
|
|
1202
|
+
/** @type {unknown} */
|
|
1203
|
+
let failure
|
|
1204
|
+
|
|
1205
|
+
try {
|
|
1206
|
+
await handle.writeFile(bytes)
|
|
1207
|
+
await handle.sync()
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
failure = error
|
|
1210
|
+
} finally {
|
|
1211
|
+
try {
|
|
1212
|
+
await handle.close()
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
if (failure === undefined) failure = error
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (failure !== undefined) {
|
|
1218
|
+
await rm(filename, {force: true}).catch(() => {})
|
|
1219
|
+
throw failure
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Reads and synchronizes one regular non-symlink file.
|
|
1225
|
+
* @param {string} filename - Exact filename.
|
|
1226
|
+
* @param {string} projectId - Project identity.
|
|
1227
|
+
* @param {string} code - Failure code.
|
|
1228
|
+
* @param {string} message - Stable failure detail.
|
|
1229
|
+
* @returns {Promise<Buffer>} Exact bytes.
|
|
1230
|
+
*/
|
|
1231
|
+
async function readAndSyncRegularFile(filename, projectId, code, message) {
|
|
1232
|
+
try {
|
|
1233
|
+
const status = await lstat(filename)
|
|
1234
|
+
|
|
1235
|
+
if (!status.isFile() || status.isSymbolicLink()) publicationFailure(code, projectId, message)
|
|
1236
|
+
assertRegularFileImmutability(status, new Set(), projectId)
|
|
1237
|
+
const handle = await open(filename, constants.O_RDONLY)
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
const bytes = await handle.readFile()
|
|
1241
|
+
|
|
1242
|
+
await handle.sync()
|
|
1243
|
+
|
|
1244
|
+
return bytes
|
|
1245
|
+
} finally {
|
|
1246
|
+
await handle.close()
|
|
1247
|
+
}
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1250
|
+
publicationFailure(code, projectId, message, error)
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Lists every regular file beneath one projection and rejects symlinks or special nodes.
|
|
1256
|
+
* @param {string} directory - Projection root.
|
|
1257
|
+
* @param {string} projectId - Project identity.
|
|
1258
|
+
* @returns {Promise<string[]>} Sorted relative POSIX paths.
|
|
1259
|
+
*/
|
|
1260
|
+
async function listRegularFiles(directory, projectId) {
|
|
1261
|
+
/** @type {string[]} */
|
|
1262
|
+
const files = []
|
|
1263
|
+
const identities = new Set()
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
await visit(directory, "")
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1269
|
+
publicationFailure("PUBLICATION_GENERATION_VERIFICATION_FAILED", projectId,
|
|
1270
|
+
"Immutable generation filesystem inventory could not be verified.", error)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return files.sort(compareStrings)
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Visits one owned directory.
|
|
1277
|
+
* @param {string} current - Exact directory.
|
|
1278
|
+
* @param {string} relative - Projection-relative path.
|
|
1279
|
+
* @returns {Promise<void>} Completion.
|
|
1280
|
+
*/
|
|
1281
|
+
async function visit(current, relative) {
|
|
1282
|
+
await assertRealInventoryDirectory(current, projectId)
|
|
1283
|
+
const entries = await readdir(current)
|
|
1284
|
+
|
|
1285
|
+
entries.sort(compareStrings)
|
|
1286
|
+
for (const entry of entries) {
|
|
1287
|
+
const childRelative = relative.length == 0 ? entry : `${relative}/${entry}`
|
|
1288
|
+
|
|
1289
|
+
if (!isSafeArtifactPath(childRelative)) {
|
|
1290
|
+
publicationFailure("INVALID_PUBLICATION_PROJECTION", projectId,
|
|
1291
|
+
"Validator output contains a non-canonical relative path.")
|
|
1292
|
+
}
|
|
1293
|
+
const child = path.join(current, entry)
|
|
1294
|
+
const status = await lstat(child)
|
|
1295
|
+
|
|
1296
|
+
if (status.isSymbolicLink()) {
|
|
1297
|
+
publicationFailure("PUBLICATION_SYMLINK_TRAVERSAL", projectId,
|
|
1298
|
+
"Candidate generation contains a symbolic link.")
|
|
1299
|
+
}
|
|
1300
|
+
if (status.isDirectory()) await visit(child, childRelative)
|
|
1301
|
+
else if (status.isFile()) {
|
|
1302
|
+
assertRegularFileImmutability(status, identities, projectId)
|
|
1303
|
+
files.push(childRelative)
|
|
1304
|
+
} else publicationFailure("PUBLICATION_GENERATION_INVENTORY_MISMATCH", projectId,
|
|
1305
|
+
"Immutable generation contains an undeclared filesystem entry.")
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Verifies that the manifest describes every directory and regular file in the immutable generation.
|
|
1312
|
+
* @param {string} generationPath - Exact generation root.
|
|
1313
|
+
* @param {import("./semantic/types.js").GenerationManifest} manifest - Verified manifest.
|
|
1314
|
+
* @param {string} projectId - Project identity.
|
|
1315
|
+
* @returns {Promise<void>} Completion.
|
|
1316
|
+
*/
|
|
1317
|
+
async function verifyGenerationInventory(generationPath, manifest, projectId) {
|
|
1318
|
+
const expectedFiles = new Set(["manifest.json"])
|
|
1319
|
+
const expectedDirectories = new Set()
|
|
1320
|
+
|
|
1321
|
+
for (const target of manifest.targets) {
|
|
1322
|
+
addExpectedDirectory(target.projections.source)
|
|
1323
|
+
addExpectedDirectory(target.projections.build)
|
|
1324
|
+
for (const artifact of target.artifacts) {
|
|
1325
|
+
addExpectedFile(`${target.projections.source}/${artifact.path}`)
|
|
1326
|
+
}
|
|
1327
|
+
for (const artifact of target.buildArtifacts) {
|
|
1328
|
+
addExpectedFile(`${target.projections.build}/${artifact.path}`)
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
/** @type {Set<string>} */
|
|
1332
|
+
const observedFiles = new Set()
|
|
1333
|
+
/** @type {Set<string>} */
|
|
1334
|
+
const observedDirectories = new Set()
|
|
1335
|
+
const identities = new Set()
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
await visit(generationPath, "")
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1341
|
+
publicationFailure("PUBLICATION_GENERATION_VERIFICATION_FAILED", projectId,
|
|
1342
|
+
"Immutable generation filesystem inventory could not be verified.", error)
|
|
1343
|
+
}
|
|
1344
|
+
if (!sameStringSet(expectedFiles, observedFiles) || !sameStringSet(expectedDirectories, observedDirectories)) {
|
|
1345
|
+
publicationFailure("PUBLICATION_GENERATION_INVENTORY_MISMATCH", projectId,
|
|
1346
|
+
"Immutable generation contains an undeclared filesystem entry.")
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Adds one expected directory and each generation-relative parent.
|
|
1351
|
+
* @param {string} relative - POSIX-style generation-relative directory.
|
|
1352
|
+
* @returns {void}
|
|
1353
|
+
*/
|
|
1354
|
+
function addExpectedDirectory(relative) {
|
|
1355
|
+
const parts = relative.split("/")
|
|
1356
|
+
|
|
1357
|
+
for (let index = 1; index <= parts.length; index += 1) {
|
|
1358
|
+
expectedDirectories.add(parts.slice(0, index).join("/"))
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Adds one expected regular file and its parent directories.
|
|
1364
|
+
* @param {string} relative - POSIX-style generation-relative filename.
|
|
1365
|
+
* @returns {void}
|
|
1366
|
+
*/
|
|
1367
|
+
function addExpectedFile(relative) {
|
|
1368
|
+
expectedFiles.add(relative)
|
|
1369
|
+
const separator = relative.lastIndexOf("/")
|
|
1370
|
+
|
|
1371
|
+
if (separator >= 0) addExpectedDirectory(relative.slice(0, separator))
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Visits every entry in one generation directory without following links.
|
|
1376
|
+
* @param {string} current - Exact directory.
|
|
1377
|
+
* @param {string} relative - Generation-relative directory.
|
|
1378
|
+
* @returns {Promise<void>} Completion.
|
|
1379
|
+
*/
|
|
1380
|
+
async function visit(current, relative) {
|
|
1381
|
+
await assertRealInventoryDirectory(current, projectId)
|
|
1382
|
+
const entries = await readdir(current)
|
|
1383
|
+
|
|
1384
|
+
entries.sort(compareStrings)
|
|
1385
|
+
for (const entry of entries) {
|
|
1386
|
+
const childRelative = relative.length == 0 ? entry : `${relative}/${entry}`
|
|
1387
|
+
|
|
1388
|
+
if (!isSafeArtifactPath(childRelative)) {
|
|
1389
|
+
publicationFailure("PUBLICATION_GENERATION_INVENTORY_MISMATCH", projectId,
|
|
1390
|
+
"Immutable generation contains an undeclared filesystem entry.")
|
|
1391
|
+
}
|
|
1392
|
+
const child = path.join(current, entry)
|
|
1393
|
+
const status = await lstat(child)
|
|
1394
|
+
|
|
1395
|
+
if (status.isSymbolicLink()) {
|
|
1396
|
+
publicationFailure("PUBLICATION_SYMLINK_TRAVERSAL", projectId,
|
|
1397
|
+
"Candidate generation contains a symbolic link.")
|
|
1398
|
+
}
|
|
1399
|
+
if (status.isDirectory()) {
|
|
1400
|
+
observedDirectories.add(childRelative)
|
|
1401
|
+
await visit(child, childRelative)
|
|
1402
|
+
} else if (status.isFile()) {
|
|
1403
|
+
assertRegularFileImmutability(status, identities, projectId)
|
|
1404
|
+
observedFiles.add(childRelative)
|
|
1405
|
+
} else {
|
|
1406
|
+
publicationFailure("PUBLICATION_GENERATION_INVENTORY_MISMATCH", projectId,
|
|
1407
|
+
"Immutable generation contains an undeclared filesystem entry.")
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Verifies one inventory traversal root without following symbolic links.
|
|
1415
|
+
* @param {string} directory - Exact directory about to be read.
|
|
1416
|
+
* @param {string} projectId - Project identity.
|
|
1417
|
+
* @returns {Promise<void>} Completion.
|
|
1418
|
+
*/
|
|
1419
|
+
async function assertRealInventoryDirectory(directory, projectId) {
|
|
1420
|
+
const status = await lstat(directory)
|
|
1421
|
+
|
|
1422
|
+
if (status.isSymbolicLink()) {
|
|
1423
|
+
publicationFailure("PUBLICATION_SYMLINK_TRAVERSAL", projectId,
|
|
1424
|
+
"Candidate generation contains a symbolic link.")
|
|
1425
|
+
}
|
|
1426
|
+
if (!status.isDirectory()) {
|
|
1427
|
+
publicationFailure("PUBLICATION_GENERATION_INVENTORY_MISMATCH", projectId,
|
|
1428
|
+
"Immutable generation contains an undeclared filesystem entry.")
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Rejects external hard links and duplicate stable file identities.
|
|
1434
|
+
* @param {import("node:fs").Stats} status - Regular-file status.
|
|
1435
|
+
* @param {Set<string>} identities - Previously observed device/inode identities.
|
|
1436
|
+
* @param {string} projectId - Project identity.
|
|
1437
|
+
* @returns {void}
|
|
1438
|
+
*/
|
|
1439
|
+
function assertRegularFileImmutability(status, identities, projectId) {
|
|
1440
|
+
if (status.nlink > 1) {
|
|
1441
|
+
publicationFailure("PUBLICATION_HARD_LINK", projectId,
|
|
1442
|
+
"Immutable generation files must not have external hard links.")
|
|
1443
|
+
}
|
|
1444
|
+
const identity = stableFileIdentity(status)
|
|
1445
|
+
|
|
1446
|
+
if (identity !== null) {
|
|
1447
|
+
if (identities.has(identity)) {
|
|
1448
|
+
publicationFailure("PUBLICATION_HARD_LINK", projectId,
|
|
1449
|
+
"Immutable generation files must have unique filesystem identities.")
|
|
1450
|
+
}
|
|
1451
|
+
identities.add(identity)
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Returns a stable device/inode identity only where the host reports one.
|
|
1457
|
+
* @param {import("node:fs").Stats} status - File status.
|
|
1458
|
+
* @returns {string | null} Identity or unavailable.
|
|
1459
|
+
*/
|
|
1460
|
+
function stableFileIdentity(status) {
|
|
1461
|
+
if ((typeof status.dev != "number" && typeof status.dev != "bigint") ||
|
|
1462
|
+
(typeof status.ino != "number" && typeof status.ino != "bigint") || status.ino == 0) return null
|
|
1463
|
+
|
|
1464
|
+
return `${String(status.dev)}:${String(status.ino)}`
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Compares two string sets exactly.
|
|
1469
|
+
* @param {Set<string>} left - First set.
|
|
1470
|
+
* @param {Set<string>} right - Second set.
|
|
1471
|
+
* @returns {boolean} Equality.
|
|
1472
|
+
*/
|
|
1473
|
+
function sameStringSet(left, right) {
|
|
1474
|
+
return left.size == right.size && [...left].every(value => right.has(value))
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Synchronizes every directory in a staged tree from leaves to root.
|
|
1479
|
+
* @param {string} root - Tree root.
|
|
1480
|
+
* @returns {Promise<void>} Completion.
|
|
1481
|
+
*/
|
|
1482
|
+
async function syncTreeDirectories(root) {
|
|
1483
|
+
const entries = await readdir(root, {withFileTypes: true})
|
|
1484
|
+
|
|
1485
|
+
for (const entry of entries) {
|
|
1486
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) await syncTreeDirectories(path.join(root, entry.name))
|
|
1487
|
+
}
|
|
1488
|
+
await syncDirectory(root)
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Synchronizes one directory entry set.
|
|
1493
|
+
* @param {string} directory - Directory path.
|
|
1494
|
+
* @returns {Promise<void>} Completion.
|
|
1495
|
+
*/
|
|
1496
|
+
async function syncDirectory(directory) {
|
|
1497
|
+
const handle = await open(directory, constants.O_RDONLY)
|
|
1498
|
+
|
|
1499
|
+
try {
|
|
1500
|
+
await handle.sync()
|
|
1501
|
+
} finally {
|
|
1502
|
+
await handle.close()
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Ensures every existing path component is a real directory and optionally creates missing components.
|
|
1508
|
+
* @param {string} directory - Canonical absolute directory.
|
|
1509
|
+
* @param {boolean} create - Whether missing components may be created.
|
|
1510
|
+
* @param {string} projectId - Project identity.
|
|
1511
|
+
* @returns {Promise<void>} Completion.
|
|
1512
|
+
*/
|
|
1513
|
+
async function ensureDirectoryWithoutSymlinks(directory, create, projectId) {
|
|
1514
|
+
const parsed = path.parse(directory)
|
|
1515
|
+
const relative = directory.slice(parsed.root.length)
|
|
1516
|
+
const parts = relative.split(path.sep).filter(Boolean)
|
|
1517
|
+
let current = parsed.root
|
|
1518
|
+
|
|
1519
|
+
for (const part of parts) {
|
|
1520
|
+
current = path.join(current, part)
|
|
1521
|
+
try {
|
|
1522
|
+
const status = await lstat(current)
|
|
1523
|
+
|
|
1524
|
+
if (status.isSymbolicLink()) {
|
|
1525
|
+
publicationFailure("PUBLICATION_SYMLINK_TRAVERSAL", projectId,
|
|
1526
|
+
"Owned path traversal encountered a symbolic link.")
|
|
1527
|
+
}
|
|
1528
|
+
if (!status.isDirectory()) {
|
|
1529
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
1530
|
+
"Owned root path contains a non-directory component.")
|
|
1531
|
+
}
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
1534
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1535
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
1536
|
+
"Owned root path could not be validated.", error)
|
|
1537
|
+
}
|
|
1538
|
+
if (!create) {
|
|
1539
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
1540
|
+
"Source root must exist as a real directory.", error)
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
await mkdir(current, {mode: 0o700})
|
|
1544
|
+
} catch (mkdirError) {
|
|
1545
|
+
publicationFailure("INVALID_PUBLICATION_ROOT", projectId,
|
|
1546
|
+
"Publication root could not be created safely.", mkdirError)
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Ensures one reserved publisher directory is neither a symlink nor an unowned file.
|
|
1554
|
+
* @param {string} directory - Reserved directory.
|
|
1555
|
+
* @param {string} projectId - Project identity.
|
|
1556
|
+
* @returns {Promise<void>} Completion.
|
|
1557
|
+
*/
|
|
1558
|
+
async function ensureOwnedDirectory(directory, projectId) {
|
|
1559
|
+
try {
|
|
1560
|
+
await mkdir(directory, {mode: 0o700})
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
if (!isErrorCode(error, "EEXIST")) {
|
|
1563
|
+
publicationFailure("PUBLICATION_STAGE_WRITE_FAILED", projectId,
|
|
1564
|
+
"Publisher-owned directory could not be created.", error)
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
let status
|
|
1568
|
+
|
|
1569
|
+
try {
|
|
1570
|
+
status = await lstat(directory)
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
publicationFailure("PUBLICATION_STAGE_WRITE_FAILED", projectId,
|
|
1573
|
+
"Reserved publisher path could not be inspected.", error)
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
1577
|
+
publicationFailure(status.isSymbolicLink() ? "PUBLICATION_SYMLINK_TRAVERSAL" : "PUBLICATION_UNOWNED_CONFLICT",
|
|
1578
|
+
projectId, "Reserved publisher path is not an owned real directory.")
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Ensures an owned path has no pre-existing entry.
|
|
1584
|
+
* @param {string} filename - Exact path.
|
|
1585
|
+
* @param {string} code - Diagnostic code.
|
|
1586
|
+
* @param {string} projectId - Project identity.
|
|
1587
|
+
* @param {string} message - Stable failure detail.
|
|
1588
|
+
* @returns {Promise<void>} Completion.
|
|
1589
|
+
*/
|
|
1590
|
+
async function assertAbsent(filename, code, projectId, message) {
|
|
1591
|
+
try {
|
|
1592
|
+
await lstat(filename)
|
|
1593
|
+
publicationFailure(code, projectId, message)
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
if (isErrorCode(error, "ENOENT")) return
|
|
1596
|
+
if (error instanceof SemantifoldDiagnostic) throw error
|
|
1597
|
+
publicationFailure(code, projectId, message, error)
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Resolves a validated relative path under an owned root and rejects escape.
|
|
1603
|
+
* @param {string} root - Owned root.
|
|
1604
|
+
* @param {string} relative - Validated relative POSIX path.
|
|
1605
|
+
* @param {string} projectId - Project identity.
|
|
1606
|
+
* @returns {string} Exact owned path.
|
|
1607
|
+
*/
|
|
1608
|
+
function ownedPath(root, relative, projectId) {
|
|
1609
|
+
const resolved = path.resolve(root, ...relative.split("/"))
|
|
1610
|
+
const child = path.relative(root, resolved)
|
|
1611
|
+
|
|
1612
|
+
if (child.length == 0 || child == ".." || child.startsWith(`..${path.sep}`) || path.isAbsolute(child)) {
|
|
1613
|
+
publicationFailure("PUBLICATION_PATH_ESCAPE", projectId,
|
|
1614
|
+
"Resolved publication path escaped its owned root.")
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return resolved
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Checks a canonical non-root absolute path without filesystem access.
|
|
1622
|
+
* @param {unknown} value - Path candidate.
|
|
1623
|
+
* @returns {value is string} Whether the path is canonical.
|
|
1624
|
+
*/
|
|
1625
|
+
function isCanonicalAbsolutePath(value) {
|
|
1626
|
+
return isCanonicalAbsolutePathForPath(value, path)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Checks one absolute path using the supplied host path semantics.
|
|
1631
|
+
* This helper is intentionally not part of the package root API.
|
|
1632
|
+
* @param {unknown} value - Path candidate.
|
|
1633
|
+
* @param {typeof path.posix | typeof path.win32} hostPath - Host-native path implementation.
|
|
1634
|
+
* @returns {value is string} Whether the path is canonical for that host.
|
|
1635
|
+
*/
|
|
1636
|
+
export function isCanonicalAbsolutePathForPath(value, hostPath) {
|
|
1637
|
+
const separatorAlias = hostPath.sep == "/" ? "\\" : "/"
|
|
1638
|
+
|
|
1639
|
+
return typeof value == "string" && value.length > 0 && !value.includes("\0") && !value.includes(separatorAlias) &&
|
|
1640
|
+
hostPath.isAbsolute(value) && value != hostPath.parse(value).root && hostPath.normalize(value) == value &&
|
|
1641
|
+
hostPath.resolve(value) == value
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Checks conservative portable overlap between absolute paths.
|
|
1646
|
+
* @param {string} left - First path.
|
|
1647
|
+
* @param {string} right - Second path.
|
|
1648
|
+
* @returns {boolean} Whether either contains the other.
|
|
1649
|
+
*/
|
|
1650
|
+
function pathsOverlap(left, right) {
|
|
1651
|
+
const leftKey = left.toLowerCase()
|
|
1652
|
+
const rightKey = right.toLowerCase()
|
|
1653
|
+
|
|
1654
|
+
return leftKey == rightKey || leftKey.startsWith(`${rightKey}${path.sep}`) || rightKey.startsWith(`${leftKey}${path.sep}`)
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Checks whether a path is equal to or nested beneath one canonical root.
|
|
1659
|
+
* @param {string} root - Canonical root.
|
|
1660
|
+
* @param {string} candidate - Canonical candidate.
|
|
1661
|
+
* @returns {boolean} Whether the candidate is within the root.
|
|
1662
|
+
*/
|
|
1663
|
+
function isPathWithin(root, candidate) {
|
|
1664
|
+
const relative = path.relative(root, candidate)
|
|
1665
|
+
|
|
1666
|
+
return relative.length == 0 || (relative != ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Checks whether a path is strictly nested beneath one canonical root.
|
|
1671
|
+
* @param {string} root - Canonical root.
|
|
1672
|
+
* @param {string} candidate - Canonical candidate.
|
|
1673
|
+
* @returns {boolean} Whether the candidate is a strict descendant.
|
|
1674
|
+
*/
|
|
1675
|
+
function isStrictPathDescendant(root, candidate) {
|
|
1676
|
+
return isPathWithin(root, candidate) && path.relative(root, candidate).length > 0
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Serializes stable generated JSON with one trailing LF.
|
|
1681
|
+
* @param {unknown} value - JSON value.
|
|
1682
|
+
* @returns {Buffer} UTF-8 bytes.
|
|
1683
|
+
*/
|
|
1684
|
+
function serializeJson(value) {
|
|
1685
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Hashes exact bytes with SHA-256.
|
|
1690
|
+
* @param {Uint8Array} bytes - Exact bytes.
|
|
1691
|
+
* @returns {string} Lowercase hexadecimal hash.
|
|
1692
|
+
*/
|
|
1693
|
+
function sha256(bytes) {
|
|
1694
|
+
return createHash("sha256").update(bytes).digest("hex")
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Deeply freezes a parsed JSON value.
|
|
1699
|
+
* @template T
|
|
1700
|
+
* @param {T} value - JSON value.
|
|
1701
|
+
* @returns {T} Frozen value.
|
|
1702
|
+
*/
|
|
1703
|
+
function deepFreeze(value) {
|
|
1704
|
+
if (typeof value == "object" && value != null) {
|
|
1705
|
+
for (const child of Object.values(value)) deepFreeze(child)
|
|
1706
|
+
Object.freeze(value)
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
return value
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Compares detached JSON values without depending on object-key order.
|
|
1714
|
+
* @param {unknown} left - First JSON value.
|
|
1715
|
+
* @param {unknown} right - Second JSON value.
|
|
1716
|
+
* @returns {boolean} Whether the values are structurally equal.
|
|
1717
|
+
*/
|
|
1718
|
+
function sameJsonValue(left, right) {
|
|
1719
|
+
if (left === right) return true
|
|
1720
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1721
|
+
return Array.isArray(left) && Array.isArray(right) && left.length == right.length &&
|
|
1722
|
+
left.every((entry, index) => sameJsonValue(entry, right[index]))
|
|
1723
|
+
}
|
|
1724
|
+
if (!isPlainObject(left) || !isPlainObject(right)) return false
|
|
1725
|
+
const leftKeys = Object.keys(left)
|
|
1726
|
+
const rightKeys = Object.keys(right)
|
|
1727
|
+
|
|
1728
|
+
return leftKeys.length == rightKeys.length &&
|
|
1729
|
+
leftKeys.every(key => Object.hasOwn(right, key) && sameJsonValue(left[key], right[key]))
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Checks an active-pointer value.
|
|
1734
|
+
* @param {unknown} value - Parsed value.
|
|
1735
|
+
* @returns {value is ActivePointer} Whether the value is strict and versioned.
|
|
1736
|
+
*/
|
|
1737
|
+
function isActivePointer(value) {
|
|
1738
|
+
return isPlainObject(value) && hasExactKeys(value, ["generationId", "manifestHash", "projectId", "schema", "version"]) &&
|
|
1739
|
+
value.schema == "SemantifoldActiveGeneration" && value.version == 1 &&
|
|
1740
|
+
typeof value.projectId == "string" && identityPattern.test(value.projectId) &&
|
|
1741
|
+
typeof value.generationId == "string" && generationPattern.test(value.generationId) &&
|
|
1742
|
+
typeof value.manifestHash == "string" && /^[a-f0-9]{64}$/u.test(value.manifestHash)
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Checks a recovery-journal value.
|
|
1747
|
+
* @param {unknown} value - Parsed value.
|
|
1748
|
+
* @returns {value is PublicationJournal} Whether the value is strict and versioned.
|
|
1749
|
+
*/
|
|
1750
|
+
function isPublicationJournal(value) {
|
|
1751
|
+
return isPlainObject(value) && hasExactKeys(value, ["generationId", "projectId", "schema", "tempPointer", "version"]) &&
|
|
1752
|
+
value.schema == "SemantifoldPublicationJournal" && value.version == 1 &&
|
|
1753
|
+
typeof value.projectId == "string" && identityPattern.test(value.projectId) &&
|
|
1754
|
+
typeof value.generationId == "string" && generationPattern.test(value.generationId) &&
|
|
1755
|
+
value.tempPointer == `.active-generation.${value.generationId}.tmp`
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Checks an ordinary object with exactly the expected enumerable string keys.
|
|
1760
|
+
* @param {Record<string, unknown>} value - Object candidate.
|
|
1761
|
+
* @param {readonly string[]} keys - Sorted expected keys.
|
|
1762
|
+
* @returns {boolean} Whether keys match exactly.
|
|
1763
|
+
*/
|
|
1764
|
+
function hasExactKeys(value, keys) {
|
|
1765
|
+
const actual = Object.keys(value).sort(compareStrings)
|
|
1766
|
+
|
|
1767
|
+
return actual.length == keys.length && actual.every((key, index) => key == keys[index])
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Checks for an ordinary string-keyed object.
|
|
1772
|
+
* @param {unknown} value - Candidate value.
|
|
1773
|
+
* @returns {value is Record<string, unknown>} Whether the value is plain.
|
|
1774
|
+
*/
|
|
1775
|
+
function isPlainObject(value) {
|
|
1776
|
+
return typeof value == "object" && value != null && !Array.isArray(value) && Object.getPrototypeOf(value) == Object.prototype
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Checks for a dense ordinary array.
|
|
1781
|
+
* @param {unknown} value - Candidate value.
|
|
1782
|
+
* @returns {value is unknown[]} Whether the value is dense.
|
|
1783
|
+
*/
|
|
1784
|
+
function isDenseArray(value) {
|
|
1785
|
+
if (!Array.isArray(value)) return false
|
|
1786
|
+
const indexedKeys = Object.keys(value).filter(key => /^(?:0|[1-9][0-9]*)$/u.test(key))
|
|
1787
|
+
|
|
1788
|
+
return indexedKeys.length == value.length
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Compares strings by Unicode code unit.
|
|
1793
|
+
* @param {string} left - First string.
|
|
1794
|
+
* @param {string} right - Second string.
|
|
1795
|
+
* @returns {number} Ordering.
|
|
1796
|
+
*/
|
|
1797
|
+
function compareStrings(left, right) {
|
|
1798
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Checks a narrowed filesystem error code.
|
|
1803
|
+
* @param {unknown} error - Opaque error.
|
|
1804
|
+
* @param {string} code - Expected code.
|
|
1805
|
+
* @returns {boolean} Whether the code matches.
|
|
1806
|
+
*/
|
|
1807
|
+
function isErrorCode(error, code) {
|
|
1808
|
+
return error instanceof Error && "code" in error && error.code == code
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* Throws one stable publication diagnostic without embedding host paths or raw filesystem messages.
|
|
1813
|
+
* @param {string} code - Stable diagnostic code.
|
|
1814
|
+
* @param {string} projectId - Project identity.
|
|
1815
|
+
* @param {string} message - Stable detail.
|
|
1816
|
+
* @param {unknown} [error] - Preserved cause.
|
|
1817
|
+
* @returns {never} Always throws.
|
|
1818
|
+
*/
|
|
1819
|
+
function publicationFailure(code, projectId, message, error) {
|
|
1820
|
+
throw new SemantifoldDiagnostic({
|
|
1821
|
+
cause: error instanceof Error ? error : undefined,
|
|
1822
|
+
code,
|
|
1823
|
+
language: projectId,
|
|
1824
|
+
message
|
|
1825
|
+
})
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* @typedef ValidatedPublicationTarget
|
|
1830
|
+
* @property {string} id - Target identity.
|
|
1831
|
+
* @property {import("./semantic/types.js").GeneratedArtifactSet} artifactSet - Detached artifact set.
|
|
1832
|
+
* @property {string} sourceProjection - Source projection.
|
|
1833
|
+
* @property {string} buildProjection - Build projection.
|
|
1834
|
+
* @property {import("./semantic/types.js").PublicationTargetRole} role - Language-neutral target role.
|
|
1835
|
+
* @property {readonly import("./semantic/types.js").PublicationValidator[]} validators - Ordered validators.
|
|
1836
|
+
*/
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* @typedef ActivePointer
|
|
1840
|
+
* @property {"SemantifoldActiveGeneration"} schema - Schema discriminator.
|
|
1841
|
+
* @property {1} version - Schema version.
|
|
1842
|
+
* @property {string} projectId - Project identity.
|
|
1843
|
+
* @property {string} generationId - Generation identity.
|
|
1844
|
+
* @property {string} manifestHash - SHA-256 generation-manifest hash.
|
|
1845
|
+
*/
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* @typedef PublicationJournal
|
|
1849
|
+
* @property {"SemantifoldPublicationJournal"} schema - Schema discriminator.
|
|
1850
|
+
* @property {1} version - Schema version.
|
|
1851
|
+
* @property {string} projectId - Project identity.
|
|
1852
|
+
* @property {string} generationId - Candidate generation identity.
|
|
1853
|
+
* @property {string} tempPointer - Owned sibling temporary-pointer filename.
|
|
1854
|
+
*/
|