@deepblend/dsh-blender-host 0.1.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/lib/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
package/lib/paths.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment and atomic file writes.
|
|
3
|
+
*
|
|
4
|
+
* Every filesystem write DeepBlend performs goes through this module, because
|
|
5
|
+
* two of the SPEC's hard requirements are properties of *this* code rather than
|
|
6
|
+
* of any caller:
|
|
7
|
+
*
|
|
8
|
+
* - **§15.2 workspace boundary.** A model-supplied project id, revision id or
|
|
9
|
+
* file name must never be able to name a path outside the projects root. The
|
|
10
|
+
* guard is enforced on the RESOLVED, symlink-free path, so `..`, an absolute
|
|
11
|
+
* segment, a symlinked project directory and a Windows-style drive prefix are
|
|
12
|
+
* all rejected by the same check instead of by four separate ones.
|
|
13
|
+
* - **§13.2 atomic publication.** A revision directory must appear complete or
|
|
14
|
+
* not at all. A reader (or a crash) must never observe a half-written
|
|
15
|
+
* revision, so a revision is built in a staging directory and published with
|
|
16
|
+
* a single `rename`, and individual files are written to a temp name first.
|
|
17
|
+
*
|
|
18
|
+
* Owner: DeepBlend Studio — M1
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
statSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from 'node:fs'
|
|
33
|
+
import { dirname, isAbsolute, join, normalize, resolve, sep } from 'node:path'
|
|
34
|
+
|
|
35
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One path segment that is safe to join under a trusted root.
|
|
39
|
+
*
|
|
40
|
+
* Rejects the three ways a single segment can escape: an explicit traversal, a
|
|
41
|
+
* separator (which would make it two segments), and the empty/`.`/`..` names.
|
|
42
|
+
* A leading `.` is allowed — revision staging directories are named `.staging-*`
|
|
43
|
+
* and Blender's own `.blend1` backups are legitimate file names — so the check
|
|
44
|
+
* is about traversal, not about hiding files.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} segment
|
|
47
|
+
* @param {string} what - used in the error message, e.g. "project id".
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function requireSafeSegment(segment, what) {
|
|
51
|
+
if (typeof segment !== 'string' || segment.length === 0) {
|
|
52
|
+
throw new BlenderError(BlenderErrorCode.PATH_SEGMENT_INVALID, `${what} must be a non-empty string.`)
|
|
53
|
+
}
|
|
54
|
+
if (segment.includes('\0')) {
|
|
55
|
+
throw new BlenderError(BlenderErrorCode.PATH_SEGMENT_INVALID, `${what} contains a NUL byte.`)
|
|
56
|
+
}
|
|
57
|
+
if (segment.includes('/') || segment.includes('\\')) {
|
|
58
|
+
throw new BlenderError(
|
|
59
|
+
BlenderErrorCode.PATH_SEGMENT_INVALID,
|
|
60
|
+
`${what} "${segment}" contains a path separator; it must be a single name.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
if (segment === '.' || segment === '..') {
|
|
64
|
+
throw new BlenderError(
|
|
65
|
+
BlenderErrorCode.PATH_SEGMENT_INVALID,
|
|
66
|
+
`${what} "${segment}" is a directory traversal token.`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
// Windows drive-relative names such as `C:foo` are absolute on that platform.
|
|
70
|
+
if (/^[a-zA-Z]:/.test(segment)) {
|
|
71
|
+
throw new BlenderError(
|
|
72
|
+
BlenderErrorCode.PATH_SEGMENT_INVALID,
|
|
73
|
+
`${what} "${segment}" looks like a drive-relative path.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return segment
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve `candidate` and assert it stays inside `root`.
|
|
81
|
+
*
|
|
82
|
+
* The comparison happens on realpaths where they exist, so a symlink pointing
|
|
83
|
+
* out of the workspace is rejected rather than followed. When the path does not
|
|
84
|
+
* exist yet — the normal case for a directory about to be created — the nearest
|
|
85
|
+
* existing ancestor is realpath'd instead, which still catches a symlinked
|
|
86
|
+
* parent.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root - trusted absolute root (already realpath'd by the caller).
|
|
89
|
+
* @param {string} candidate - absolute or root-relative path.
|
|
90
|
+
* @param {string} what
|
|
91
|
+
* @returns {string} the resolved, contained absolute path.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveInside(root, candidate, what) {
|
|
94
|
+
const base = resolve(root)
|
|
95
|
+
const target = isAbsolute(candidate) ? normalize(candidate) : resolve(base, candidate)
|
|
96
|
+
|
|
97
|
+
const baseReal = safeRealpath(base)
|
|
98
|
+
const targetReal = safeRealpath(target)
|
|
99
|
+
|
|
100
|
+
if (targetReal !== baseReal && !targetReal.startsWith(baseReal + sep)) {
|
|
101
|
+
throw new BlenderError(
|
|
102
|
+
BlenderErrorCode.PATH_OUTSIDE_WORKSPACE,
|
|
103
|
+
`${what} resolves to ${targetReal}, which is outside ${baseReal} (SPEC §15.2).`,
|
|
104
|
+
{ detail: { root: baseReal, resolved: targetReal } },
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return targetReal
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* realpath that tolerates a not-yet-existing path, by walking up to the closest
|
|
112
|
+
* existing ancestor and re-appending the missing tail.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} target
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function safeRealpath(target) {
|
|
118
|
+
const absolute = resolve(target)
|
|
119
|
+
try {
|
|
120
|
+
return realpathSync(absolute)
|
|
121
|
+
} catch {
|
|
122
|
+
// Fall through: the path (or its parent) does not exist yet.
|
|
123
|
+
}
|
|
124
|
+
const segments = []
|
|
125
|
+
let cursor = absolute
|
|
126
|
+
for (;;) {
|
|
127
|
+
const parent = dirname(cursor)
|
|
128
|
+
if (parent === cursor) return absolute
|
|
129
|
+
segments.unshift(cursor.slice(parent.length + 1))
|
|
130
|
+
cursor = parent
|
|
131
|
+
try {
|
|
132
|
+
return join(realpathSync(cursor), ...segments)
|
|
133
|
+
} catch {
|
|
134
|
+
// Keep walking up.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Ensure a directory exists, resolving and checking containment first. */
|
|
140
|
+
export function ensureDirectory(root, candidate, what) {
|
|
141
|
+
const target = resolveInside(root, candidate, what)
|
|
142
|
+
mkdirSync(target, { recursive: true })
|
|
143
|
+
return target
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Write a file so a reader never sees a partial document.
|
|
148
|
+
*
|
|
149
|
+
* The temp name is a sibling (same filesystem, so `rename` is atomic) and is
|
|
150
|
+
* removed on failure so a crash mid-write does not leave litter that a later
|
|
151
|
+
* `readdir` would mistake for content.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} path
|
|
154
|
+
* @param {string} contents
|
|
155
|
+
*/
|
|
156
|
+
export function writeFileAtomic(path, contents) {
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
158
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(temporary, contents, 'utf8')
|
|
161
|
+
renameSync(temporary, path)
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
rmSync(temporary, { force: true })
|
|
164
|
+
throw cause
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Write canonical JSON atomically, with a trailing newline. */
|
|
169
|
+
export function writeJsonAtomic(path, value) {
|
|
170
|
+
writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read and parse JSON, or return `null` when the file is absent.
|
|
175
|
+
*
|
|
176
|
+
* A file that exists but does not parse THROWS rather than returning null: the
|
|
177
|
+
* two situations need different handling. Absence is a normal state ("this
|
|
178
|
+
* project has no final render yet"); corruption is a bug that must surface with
|
|
179
|
+
* the path in the message instead of being silently treated as absence, which is
|
|
180
|
+
* how a corrupt revision would quietly become a lost revision.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} path
|
|
183
|
+
* @param {{ allowMissing?: boolean }} [options]
|
|
184
|
+
* @returns {any}
|
|
185
|
+
*/
|
|
186
|
+
export function readJson(path, options = {}) {
|
|
187
|
+
if (!existsSync(path)) {
|
|
188
|
+
if (options.allowMissing === false) {
|
|
189
|
+
throw new BlenderError(BlenderErrorCode.REVISION_CORRUPT, `Expected a document at ${path}, but it does not exist.`)
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
let text
|
|
194
|
+
try {
|
|
195
|
+
text = readFileSync(path, 'utf8')
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
throw new BlenderError(BlenderErrorCode.REVISION_CORRUPT, `Could not read ${path}.`, { cause })
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(text)
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
throw new BlenderError(
|
|
203
|
+
BlenderErrorCode.REVISION_CORRUPT,
|
|
204
|
+
`${path} exists but is not valid JSON: ${cause.message}. Refusing to treat corruption as absence.`,
|
|
205
|
+
{ cause },
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read JSON and return `null` for EVERY failure, including corruption.
|
|
212
|
+
*
|
|
213
|
+
* For derived data only — the projects index, a cache. Anything whose absence
|
|
214
|
+
* would be a lie about the user's work must use {@link readJson}, which treats
|
|
215
|
+
* corruption as an error.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} path
|
|
218
|
+
* @returns {any}
|
|
219
|
+
*/
|
|
220
|
+
export function readJsonSafe(path) {
|
|
221
|
+
try {
|
|
222
|
+
if (!existsSync(path)) return null
|
|
223
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
224
|
+
} catch {
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Atomically publish a fully-built staging directory as the final directory.
|
|
231
|
+
*
|
|
232
|
+
* `rename` on the same filesystem is atomic, which is the entire mechanism by
|
|
233
|
+
* which SPEC §13.2's "原子发布 Revision 目录" holds: the revision directory does
|
|
234
|
+
* not exist until it is complete under its real name.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} staging
|
|
237
|
+
* @param {string} finalPath
|
|
238
|
+
*/
|
|
239
|
+
export function publishDirectory(staging, finalPath) {
|
|
240
|
+
if (existsSync(finalPath)) {
|
|
241
|
+
throw new BlenderError(
|
|
242
|
+
BlenderErrorCode.REVISION_ALLOCATION_FAILED,
|
|
243
|
+
`Refusing to publish over the existing directory ${finalPath}.`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
mkdirSync(dirname(finalPath), { recursive: true })
|
|
247
|
+
renameSync(staging, finalPath)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Remove a directory tree, tolerating absence. */
|
|
251
|
+
export function removeTree(path) {
|
|
252
|
+
rmSync(path, { recursive: true, force: true })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** List immediate child directories of `path`, sorted; `[]` when absent. */
|
|
256
|
+
export function listDirectories(path) {
|
|
257
|
+
if (!existsSync(path)) return []
|
|
258
|
+
return readdirSync(path, { withFileTypes: true })
|
|
259
|
+
.filter(entry => entry.isDirectory())
|
|
260
|
+
.map(entry => entry.name)
|
|
261
|
+
.sort()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** File size in bytes, or `null` when the file is absent. */
|
|
265
|
+
export function fileSize(path) {
|
|
266
|
+
try {
|
|
267
|
+
return statSync(path).size
|
|
268
|
+
} catch {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** sha256 of a file's bytes, or `null` when the file is absent. */
|
|
274
|
+
export function fileSha256(path) {
|
|
275
|
+
try {
|
|
276
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** True when a readable file exists at `path`. */
|
|
283
|
+
export function isFile(path) {
|
|
284
|
+
try {
|
|
285
|
+
return statSync(path).isFile()
|
|
286
|
+
} catch {
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Turn an arbitrary human title into a safe, stable project id.
|
|
293
|
+
*
|
|
294
|
+
* Non-ASCII titles are dropped rather than transliterated: a project id appears
|
|
295
|
+
* in directory names and in tool arguments, and a predictable ASCII id that a
|
|
296
|
+
* model can retype exactly is worth more than a pretty one. A title with no
|
|
297
|
+
* usable ASCII ends up as `project`, and the store appends a numeric suffix on
|
|
298
|
+
* collision — an id is a handle, and the human-readable name lives in
|
|
299
|
+
* `project.title`.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} title
|
|
302
|
+
* @returns {string}
|
|
303
|
+
*/
|
|
304
|
+
export function slugifyProjectId(title) {
|
|
305
|
+
const ascii = String(title ?? '')
|
|
306
|
+
.normalize('NFKD')
|
|
307
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
308
|
+
.toLowerCase()
|
|
309
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
310
|
+
.replace(/^-+|-+$/g, '')
|
|
311
|
+
.slice(0, 48)
|
|
312
|
+
.replace(/-+$/g, '')
|
|
313
|
+
return ascii.length === 0 ? 'project' : ascii
|
|
314
|
+
}
|