@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/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
+ }