@gesslar/toolkit 2.8.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/toolkit",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "A collection of utilities for Node.js and browser environments.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -13,6 +13,7 @@ import path from "node:path"
13
13
  import {Data, Valid} from "../browser/index.js"
14
14
  import DirectoryObject from "./DirectoryObject.js"
15
15
  import FileObject from "./FileObject.js"
16
+ import FS from "./FS.js"
16
17
  import Sass from "./Sass.js"
17
18
 
18
19
  /**
@@ -84,7 +85,8 @@ export default class CappedDirectoryObject extends DirectoryObject {
84
85
  "Parent must have the same cap as this directory.",
85
86
  )
86
87
 
87
- const parentPath = parent.path
88
+ // Use real path for filesystem operations
89
+ const parentPath = parent.realPath || parent.path
88
90
 
89
91
  // Validate parent's lineage traces back to the cap
90
92
  let found = false
@@ -128,13 +130,13 @@ export default class CappedDirectoryObject extends DirectoryObject {
128
130
  */
129
131
  #validateCapPath() {
130
132
  const cap = this.#cap
131
- const resolved = path.resolve(this.path)
133
+ const resolved = path.resolve(this.#realPath)
132
134
  const capResolved = path.resolve(cap)
133
135
 
134
136
  // Check if the resolved path starts with the cap directory
135
137
  if(!resolved.startsWith(capResolved)) {
136
138
  throw Sass.new(
137
- `Path '${this.path}' is not within the cap directory '${cap}'`
139
+ `Path '${this.#realPath}' is not within the cap directory '${cap}'`
138
140
  )
139
141
  }
140
142
  }
@@ -157,6 +159,73 @@ export default class CappedDirectoryObject extends DirectoryObject {
157
159
  return true
158
160
  }
159
161
 
162
+ /**
163
+ * Returns the real filesystem path (for internal and subclass use).
164
+ *
165
+ * @protected
166
+ * @returns {string} The actual filesystem path
167
+ */
168
+ get realPath() {
169
+ return super.path
170
+ }
171
+
172
+ /**
173
+ * Private alias for realPath (for use in private methods).
174
+ *
175
+ * @private
176
+ * @returns {string} The actual filesystem path
177
+ */
178
+ get #realPath() {
179
+ return this.realPath
180
+ }
181
+
182
+ /**
183
+ * Returns the virtual path relative to the cap.
184
+ * This is the default path representation in the capped environment.
185
+ * Use `.real.path` to access the actual filesystem path.
186
+ *
187
+ * @returns {string} Path relative to cap, or "/" if at cap root
188
+ * @example
189
+ * const temp = new TempDirectoryObject("myapp")
190
+ * const subdir = temp.getDirectory("data/cache")
191
+ * console.log(subdir.path) // "/data/cache" (virtual, relative to cap)
192
+ * console.log(subdir.real.path) // "/tmp/myapp-ABC123/data/cache" (actual filesystem)
193
+ */
194
+ get path() {
195
+ const capResolved = path.resolve(this.#cap)
196
+ const relative = path.relative(capResolved, this.#realPath)
197
+
198
+ // If at cap root or empty, return "/"
199
+ if(!relative || relative === ".") {
200
+ return "/"
201
+ }
202
+
203
+ // Return with leading slash to indicate it's cap-relative
204
+ return "/" + relative.split(path.sep).join("/")
205
+ }
206
+
207
+ /**
208
+ * Returns a plain DirectoryObject representing the actual filesystem location.
209
+ * This provides an "escape hatch" from the capped environment to interact
210
+ * with the real filesystem when needed.
211
+ *
212
+ * @returns {DirectoryObject} Uncapped directory object at the real filesystem path
213
+ * @example
214
+ * const temp = new TempDirectoryObject("myapp")
215
+ * const subdir = temp.getDirectory("data")
216
+ *
217
+ * // Work within the capped environment (virtual paths)
218
+ * console.log(subdir.path) // "/data" (virtual)
219
+ * subdir.getFile("config.json") // Stays within cap
220
+ *
221
+ * // Break out to real filesystem when needed
222
+ * console.log(subdir.real.path) // "/tmp/myapp-ABC123/data" (real)
223
+ * subdir.real.parent // Can traverse outside the cap
224
+ */
225
+ get real() {
226
+ return new DirectoryObject(this.#realPath)
227
+ }
228
+
160
229
  /**
161
230
  * Returns the parent directory of this capped directory.
162
231
  * Returns null only if this directory is at the cap (the "root" of the capped tree).
@@ -175,12 +244,17 @@ export default class CappedDirectoryObject extends DirectoryObject {
175
244
  const capResolved = path.resolve(this.#cap)
176
245
 
177
246
  // If we're at the cap, return null (cap is the "root")
178
- if(this.path === capResolved) {
247
+ if(this.#realPath === capResolved) {
179
248
  return null
180
249
  }
181
250
 
182
- // Otherwise return the parent (plain DirectoryObject, not capped)
183
- return super.parent
251
+ // Otherwise return the parent using real path (plain DirectoryObject, not capped)
252
+ const parentPath = path.dirname(this.#realPath)
253
+ const isRoot = parentPath === this.#realPath
254
+
255
+ return isRoot
256
+ ? null
257
+ : new DirectoryObject(parentPath, this.temporary)
184
258
  }
185
259
 
186
260
  /**
@@ -201,19 +275,27 @@ export default class CappedDirectoryObject extends DirectoryObject {
201
275
  *#walkUpCapped() {
202
276
  const capResolved = path.resolve(this.#cap)
203
277
 
204
- // Use super.walkUp but stop when we would go beyond the cap
205
- for(const dir of super.walkUp) {
278
+ // Build trail from real path
279
+ const trail = this.#realPath.split(path.sep).filter(Boolean)
280
+ const curr = [...trail]
281
+
282
+ while(curr.length > 0) {
283
+ const joined = path.sep + curr.join(path.sep)
284
+
206
285
  // Don't yield anything beyond the cap
207
- if(!dir.path.startsWith(capResolved)) {
286
+ if(!joined.startsWith(capResolved)) {
208
287
  break
209
288
  }
210
289
 
211
- yield dir
290
+ // Yield plain DirectoryObject with real path
291
+ yield new DirectoryObject(joined, this.temporary)
212
292
 
213
293
  // Stop after yielding the cap
214
- if(dir.path === capResolved) {
294
+ if(joined === capResolved) {
215
295
  break
216
296
  }
297
+
298
+ curr.pop()
217
299
  }
218
300
  }
219
301
 
@@ -229,69 +311,286 @@ export default class CappedDirectoryObject extends DirectoryObject {
229
311
  /**
230
312
  * Creates a new CappedDirectoryObject by extending this directory's path.
231
313
  *
232
- * Validates that the resulting path remains within the cap directory tree.
314
+ * All paths are coerced to remain within the cap directory tree:
315
+ * - Absolute paths (e.g., "/foo") are treated as relative to the cap
316
+ * - Parent traversal ("..") is allowed but clamped at the cap boundary
317
+ * - The cap acts as the virtual root directory
233
318
  *
234
- * @param {string} newPath - The path segment to append
235
- * @returns {CappedDirectoryObject} A new CappedDirectoryObject with the extended path
236
- * @throws {Sass} If the path would escape the cap directory
237
- * @throws {Sass} If the path is absolute
238
- * @throws {Sass} If the path contains traversal (..)
319
+ * @param {string} newPath - The path to resolve (can be absolute or contain ..)
320
+ * @returns {CappedDirectoryObject} A new CappedDirectoryObject with the coerced path
239
321
  * @example
240
322
  * const capped = new TempDirectoryObject("myapp")
241
323
  * const subDir = capped.getDirectory("data")
242
324
  * console.log(subDir.path) // "/tmp/myapp-ABC123/data"
325
+ *
326
+ * @example
327
+ * // Absolute paths are relative to cap
328
+ * const abs = capped.getDirectory("/foo/bar")
329
+ * console.log(abs.path) // "/tmp/myapp-ABC123/foo/bar"
330
+ *
331
+ * @example
332
+ * // Excessive .. traversal clamps to cap
333
+ * const up = capped.getDirectory("../../../etc/passwd")
334
+ * console.log(up.path) // "/tmp/myapp-ABC123" (clamped to cap)
243
335
  */
244
336
  getDirectory(newPath) {
245
337
  Valid.type(newPath, "String")
246
338
 
247
- // Prevent absolute paths
339
+ // Fast path: if it's a simple name (no separators, not absolute, no ..)
340
+ // use the subclass constructor directly to preserve type
341
+ const isSimpleName = !path.isAbsolute(newPath) &&
342
+ !newPath.includes("/") &&
343
+ !newPath.includes("\\") &&
344
+ !newPath.includes("..")
345
+
346
+ if(isSimpleName) {
347
+ // For CappedDirectoryObject, pass (name, cap, parent, temporary)
348
+ // For TempDirectoryObject subclass, it expects (name, parent) but will
349
+ // internally call super with the cap parameter
350
+ if(this.constructor === CappedDirectoryObject) {
351
+ return new CappedDirectoryObject(
352
+ newPath,
353
+ this.#cap,
354
+ this,
355
+ this.temporary
356
+ )
357
+ }
358
+
359
+ // For subclasses like TempDirectoryObject
360
+ return new this.constructor(newPath, this)
361
+ }
362
+
363
+ // Complex path - handle coercion
364
+ const capResolved = path.resolve(this.#cap)
365
+ let targetPath
366
+
367
+ // If absolute, treat as relative to cap (virtual root)
248
368
  if(path.isAbsolute(newPath)) {
249
- throw Sass.new("Absolute paths are not allowed in capped directories")
369
+ // Strip leading slashes to make relative
370
+ const relative = newPath.replace(/^[/\\]+/, "")
371
+
372
+ // Join with cap (unless empty, which means cap root)
373
+ targetPath = relative ? path.join(capResolved, relative) : capResolved
374
+ } else {
375
+ // Relative path - resolve from current directory
376
+ targetPath = FS.resolvePath(this.#realPath, newPath)
250
377
  }
251
378
 
252
- // Prevent path traversal attacks
253
- const normalized = path.normalize(newPath)
254
- if(normalized.includes("..")) {
255
- throw Sass.new("Path traversal (..) is not allowed in capped directories")
379
+ // Resolve to absolute path (handles .. and .)
380
+ const resolved = path.resolve(targetPath)
381
+
382
+ // Coerce: if path escaped cap, clamp to cap boundary
383
+ const coerced = resolved.startsWith(capResolved)
384
+ ? resolved
385
+ : capResolved
386
+
387
+ // Compute path relative to cap for reconstruction
388
+ const relativeToCap = path.relative(capResolved, coerced)
389
+
390
+ // If we're at the cap root, return cap root directory
391
+ if(!relativeToCap || relativeToCap === ".") {
392
+ return this.#createCappedAtRoot()
256
393
  }
257
394
 
258
- // Use the constructor of the current class (supports subclassing)
259
- // Pass this as parent so the child inherits the same cap
260
- return new this.constructor(newPath, this)
395
+ // Build directory by traversing segments from cap
396
+ return this.#buildDirectoryFromRelativePath(relativeToCap)
397
+ }
398
+
399
+ /**
400
+ * Creates a CappedDirectoryObject at the cap root.
401
+ * Can be overridden by subclasses that have different root semantics.
402
+ *
403
+ * @private
404
+ * @returns {CappedDirectoryObject} Directory object at cap root
405
+ */
406
+ #createCappedAtRoot() {
407
+ // Create a base CappedDirectoryObject at the cap path
408
+ // This works for direct usage of CappedDirectoryObject
409
+ // Subclasses may need to override if they have special semantics
410
+ return new CappedDirectoryObject(null, this.#cap, null, this.temporary)
411
+ }
412
+
413
+ /**
414
+ * Builds a directory by traversing path segments from cap.
415
+ *
416
+ * @private
417
+ * @param {string} relativePath - Path relative to cap
418
+ * @returns {CappedDirectoryObject} The directory at the final path
419
+ */
420
+ #buildDirectoryFromRelativePath(relativePath) {
421
+ const segments = relativePath.split(path.sep).filter(Boolean)
422
+
423
+ // Start at cap root
424
+ let current = this.#createCappedAtRoot()
425
+
426
+ // Traverse each segment, creating CappedDirectoryObject instances
427
+ // (not subclass instances, to avoid constructor signature issues)
428
+ for(const segment of segments) {
429
+ current = new CappedDirectoryObject(
430
+ segment,
431
+ this.#cap,
432
+ current,
433
+ this.temporary
434
+ )
435
+ }
436
+
437
+ return current
261
438
  }
262
439
 
263
440
  /**
264
441
  * Creates a new FileObject by extending this directory's path.
265
442
  *
266
- * Validates that the resulting path remains within the cap directory tree.
443
+ * All paths are coerced to remain within the cap directory tree:
444
+ * - Absolute paths (e.g., "/config.json") are treated as relative to the cap
445
+ * - Parent traversal ("..") is allowed but clamped at the cap boundary
446
+ * - The cap acts as the virtual root directory
267
447
  *
268
- * @param {string} filename - The filename to append
269
- * @returns {FileObject} A new FileObject with the extended path
270
- * @throws {Sass} If the path would escape the cap directory
271
- * @throws {Sass} If the path is absolute
272
- * @throws {Sass} If the path contains traversal (..)
448
+ * @param {string} filename - The filename to resolve (can be absolute or contain ..)
449
+ * @returns {FileObject} A new FileObject with the coerced path
273
450
  * @example
274
451
  * const capped = new TempDirectoryObject("myapp")
275
452
  * const file = capped.getFile("config.json")
276
453
  * console.log(file.path) // "/tmp/myapp-ABC123/config.json"
454
+ *
455
+ * @example
456
+ * // Absolute paths are relative to cap
457
+ * const abs = capped.getFile("/data/config.json")
458
+ * console.log(abs.path) // "/tmp/myapp-ABC123/data/config.json"
459
+ *
460
+ * @example
461
+ * // Excessive .. traversal clamps to cap
462
+ * const up = capped.getFile("../../../etc/passwd")
463
+ * console.log(up.path) // "/tmp/myapp-ABC123/passwd" (clamped to cap)
277
464
  */
278
465
  getFile(filename) {
279
466
  Valid.type(filename, "String")
280
467
 
281
- // Prevent absolute paths
468
+ // Fast path: if it's a simple filename (no separators, not absolute, no ..)
469
+ // use this as the parent directly
470
+ const isSimpleName = !path.isAbsolute(filename) &&
471
+ !filename.includes("/") &&
472
+ !filename.includes("\\") &&
473
+ !filename.includes("..")
474
+
475
+ if(isSimpleName) {
476
+ // Simple filename - create directly with this as parent
477
+ return new FileObject(filename, this)
478
+ }
479
+
480
+ // Complex path - handle coercion
481
+ const capResolved = path.resolve(this.#cap)
482
+ let targetPath
483
+
484
+ // If absolute, treat as relative to cap (virtual root)
282
485
  if(path.isAbsolute(filename)) {
283
- throw Sass.new("Absolute paths are not allowed in capped directories")
486
+ // Strip leading slashes to make relative
487
+ const relative = filename.replace(/^[/\\]+/, "")
488
+
489
+ // Join with cap
490
+ targetPath = path.join(capResolved, relative)
491
+ } else {
492
+ // Relative path - resolve from current directory
493
+ targetPath = FS.resolvePath(this.#realPath, filename)
284
494
  }
285
495
 
286
- // Prevent path traversal attacks
287
- const normalized = path.normalize(filename)
288
- if(normalized.includes("..")) {
289
- throw Sass.new("Path traversal (..) is not allowed in capped directories")
496
+ // Resolve to absolute path (handles .. and .)
497
+ const resolved = path.resolve(targetPath)
498
+
499
+ // Coerce: if path escaped cap, clamp to cap boundary
500
+ const coerced = resolved.startsWith(capResolved)
501
+ ? resolved
502
+ : capResolved
503
+
504
+ // Extract directory and filename parts
505
+ let fileDir = path.dirname(coerced)
506
+ let fileBasename = path.basename(coerced)
507
+
508
+ // Special case: if coerced is exactly the cap (file tried to escape),
509
+ // the file should be placed at the cap root with just the filename
510
+ if(coerced === capResolved) {
511
+ // Extract just the filename from the original path
512
+ fileBasename = path.basename(resolved)
513
+ fileDir = capResolved
290
514
  }
291
515
 
292
- // Pass the filename and this directory as parent
293
- // This ensures the FileObject maintains the correct parent reference
294
- return new FileObject(filename, this)
516
+ // Get or create the parent directory
517
+ const relativeToCap = path.relative(capResolved, fileDir)
518
+ const parentDir = !relativeToCap || relativeToCap === "."
519
+ ? this.#createCappedAtRoot()
520
+ : this.#buildDirectoryFromRelativePath(relativeToCap)
521
+
522
+ // Create FileObject with parent directory
523
+ return new FileObject(fileBasename, parentDir)
524
+ }
525
+
526
+ /**
527
+ * Override exists to use real filesystem path.
528
+ *
529
+ * @returns {Promise<boolean>} Whether the directory exists
530
+ */
531
+ get exists() {
532
+ return this.real.exists
533
+ }
534
+
535
+ /**
536
+ * Override read to use real filesystem path and return capped objects.
537
+ *
538
+ * @param {string} [pat=""] - Optional glob pattern
539
+ * @returns {Promise<{files: Array<FileObject>, directories: Array}>} Directory contents
540
+ */
541
+ async read(pat="") {
542
+ const {files, directories} = await this.real.read(pat)
543
+
544
+ // Convert plain DirectoryObjects to CappedDirectoryObjects with same cap
545
+ const cappedDirectories = directories.map(dir => {
546
+ const name = dir.name
547
+
548
+ return new this.constructor(name, this)
549
+ })
550
+
551
+ return {files, directories: cappedDirectories}
552
+ }
553
+
554
+ /**
555
+ * Override assureExists to use real filesystem path.
556
+ *
557
+ * @param {object} [options] - Options for mkdir
558
+ * @returns {Promise<void>}
559
+ */
560
+ async assureExists(options = {}) {
561
+ return await this.real.assureExists(options)
562
+ }
563
+
564
+ /**
565
+ * Override delete to use real filesystem path.
566
+ *
567
+ * @returns {Promise<void>}
568
+ */
569
+ async delete() {
570
+ return await this.real.delete()
571
+ }
572
+
573
+ /**
574
+ * Override remove to preserve temporary flag check.
575
+ *
576
+ * @returns {Promise<void>}
577
+ */
578
+ async remove() {
579
+ if(!this.temporary)
580
+ throw Sass.new("This is not a temporary directory.")
581
+
582
+ const {files, directories} = await this.read()
583
+
584
+ // Remove subdirectories recursively
585
+ for(const dir of directories)
586
+ await dir.remove()
587
+
588
+ // Remove files
589
+ for(const file of files)
590
+ await file.delete()
591
+
592
+ // Delete the now-empty directory
593
+ await this.delete()
295
594
  }
296
595
 
297
596
  /**
@@ -300,6 +599,6 @@ export default class CappedDirectoryObject extends DirectoryObject {
300
599
  * @returns {string} string representation of the CappedDirectoryObject
301
600
  */
302
601
  toString() {
303
- return `[CappedDirectoryObject: ${this.path}]`
602
+ return `[CappedDirectoryObject: ${this.path} (real: ${this.#realPath})]`
304
603
  }
305
604
  }
@@ -96,6 +96,7 @@ export default class FileObject extends FS {
96
96
  case "String":
97
97
  return new DirectoryObject(parent)
98
98
  case "DirectoryObject":
99
+ case "CappedDirectoryObject":
99
100
  case "TempDirectoryObject":
100
101
  return parent
101
102
  default:
@@ -103,7 +104,9 @@ export default class FileObject extends FS {
103
104
  }
104
105
  })()
105
106
 
106
- const final = FS.resolvePath(parentObject.path ?? ".", fixedFile)
107
+ // Use real path if parent is capped, otherwise use path
108
+ const parentPath = parentObject.realPath || parentObject.path
109
+ const final = FS.resolvePath(parentPath ?? ".", fixedFile)
107
110
 
108
111
  const resolved = final
109
112
  const url = new URL(FS.pathToUri(resolved))
@@ -113,7 +116,9 @@ export default class FileObject extends FS {
113
116
 
114
117
  // If the file is directly in the provided parent directory, reuse that object
115
118
  // Otherwise, create a DirectoryObject for the actual parent directory
116
- const actualParent = parentObject && actualParentPath === parentObject.path
119
+ // Use real path for comparison if parent is capped
120
+ const parentRealPath = parentObject.realPath || parentObject.path
121
+ const actualParent = parentObject && actualParentPath === parentRealPath
117
122
  ? parentObject
118
123
  : new DirectoryObject(actualParentPath)
119
124
 
@@ -184,12 +189,28 @@ export default class FileObject extends FS {
184
189
  }
185
190
 
186
191
  /**
187
- * Return the fully resolved absolute path to the file on disk.
192
+ * Returns the file path. If the parent is a capped directory, returns the
193
+ * virtual path relative to the cap. Otherwise returns the real filesystem path.
194
+ * Use `.real.path` to always get the actual filesystem path.
188
195
  *
189
- * @returns {string} The fully resolved absolute file path
196
+ * @returns {string} The file path (virtual if parent is capped, real otherwise)
190
197
  */
191
198
  get path() {
192
- return this.#meta.path
199
+ const realPath = this.#meta.path
200
+ const parent = this.#meta.parent
201
+
202
+ // If parent is capped, return virtual path
203
+ if(parent?.capped) {
204
+ const cap = parent.cap
205
+ const capResolved = path.resolve(cap)
206
+ const relative = path.relative(capResolved, realPath)
207
+
208
+ // Return with leading slash to indicate it's cap-relative
209
+ return "/" + relative.split(path.sep).join("/")
210
+ }
211
+
212
+ // Otherwise return real path
213
+ return realPath
193
214
  }
194
215
 
195
216
  /**
@@ -264,6 +285,26 @@ export default class FileObject extends FS {
264
285
  return this.#meta.parent
265
286
  }
266
287
 
288
+ /**
289
+ * Returns a plain FileObject representing the actual filesystem location.
290
+ * This provides an "escape hatch" when working with capped directories,
291
+ * allowing direct filesystem access when needed.
292
+ *
293
+ * @returns {FileObject} Uncapped file object at the real filesystem path
294
+ * @example
295
+ * const temp = new TempDirectoryObject("myapp")
296
+ * const file = temp.getFile("/config/app.json")
297
+ *
298
+ * // file.path shows virtual path
299
+ * console.log(file.path) // "/config/app.json"
300
+ * // file.real.path shows actual filesystem path
301
+ * console.log(file.real.path) // "/tmp/myapp-ABC123/config/app.json"
302
+ * file.real.parent.parent // Can traverse outside the cap
303
+ */
304
+ get real() {
305
+ return new FileObject(this.#meta.path)
306
+ }
307
+
267
308
  /**
268
309
  * Check if a file can be read. Returns true if the file can be read, false
269
310
  *
@@ -271,7 +312,7 @@ export default class FileObject extends FS {
271
312
  */
272
313
  async canRead() {
273
314
  try {
274
- await fs.access(this.path, fs.constants.R_OK)
315
+ await fs.access(this.#meta.path, fs.constants.R_OK)
275
316
 
276
317
  return true
277
318
  } catch(_) {
@@ -286,7 +327,7 @@ export default class FileObject extends FS {
286
327
  */
287
328
  async canWrite() {
288
329
  try {
289
- await fs.access(this.path, fs.constants.W_OK)
330
+ await fs.access(this.#meta.path, fs.constants.W_OK)
290
331
 
291
332
  return true
292
333
  } catch(_) {
@@ -301,7 +342,7 @@ export default class FileObject extends FS {
301
342
  */
302
343
  async #fileExists() {
303
344
  try {
304
- await fs.access(this.path, fs.constants.F_OK)
345
+ await fs.access(this.#meta.path, fs.constants.F_OK)
305
346
 
306
347
  return true
307
348
  } catch(_) {
@@ -316,7 +357,7 @@ export default class FileObject extends FS {
316
357
  */
317
358
  async size() {
318
359
  try {
319
- const stat = await fs.stat(this.path)
360
+ const stat = await fs.stat(this.#meta.path)
320
361
 
321
362
  return stat.size
322
363
  } catch(_) {
@@ -332,7 +373,7 @@ export default class FileObject extends FS {
332
373
  */
333
374
  async modified() {
334
375
  try {
335
- const stat = await fs.stat(this.path)
376
+ const stat = await fs.stat(this.#meta.path)
336
377
 
337
378
  return stat.mtime
338
379
  } catch(_) {
@@ -86,12 +86,12 @@ export default class TempDirectoryObject extends CappedDirectoryObject {
86
86
  */
87
87
  #createDirectory() {
88
88
  try {
89
- fs.mkdirSync(this.path)
89
+ fs.mkdirSync(this.realPath)
90
90
  } catch(e) {
91
91
  // EEXIST is fine - directory already exists
92
92
  if(e.code !== "EEXIST") {
93
93
  throw Sass.new(
94
- `Unable to create temporary directory '${this.path}': ${e.message}`
94
+ `Unable to create temporary directory '${this.realPath}': ${e.message}`
95
95
  )
96
96
  }
97
97
  }
@@ -38,6 +38,32 @@ export default class CappedDirectoryObject extends DirectoryObject {
38
38
  * @returns {boolean} Always true for CappedDirectoryObject instances
39
39
  */
40
40
  get capped(): boolean;
41
+ /**
42
+ * Returns the real filesystem path (for internal and subclass use).
43
+ *
44
+ * @protected
45
+ * @returns {string} The actual filesystem path
46
+ */
47
+ protected get realPath(): string;
48
+ /**
49
+ * Returns a plain DirectoryObject representing the actual filesystem location.
50
+ * This provides an "escape hatch" from the capped environment to interact
51
+ * with the real filesystem when needed.
52
+ *
53
+ * @returns {DirectoryObject} Uncapped directory object at the real filesystem path
54
+ * @example
55
+ * const temp = new TempDirectoryObject("myapp")
56
+ * const subdir = temp.getDirectory("data")
57
+ *
58
+ * // Work within the capped environment (virtual paths)
59
+ * console.log(subdir.path) // "/data" (virtual)
60
+ * subdir.getFile("config.json") // Stays within cap
61
+ *
62
+ * // Break out to real filesystem when needed
63
+ * console.log(subdir.real.path) // "/tmp/myapp-ABC123/data" (real)
64
+ * subdir.real.parent // Can traverse outside the cap
65
+ */
66
+ get real(): DirectoryObject;
41
67
  /**
42
68
  * Returns a generator that walks up to the cap.
43
69
  *
@@ -47,20 +73,41 @@ export default class CappedDirectoryObject extends DirectoryObject {
47
73
  /**
48
74
  * Creates a new CappedDirectoryObject by extending this directory's path.
49
75
  *
50
- * Validates that the resulting path remains within the cap directory tree.
76
+ * All paths are coerced to remain within the cap directory tree:
77
+ * - Absolute paths (e.g., "/foo") are treated as relative to the cap
78
+ * - Parent traversal ("..") is allowed but clamped at the cap boundary
79
+ * - The cap acts as the virtual root directory
51
80
  *
52
- * @param {string} newPath - The path segment to append
53
- * @returns {CappedDirectoryObject} A new CappedDirectoryObject with the extended path
54
- * @throws {Sass} If the path would escape the cap directory
55
- * @throws {Sass} If the path is absolute
56
- * @throws {Sass} If the path contains traversal (..)
81
+ * @param {string} newPath - The path to resolve (can be absolute or contain ..)
82
+ * @returns {CappedDirectoryObject} A new CappedDirectoryObject with the coerced path
57
83
  * @example
58
84
  * const capped = new TempDirectoryObject("myapp")
59
85
  * const subDir = capped.getDirectory("data")
60
86
  * console.log(subDir.path) // "/tmp/myapp-ABC123/data"
87
+ *
88
+ * @example
89
+ * // Absolute paths are relative to cap
90
+ * const abs = capped.getDirectory("/foo/bar")
91
+ * console.log(abs.path) // "/tmp/myapp-ABC123/foo/bar"
92
+ *
93
+ * @example
94
+ * // Excessive .. traversal clamps to cap
95
+ * const up = capped.getDirectory("../../../etc/passwd")
96
+ * console.log(up.path) // "/tmp/myapp-ABC123" (clamped to cap)
61
97
  */
62
98
  getDirectory(newPath: string): CappedDirectoryObject;
99
+ /**
100
+ * Override read to use real filesystem path and return capped objects.
101
+ *
102
+ * @param {string} [pat=""] - Optional glob pattern
103
+ * @returns {Promise<{files: Array<FileObject>, directories: Array}>} Directory contents
104
+ */
105
+ read(pat?: string): Promise<{
106
+ files: Array<FileObject>;
107
+ directories: any[];
108
+ }>;
63
109
  #private;
64
110
  }
65
111
  import DirectoryObject from "./DirectoryObject.js";
112
+ import FileObject from "./FileObject.js";
66
113
  //# sourceMappingURL=CappedDirectoryObject.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CappedDirectoryObject.d.ts","sourceRoot":"","sources":["../../lib/CappedDirectoryObject.js"],"names":[],"mappings":"AAiBA;;;;;;;;GAQG;AACH;IAGE;;;;;;;;;;;;;;;;OAgBG;IACH,kBAXW,MAAM,OAAC,OACP,MAAM,WACN,qBAAqB,OAAC,cACtB,OAAO,EAkFjB;IAqBD;;;;OAIG;IACH,WAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,cAFa,OAAO,CAInB;IA8DD;;;;OAIG;IACH,cAFa,SAAS,CAAC,eAAe,CAAC,CAItC;IAED;;;;;;;;;;;;;;OAcG;IACH,sBAVW,MAAM,GACJ,qBAAqB,CA0BjC;;CA4CF;4BAnS2B,sBAAsB"}
1
+ {"version":3,"file":"CappedDirectoryObject.d.ts","sourceRoot":"","sources":["../../lib/CappedDirectoryObject.js"],"names":[],"mappings":"AAkBA;;;;;;;;GAQG;AACH;IAGE;;;;;;;;;;;;;;;;OAgBG;IACH,kBAXW,MAAM,OAAC,OACP,MAAM,WACN,qBAAqB,OAAC,cACtB,OAAO,EAmFjB;IAqBD;;;;OAIG;IACH,WAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,cAFa,OAAO,CAInB;IAED;;;;;OAKG;IACH,0BAFa,MAAM,CAIlB;IAqCD;;;;;;;;;;;;;;;;;OAiBG;IACH,YAba,eAAe,CAe3B;IA2ED;;;;OAIG;IACH,cAFa,SAAS,CAAC,eAAe,CAAC,CAItC;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,sBAjBW,MAAM,GACJ,qBAAqB,CA6EjC;IA0ID;;;;;OAKG;IACH,WAHW,MAAM,GACJ,OAAO,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;QAAC,WAAW,QAAO;KAAC,CAAC,CAanE;;CAoDF;4BA9kB2B,sBAAsB;uBAC3B,iBAAiB"}
@@ -50,9 +50,11 @@ export default class FileObject extends FS {
50
50
  */
51
51
  get supplied(): string;
52
52
  /**
53
- * Return the fully resolved absolute path to the file on disk.
53
+ * Returns the file path. If the parent is a capped directory, returns the
54
+ * virtual path relative to the cap. Otherwise returns the real filesystem path.
55
+ * Use `.real.path` to always get the actual filesystem path.
54
56
  *
55
- * @returns {string} The fully resolved absolute file path
57
+ * @returns {string} The file path (virtual if parent is capped, real otherwise)
56
58
  */
57
59
  get path(): string;
58
60
  /**
@@ -107,6 +109,23 @@ export default class FileObject extends FS {
107
109
  * @returns {DirectoryObject} The parent directory object
108
110
  */
109
111
  get parent(): DirectoryObject;
112
+ /**
113
+ * Returns a plain FileObject representing the actual filesystem location.
114
+ * This provides an "escape hatch" when working with capped directories,
115
+ * allowing direct filesystem access when needed.
116
+ *
117
+ * @returns {FileObject} Uncapped file object at the real filesystem path
118
+ * @example
119
+ * const temp = new TempDirectoryObject("myapp")
120
+ * const file = temp.getFile("/config/app.json")
121
+ *
122
+ * // file.path shows virtual path
123
+ * console.log(file.path) // "/config/app.json"
124
+ * // file.real.path shows actual filesystem path
125
+ * console.log(file.real.path) // "/tmp/myapp-ABC123/config/app.json"
126
+ * file.real.parent.parent // Can traverse outside the cap
127
+ */
128
+ get real(): FileObject;
110
129
  /**
111
130
  * Check if a file can be read. Returns true if the file can be read, false
112
131
  *
@@ -1 +1 @@
1
- {"version":3,"file":"FileObject.d.ts","sourceRoot":"","sources":["../../lib/FileObject.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;GAcG;AAEH;uBA8He,MAAM;IA7HnB;;;;;OAKG;IACH,yBAFU;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC,OAAO,KAAK,GAAG,OAAO,IAAI,CAAC,CAAA;KAAC,CAO1D;IA2BF;;;;;OAKG;IACH,sBAHW,MAAM,GAAG,UAAU,WACnB,eAAe,GAAC,MAAM,GAAC,IAAI,EAkDrC;IAWD;;;;OAIG;IACH,UAFa,MAAM,CAclB;IAWD;;;;OAIG;IACH,cAFa,OAAO,CAAC,OAAO,CAAC,CAI5B;IAED;;;;OAIG;IACH,gBAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,YAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,WAFa,GAAG,CAIf;IAED;;;;OAIG;IACH,YAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,cAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,iBAFa,MAAM,CAIlB;IACD;;;;OAIG;IACH,cAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,mBAFa,OAAO,CAInB;IAED;;;;;;;;;;;;;;OAcG;IACH,cAFa,eAAe,CAI3B;IAED;;;;OAIG;IACH,WAFa,OAAO,CAAC,OAAO,CAAC,CAU5B;IAED;;;;OAIG;IACH,YAFa,OAAO,CAAC,OAAO,CAAC,CAU5B;IAiBD;;;;OAIG;IACH,QAFa,OAAO,CAAC,MAAM,OAAC,CAAC,CAU5B;IAED;;;;;OAKG;IACH,YAFa,OAAO,CAAC,IAAI,OAAC,CAAC,CAU1B;IAsBD;;;;;OAKG;IACH,gBAHW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CAY3B;IAED;;;;;;;;;;;OAWG;IACH,cARa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAED;;;;;;;;;;;OAWG;IACH,eARW,MAAM,aACN,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAezB;IAED;;;;;;;;;;;;;;;OAeG;IACH,kBAXW,WAAW,GAAC,IAAI,GAAC,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAyBzB;IAED;;;;;;;;;;;;;;OAcG;IACH,gBAXW,MAAM,aACN,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CAkC5B;IAED;;;;OAIG;IACH,UAFa,OAAO,CAAC,MAAM,CAAC,CAY3B;IAED;;;;;;;;;OASG;IACH,UAPa,OAAO,CAAC,IAAI,CAAC,CAiBzB;;CACF;eA7gBc,SAAS;4BADI,sBAAsB;kBARhC,OAAO;iBAIR,MAAM"}
1
+ {"version":3,"file":"FileObject.d.ts","sourceRoot":"","sources":["../../lib/FileObject.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;GAcG;AAEH;uBAmIe,MAAM;IAlInB;;;;;OAKG;IACH,yBAFU;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC,OAAO,KAAK,GAAG,OAAO,IAAI,CAAC,CAAA;KAAC,CAO1D;IA2BF;;;;;OAKG;IACH,sBAHW,MAAM,GAAG,UAAU,WACnB,eAAe,GAAC,MAAM,GAAC,IAAI,EAuDrC;IAWD;;;;OAIG;IACH,UAFa,MAAM,CAclB;IAWD;;;;OAIG;IACH,cAFa,OAAO,CAAC,OAAO,CAAC,CAI5B;IAED;;;;OAIG;IACH,gBAFa,MAAM,CAIlB;IAED;;;;;;OAMG;IACH,YAFa,MAAM,CAkBlB;IAED;;;;OAIG;IACH,WAFa,GAAG,CAIf;IAED;;;;OAIG;IACH,YAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,cAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,iBAFa,MAAM,CAIlB;IACD;;;;OAIG;IACH,cAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,mBAFa,OAAO,CAInB;IAED;;;;;;;;;;;;;;OAcG;IACH,cAFa,eAAe,CAI3B;IAED;;;;;;;;;;;;;;;OAeG;IACH,YAXa,UAAU,CAatB;IAED;;;;OAIG;IACH,WAFa,OAAO,CAAC,OAAO,CAAC,CAU5B;IAED;;;;OAIG;IACH,YAFa,OAAO,CAAC,OAAO,CAAC,CAU5B;IAiBD;;;;OAIG;IACH,QAFa,OAAO,CAAC,MAAM,OAAC,CAAC,CAU5B;IAED;;;;;OAKG;IACH,YAFa,OAAO,CAAC,IAAI,OAAC,CAAC,CAU1B;IAsBD;;;;;OAKG;IACH,gBAHW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CAY3B;IAED;;;;;;;;;;;OAWG;IACH,cARa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAED;;;;;;;;;;;OAWG;IACH,eARW,MAAM,aACN,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAezB;IAED;;;;;;;;;;;;;;;OAeG;IACH,kBAXW,WAAW,GAAC,IAAI,GAAC,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAyBzB;IAED;;;;;;;;;;;;;;OAcG;IACH,gBAXW,MAAM,aACN,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CAkC5B;IAED;;;;OAIG;IACH,UAFa,OAAO,CAAC,MAAM,CAAC,CAY3B;IAED;;;;;;;;;OASG;IACH,UAPa,OAAO,CAAC,IAAI,CAAC,CAiBzB;;CACF;eAtjBc,SAAS;4BADI,sBAAsB;kBARhC,OAAO;iBAIR,MAAM"}