@poe-platform/safe-fs 0.1.818 → 0.1.819

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 CHANGED
@@ -54,7 +54,7 @@ rename nor inode/version identity from placeholder native metadata. Retained
54
54
  reads remain available, with native seek reporting `ENOTSUP`. Ordinary browser
55
55
  bundles cannot import this backend. Node keeps its POSIX host profile.
56
56
 
57
- For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Archive extraction requires atomic ancestry verification. MemoryFileSystem supports it, including a writable root composed with read-only mounts such as `/skills/.system`; the real adapter refuses it. Mount extraction delegates confinement to the root backend and keeps other mounts read-only in the extraction view. Extraction roots inside a separate mount are rejected because directory and metadata mutations cannot atomically guard ancestors on another backend. Staged file publication supports nested writable mounts when every ancestor provides authoritative validation. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
57
+ For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Tar extraction can use `confineTrustedExtraction` on an externally isolated host tree. This view retains extraction roots and checks full ancestry at publication without claiming OS-atomic staging. Atomic archive extraction requires atomic ancestry verification. MemoryFileSystem supports it, including a writable root composed with read-only mounts such as `/skills/.system`; the real adapter refuses it. Mount extraction delegates confinement to each selected backend and keeps other mounts read-only. Extraction inside a separate mount requires synchronous outer-ancestor guards at every mutation; backends that cannot honor those guards refuse it. Staged file publication supports nested writable mounts when every ancestor provides authoritative validation. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
58
58
 
59
59
  To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression commands, host adapters need atomic no-replace publication. Supply `createRealFileSystem({ root, renameNoReplace })` only when that callback binds a qualified native primitive such as Linux `renameat2` with `RENAME_NOREPLACE`. It receives resolved absolute host paths and an optional signal in its third argument. Existing destinations must reject `EEXIST` atomically; existence checks followed by rename and copy/delete are insufficient. Without this binding the adapter explicitly refuses those operations. See the [no-replace contract](src/contracts/filesystem.md#atomic-no-replace-rename).
60
60
 
@@ -83,6 +83,8 @@ export interface FileSystemCapabilities {
83
83
  readonly retainedStagingWrite?: boolean;
84
84
  /** Atomically verifies every supplied root-to-parent directory identity at publication. */
85
85
  readonly atomicStagingAncestry?: boolean;
86
+ /** Full ancestry validation serialized with publication on an externally isolated host. */
87
+ readonly trustedStagingAncestry?: boolean;
86
88
  /** Prepares directory guards that validate without yielding or mutating state. */
87
89
  readonly synchronousDirectoryValidation?: boolean;
88
90
  /** Captures a followed destination path with a synchronous commit validator. */
@@ -323,12 +325,18 @@ export interface ConditionalFilePublicationOptions extends FsOptions {
323
325
  readonly mode?: number;
324
326
  readonly mtimeMs?: number;
325
327
  }
328
+ export interface ExtractionOptions extends FsOptions {
329
+ /** Validate outer mount bindings synchronously at every mutation; reject unsupported guards. */
330
+ readonly commitGuard?: () => true;
331
+ }
326
332
  export interface FileSystem {
327
333
  /** Retain each extraction root and enforce no-symlink ancestry atomically with
328
334
  * every mutation (including metadata and both hardlink paths). Returned views
329
335
  * must refuse unsupported mutations; no check-then-write emulation is allowed.
330
336
  * Roots and their ancestors must remain the retained directories. */
331
- confineExtraction?(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
337
+ confineExtraction?(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
338
+ /** Trusted-host counterpart; requires external tree isolation, and never promises OS-atomic confinement. */
339
+ confineTrustedExtraction?(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
332
340
  /** Explicit retained-object capability for qualified byte-path backends. */
333
341
  readonly objects?: ObjectFileSystem;
334
342
  /** Consume the complete source privately, then atomically compare/publish.
@@ -9,7 +9,7 @@ export * from "./fs/webdav/index.js";
9
9
  export * from "./bridge/index.js";
10
10
  export * from "./python/index.js";
11
11
  export { parseXml, parseXmlSteps, parseXmlSourceSteps, normalizeXmlChunks, parseXmlStream, XmlLimitError } from "./xml.js";
12
- export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits, XmlParserFrame, XmlFrameRequest, XmlNamespaceScope, XmlNamespaceRequest, XmlAttributeRequest, XmlAttributeRecord } from "./xml.js";
12
+ export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits, XmlParserFrame, XmlFrameRequest, XmlNamespaceScope, XmlNamespaceRequest, XmlSourceRead, XmlSourceSpan, XmlAttributeRequest, XmlAttributeRecord } from "./xml.js";
13
13
  export * from "./contracts/object.js";
14
14
  export { ObjectAuthority } from "./fs/object-authority.js";
15
15
  export * from "./fs/s3/index.js";
@@ -144,7 +144,7 @@ export function quotaCapabilities(capabilities) {
144
144
  const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
145
145
  const streamingAppend = requireCapabilities(capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
146
146
  const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
147
- return Object.freeze({ ...rest, synchronousStagingResolution: false, synchronousFollowedStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
147
+ return Object.freeze({ ...rest, synchronousStagingResolution: false, synchronousFollowedStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, trustedStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
148
148
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
149
149
  ...(streamingAppend === undefined ? {} : { streamingAppend }),
150
150
  });
@@ -181,7 +181,7 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
181
181
  (unavailable ??= {}).synchronousStagingResolution = false;
182
182
  if (capabilities.synchronousFollowedStagingResolution === true && (capabilities.synchronousStagingResolution !== true || unavailable?.synchronousStagingResolution === false))
183
183
  (unavailable ??= {}).synchronousFollowedStagingResolution = false;
184
- if (capabilities.guardedStagingPublication === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
184
+ if (capabilities.guardedStagingPublication === true && ((capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false) && capabilities.trustedOwnedStaging !== true))
185
185
  (unavailable ??= {}).guardedStagingPublication = false;
186
186
  if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
187
187
  (unavailable ??= {}).atomicDirectoryMetadata = false;
@@ -195,8 +195,11 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
195
195
  }
196
196
  }
197
197
  }
198
- if (missing)
198
+ if (missing) {
199
199
  (unavailable ??= {}).trustedOwnedStaging = false;
200
+ unavailable.trustedStagingAncestry = false;
201
+ unavailable.guardedStagingPublication = false;
202
+ }
200
203
  }
201
204
  return unavailable ? { ...capabilities, ...unavailable } : capabilities;
202
205
  }
@@ -206,7 +209,7 @@ export async function requireOwnedMutation(filesystem, path, capability, options
206
209
  const query = create ? { ...options, create: true } : options;
207
210
  const capabilities = ownedMutationCapabilities(filesystem, await filesystem.capabilitiesFor?.(path, capability === "atomicStagingAncestry" ? { ...query, stagingAncestry: true } : query) ?? filesystem.capabilities);
208
211
  options.signal?.throwIfAborted();
209
- if (capabilities[capability] !== true && !(capabilities.trustedOwnedStaging === true && ["atomicFileStaging", "atomicFileMutation", "atomicDirectoryMetadata"].includes(capability)))
212
+ if (capabilities[capability] !== true && !(capability === "atomicStagingAncestry" && capabilities.trustedStagingAncestry === true && capabilities.trustedOwnedStaging === true) && !(capabilities.trustedOwnedStaging === true && ["atomicFileStaging", "atomicFileMutation", "atomicDirectoryMetadata"].includes(capability)))
210
213
  throw new FsError("ENOTSUP", { path, syscall: capability });
211
214
  }
212
215
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import type { ExtractionOptions } from "../../contracts/filesystem.js";
1
2
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
2
3
  import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
3
4
  import type { ByteSource } from "../../contracts/io.js";
@@ -51,7 +52,8 @@ export declare class DeviceFileSystem implements FileSystem {
51
52
  access(path: string, mode?: number, options?: FsOptions): Promise<void>;
52
53
  readlink(path: string, options?: FsOptions): Promise<string>;
53
54
  symlink(target: string, path: string, options?: FsOptions): Promise<void>;
54
- confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
55
+ confineTrustedExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
56
+ confineExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
55
57
  link(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
56
58
  chmod(path: string, mode: number, options?: FsOptions): Promise<void>;
57
59
  utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
@@ -873,6 +873,13 @@ export class DeviceFileSystem {
873
873
  throw new FsError("ENOTSUP", { path });
874
874
  await this.#filesystem.symlink(target, path, options);
875
875
  }
876
+ async confineTrustedExtraction(roots, options = {}) {
877
+ options.signal?.throwIfAborted();
878
+ const operation = this.#filesystem.confineTrustedExtraction ?? this.#filesystem.confineExtraction;
879
+ if (!operation)
880
+ throw new FsError("ENOTSUP", { syscall: "confineTrustedExtraction" });
881
+ return operation.call(this.#filesystem, roots, options);
882
+ }
876
883
  async confineExtraction(roots, options = {}) {
877
884
  options.signal?.throwIfAborted();
878
885
  if (!this.#filesystem.confineExtraction)
@@ -1,3 +1,4 @@
1
+ import { plainDevicePathResolvers } from "./plain-path.js";
1
2
  import { FsError, isFsError } from "../../contracts/errors.js";
2
3
  import { pathByteLength, validatePath } from "../../contracts/virtual-path.js";
3
4
  import { capturePathNamespace, pathNamespace } from "../path-namespace.js";
@@ -146,6 +147,11 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
146
147
  const fast = tryResolveMemoryDevicePath(filesystem, path, resizeCreate);
147
148
  if (fast !== undefined)
148
149
  return fast;
150
+ if (followFinal && resizeCreate === undefined && traversal === undefined && isCleanAbsolutePath(path) && path !== "/" && path !== "/dev" && !path.startsWith("/dev/")) {
151
+ const resolved = await plainDevicePathResolvers.get(filesystem)?.(path, options);
152
+ if (resolved !== undefined)
153
+ return resolved;
154
+ }
149
155
  }
150
156
  const namespace = capturePathNamespace(filesystem, options);
151
157
  if (resizeCreate !== undefined)
@@ -0,0 +1,2 @@
1
+ import type { FileSystem, FsOptions } from "../../contracts/filesystem.js";
2
+ export declare const plainDevicePathResolvers: WeakMap<FileSystem, (path: string, options: FsOptions) => Promise<string | undefined>>;
@@ -0,0 +1,3 @@
1
+ // Private backend hooks, not cached path observations. A hook may only accept
2
+ // an ordinary existing path after freshly checking every component for symlinks.
3
+ export const plainDevicePathResolvers = new WeakMap();
@@ -1,3 +1,4 @@
1
+ import type { ExtractionOptions } from "../../contracts/filesystem.js";
1
2
  import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
3
  import type { ByteSource } from "../../contracts/io.js";
3
4
  import { type MemoryAtomicView } from "./atomic-view.js";
@@ -77,7 +78,7 @@ export declare class MemoryFileSystem implements FileSystem {
77
78
  constructor(options?: MemoryFileSystemOptions);
78
79
  compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
79
80
  canonicalizeMissingTarget(path: string, options?: FsOptions): string | undefined;
80
- confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
81
+ confineExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
81
82
  private metadata;
82
83
  private directory;
83
84
  private addDirectoryNode;
@@ -738,6 +738,7 @@ export class MemoryFileSystem {
738
738
  }
739
739
  async confineExtraction(roots, options = {}) {
740
740
  options.signal?.throwIfAborted();
741
+ const commitGuard = options.commitGuard;
741
742
  const retained = new Map();
742
743
  for (const root of roots) {
743
744
  this.validatePath(root, "confineExtraction");
@@ -757,6 +758,8 @@ export class MemoryFileSystem {
757
758
  const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional", "removeEntryConditional", "prepareDirectory", "createStagedFile", "publishStagedFile", "removeStagedFile"]);
758
759
  const reads = new Set(["access", "capabilitiesFor", "compareEntry", "lstat", "stat", "readFile", "readStream", "readdir", "readlink", "realpath"]);
759
760
  const check = (path, followFinal) => {
761
+ if (commitGuard)
762
+ runStagingGuard(commitGuard);
760
763
  if (!roots.some(root => root === "/" || path === root || path.startsWith(`${root}/`)))
761
764
  this.fail("EPERM", "confineExtraction", path);
762
765
  const components = path.split("/").filter(Boolean);
@@ -1,3 +1,4 @@
1
+ import type { ExtractionOptions } from "../../contracts/filesystem.js";
1
2
  import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
3
  import type { ByteSource } from "../../contracts/io.js";
3
4
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
@@ -12,7 +13,9 @@ export declare class MountFileSystem implements FileSystem {
12
13
  private namespaceMutation;
13
14
  private readonly namespaceWaiters;
14
15
  constructor(options: MountFileSystemOptions);
15
- confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
16
+ private confineArchive;
17
+ confineTrustedExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
18
+ confineExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
16
19
  private select;
17
20
  capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
18
21
  private supportsStagingAncestry;
@@ -152,35 +152,66 @@ export class MountFileSystem {
152
152
  ...(retainedRead === undefined ? {} : { retainedRead }),
153
153
  });
154
154
  }
155
- async confineExtraction(roots, options = {}) {
156
- options.signal?.throwIfAborted();
157
- const root = this.select("/").backend;
158
- const captured = [...roots];
159
- for (const path of captured) {
155
+ async confineArchive(roots, options, trusted) {
156
+ const callerGuard = options.commitGuard;
157
+ const groups = new Map();
158
+ for (const path of [...roots]) {
160
159
  validatePath(path);
161
160
  if (normalizePath(globalPath(path)) !== path)
162
161
  fail("EINVAL");
163
- // A backing confinement retains its own root-to-destination ancestry.
164
- // It cannot retain directories belonging to another mounted backend.
165
- if (this.select(path).path !== "/")
162
+ const location = await this.resolve(path, options, { followFinal: false, entry: true });
163
+ if (location.path !== path || location.stat?.type !== "directory" || location.synthetic)
166
164
  fail("ENOTSUP");
165
+ const paths = groups.get(location.mount) ?? [];
166
+ paths.push(location.local);
167
+ groups.set(location.mount, paths);
168
+ }
169
+ const confined = new Map();
170
+ for (const mount of this.mounts) {
171
+ const paths = groups.get(mount);
172
+ const backend = mount.backend;
173
+ const operation = trusted ? backend.confineTrustedExtraction ?? backend.confineExtraction : backend.confineExtraction;
174
+ if (paths && !operation)
175
+ fail("ENOTSUP");
176
+ let outerGuard;
177
+ if (paths && mount.path !== "/") {
178
+ const parent = mount.path.slice(0, mount.path.lastIndexOf("/")) || "/";
179
+ const ancestors = await Promise.all(directoryAncestryPaths(parent).map(async (path) => ({ path, stat: await this.lstat(path, options) })));
180
+ outerGuard = await this.prepareAncestry(ancestors, options);
181
+ }
182
+ const controls = { ...options, ...(outerGuard || callerGuard ? { commitGuard: () => {
183
+ if (callerGuard)
184
+ runStagingGuard(callerGuard);
185
+ if (outerGuard)
186
+ runStagingGuard(outerGuard);
187
+ return true;
188
+ } } : {}) };
189
+ const view = paths ? await operation.call(backend, paths, controls) : createReadOnlyFileSystem(backend);
190
+ const observations = paths ? view : backend;
191
+ confined.set(mount, new Proxy(view, {
192
+ get(target, property) {
193
+ if (property === "capabilities")
194
+ return { ...target.capabilities, synchronousDirectoryValidation: observations.capabilities.synchronousDirectoryValidation };
195
+ if (property === "capabilitiesFor")
196
+ return async (path, query = {}) => ({
197
+ ...await target.capabilitiesFor?.(path, query) ?? target.capabilities,
198
+ synchronousDirectoryValidation: (await observations.capabilitiesFor?.(path, query) ?? observations.capabilities).synchronousDirectoryValidation,
199
+ });
200
+ // These capture bindings; mutations still use the confined view.
201
+ const owner = property === "prepareDirectoryAncestry" || property === "prepareStagingResolution" ? backend : target;
202
+ const value = Reflect.get(owner, property);
203
+ return typeof value === "function" ? value.bind(owner) : value;
204
+ },
205
+ }));
167
206
  }
168
- if (!root.confineExtraction)
169
- fail("ENOTSUP");
170
- const confined = await root.confineExtraction(captured, options);
171
207
  options.signal?.throwIfAborted();
172
- const observations = new Set(["prepareDirectoryAncestry", "prepareStagingResolution"]);
173
- const backing = new Proxy(confined, {
174
- get(target, key) {
175
- // These only capture/validate bindings. Mutations must always use the
176
- // backend's confined view, including creation, metadata and hardlinks.
177
- const owner = observations.has(key) ? root : target;
178
- const value = Reflect.get(owner, key);
179
- return typeof value === "function" ? value.bind(owner) : value;
180
- },
181
- });
182
- return new MountFileSystem({ root: backing, mounts: Object.fromEntries(this.mounts.filter(mount => mount.path !== "/").map(mount => [mount.path,
183
- mount.backend.capabilities.readOnly === true ? mount.backend : createReadOnlyFileSystem(mount.backend)])) });
208
+ return new MountFileSystem({ root: confined.get(this.select("/")), mounts: Object.fromEntries(this.mounts.filter(mount => mount.path !== "/").map(mount => [mount.path, confined.get(mount)])) });
209
+ }
210
+ confineTrustedExtraction(roots, options = {}) {
211
+ return this.confineArchive(roots, options, true);
212
+ }
213
+ confineExtraction(roots, options = {}) {
214
+ return this.confineArchive(roots, options, false);
184
215
  }
185
216
  select(path) {
186
217
  return this.mounts.find((mount) => within(mount.path, path));
@@ -221,7 +252,7 @@ export class MountFileSystem {
221
252
  && await this.supportsDirectoryValidation(location.path.slice(0, location.path.lastIndexOf("/")) || "/", options, true)
222
253
  : this.capabilities.conditionalChmod;
223
254
  const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, synchronousFollowedStagingResolution: ignoredFollowedResolution, ...ordinary } = resize;
224
- const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution, ...(options.followFinalSymlink === true ? { synchronousFollowedStagingResolution: resolution } : {}) }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
255
+ const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution, ...(options.followFinalSymlink === true ? { synchronousFollowedStagingResolution: resolution } : {}) }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry && resize.atomicStagingAncestry === true, trustedStagingAncestry: ancestry && resize.trustedStagingAncestry === true, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
225
256
  const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
226
257
  : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
227
258
  if (location.synthetic)
@@ -240,7 +271,7 @@ export class MountFileSystem {
240
271
  async supportsStagingAncestry(target, capabilities, options) {
241
272
  options.signal?.throwIfAborted();
242
273
  if (target.synthetic || target.stat !== undefined && (target.stat.type !== "file" || compareIdentity(target.stat, target.stat) !== "same")
243
- || capabilities.atomicFileStaging !== true || capabilities.atomicStagingAncestry !== true
274
+ || (capabilities.atomicFileStaging !== true || capabilities.atomicStagingAncestry !== true) && (capabilities.trustedOwnedStaging !== true || capabilities.trustedStagingAncestry !== true)
244
275
  || capabilities.guardedStagingPublication !== true || capabilities.readOnly === true)
245
276
  return false;
246
277
  const parent = target.path.slice(0, target.path.lastIndexOf("/")) || "/";
@@ -1,3 +1,4 @@
1
+ import type { ExtractionOptions } from "../../contracts/filesystem.js";
1
2
  import type { ByteSource } from "../../contracts/io.js";
2
3
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
3
4
  import type { ConditionalWriteFileOptions, ConditionalRemoveFileOptions, PrepareDirectoryOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
@@ -19,7 +20,7 @@ export declare class OverlayFileSystem implements FileSystem {
19
20
  private readonly publication;
20
21
  private queue;
21
22
  constructor(options: OverlayFileSystemOptions);
22
- confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
23
+ confineExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
23
24
  createStagedFile(directory: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
24
25
  publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
25
26
  removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
@@ -148,6 +148,8 @@ export class OverlayFileSystem {
148
148
  Object.defineProperty(this, "capabilities", { writable: false, configurable: false });
149
149
  }
150
150
  confineExtraction(roots, options = {}) {
151
+ if (options.commitGuard !== undefined)
152
+ return Promise.reject(new FsError("ENOTSUP"));
151
153
  return this.run(options, async () => this.publication.confine(roots, options, (options, operation) => this.run(options, operation, false), this, this.maxBufferBytes), false);
152
154
  }
153
155
  createStagedFile(directory, name, content, options) {
@@ -1,3 +1,4 @@
1
+ import type { ExtractionOptions } from "../../contracts/filesystem.js";
1
2
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
2
3
  import type { AppendFileOptions, ByteSource, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ChmodOptions, FileStagingEntry, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent, OpenReadFileOptions, OpenResizeFileOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/index.js";
3
4
  export interface RealFileSystemOptions {
@@ -54,6 +55,7 @@ export declare class RealFileSystem implements FileSystem {
54
55
  readonly capabilities: FileSystemCapabilities;
55
56
  private readonly configuredRoot;
56
57
  private readonly renameNoReplace;
58
+ private extractionGuard?;
57
59
  private rootPromise;
58
60
  constructor(options: RealFileSystemOptions | string);
59
61
  private root;
@@ -62,6 +64,7 @@ export declare class RealFileSystem implements FileSystem {
62
64
  private path;
63
65
  private operation;
64
66
  private protectRoot;
67
+ confineTrustedExtraction(roots: readonly string[], options?: ExtractionOptions): Promise<FileSystem>;
65
68
  private stagingSnapshot;
66
69
  private expectStaging;
67
70
  private stagingMetadata;
@@ -1,7 +1,8 @@
1
+ import { plainDevicePathResolvers } from "../devices/plain-path.js";
1
2
  import { platform } from "#safe-fs-platform";
2
3
  import { snapshotStagingCreation } from "../staging-cleanup.js";
3
4
  import { snapshotConditionalChmod } from "../conditional-chmod.js";
4
- import { runStagingGuard, snapshotDirectoryAncestry } from "../staging-ancestry.js";
5
+ import { directoryAncestryPaths, runStagingGuard, snapshotDirectoryAncestry } from "../staging-ancestry.js";
5
6
  import { constants } from "node:fs";
6
7
  import * as immediate from "node:fs";
7
8
  import * as native from "node:fs/promises";
@@ -110,6 +111,7 @@ export class RealFileSystem {
110
111
  });
111
112
  configuredRoot;
112
113
  renameNoReplace;
114
+ extractionGuard;
113
115
  rootPromise;
114
116
  constructor(options) {
115
117
  const root = typeof options === "string" ? options : options.root;
@@ -121,6 +123,30 @@ export class RealFileSystem {
121
123
  throw new FsError("ENOTSUP", { syscall: "root", message: "this backend requires a POSIX host" });
122
124
  }
123
125
  this.configuredRoot = root;
126
+ if (Object.getPrototypeOf(this) === RealFileSystem.prototype) {
127
+ plainDevicePathResolvers.set(this, async (path, options) => {
128
+ if (this.lstat !== realImplementation.lstat?.value || this.readlink !== realImplementation.readlink?.value)
129
+ return undefined;
130
+ try {
131
+ let target = await this.root(options);
132
+ const components = path.slice(1).split("/");
133
+ for (let index = 0; index < components.length; index++) {
134
+ options.signal?.throwIfAborted();
135
+ target = join(target, components[index]);
136
+ const stat = await native.lstat(target);
137
+ options.signal?.throwIfAborted();
138
+ if ((!stat.isFile() && !stat.isDirectory()) || (index < components.length - 1 && !stat.isDirectory()))
139
+ return undefined;
140
+ }
141
+ return path;
142
+ }
143
+ catch {
144
+ options.signal?.throwIfAborted();
145
+ // Missing entries, symlinks and other failures use generic routing.
146
+ return undefined;
147
+ }
148
+ });
149
+ }
124
150
  this.renameNoReplace = typeof options === "string" ? undefined : options.renameNoReplace;
125
151
  if (this.renameNoReplace !== undefined) {
126
152
  if (!platform.nativeFileSystem.atomicRename)
@@ -140,10 +166,15 @@ export class RealFileSystem {
140
166
  })();
141
167
  const root = await this.rootPromise;
142
168
  options.signal?.throwIfAborted();
143
- if (await native.realpath(root) !== root)
169
+ const [canonical, metadata] = await Promise.allSettled([native.realpath(root), native.stat(root)]);
170
+ if (canonical.status === "rejected")
171
+ throw canonical.reason;
172
+ if (canonical.value !== root)
144
173
  throw new FsError("EACCES");
145
174
  options.signal?.throwIfAborted();
146
- if (!(await native.stat(root)).isDirectory())
175
+ if (metadata.status === "rejected")
176
+ throw metadata.reason;
177
+ if (!metadata.value.isDirectory())
147
178
  throw new FsError("ENOTDIR");
148
179
  options.signal?.throwIfAborted();
149
180
  return root;
@@ -283,6 +314,57 @@ export class RealFileSystem {
283
314
  // These critical sections contain no await. They serialize with all JavaScript
284
315
  // callers, under this adapter's existing trusted-host (externally isolated tree)
285
316
  // boundary. They deliberately do not advertise the stronger atomic capabilities.
317
+ async confineTrustedExtraction(roots, options = {}) {
318
+ if (!platform.nativeFileSystem.trustedOwnedStaging || !roots.length)
319
+ throw new FsError("ENOTSUP");
320
+ const root = await this.root(options);
321
+ const retained = new Map();
322
+ const captured = [...roots];
323
+ const commitGuard = options.commitGuard;
324
+ for (const path of captured) {
325
+ for (const ancestor of directoryAncestryPaths(path)) {
326
+ const host = join(root, ancestor.slice(1));
327
+ const stat = this.stagingSnapshot(host);
328
+ if (stat?.type !== "directory")
329
+ throw new FsError("ENOTDIR", { path: ancestor });
330
+ this.expectStaging(host, stat, true);
331
+ retained.set(host, stat);
332
+ }
333
+ }
334
+ options.signal?.throwIfAborted();
335
+ const view = new RealFileSystem({ root });
336
+ Object.defineProperty(view, "capabilities", { value: Object.freeze({ ...view.capabilities, trustedStagingAncestry: true, guardedStagingPublication: true, synchronousDirectoryValidation: true }) });
337
+ view.extractionGuard = host => {
338
+ if (commitGuard)
339
+ runStagingGuard(commitGuard);
340
+ const path = `/${relative(root, host)}`;
341
+ if (!captured.some(base => base === "/" || path === base || path.startsWith(`${base}/`)))
342
+ throw new FsError("EPERM");
343
+ for (const [entry, stat] of retained)
344
+ view.expectStaging(entry, stat, true);
345
+ for (const ancestor of directoryAncestryPaths(dirname(path))) {
346
+ const entry = join(root, ancestor.slice(1));
347
+ if (view.stagingSnapshot(entry)?.type !== "directory")
348
+ throw new FsError("EAGAIN");
349
+ if (immediate.realpathSync(entry) !== entry)
350
+ throw new FsError("EAGAIN");
351
+ }
352
+ };
353
+ const reads = new Set(["access", "capabilitiesFor", "lstat", "stat", "readFile", "readStream", "openReadFile", "readdir", "readlink", "realpath", "prepareDirectoryAncestry"]);
354
+ const mutations = new Set(["prepareDirectory", "createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "link"]);
355
+ return new Proxy(view, {
356
+ get(target, property) {
357
+ if (property === "confineTrustedExtraction" || property === "confineExtraction")
358
+ return undefined;
359
+ const value = Reflect.get(target, property);
360
+ if (typeof value !== "function")
361
+ return value;
362
+ if (reads.has(String(property)) || mutations.has(String(property)))
363
+ return value.bind(target);
364
+ return () => { throw new FsError("ENOTSUP", { syscall: String(property) }); };
365
+ },
366
+ });
367
+ }
286
368
  stagingSnapshot(path) {
287
369
  try {
288
370
  return fileStat(immediate.lstatSync(path, { bigint: true }));
@@ -361,6 +443,7 @@ export class RealFileSystem {
361
443
  options.signal?.throwIfAborted();
362
444
  this.protectTerminal(directoryPath);
363
445
  this.expectStaging(dirname(directory), options.parent, true);
446
+ this.extractionGuard?.(directory);
364
447
  immediate.mkdirSync(directory, { mode: 0o700 });
365
448
  const file = join(directory, name);
366
449
  try {
@@ -417,8 +500,12 @@ export class RealFileSystem {
417
500
  throw new FsError("ENOTSUP", { path: destination });
418
501
  return this.operation("publishStagedFile", staging.file.path, options, async () => {
419
502
  options.signal?.throwIfAborted();
420
- if (options.ancestors !== undefined || options.commitGuard !== undefined)
503
+ if (!this.extractionGuard && (options.ancestors !== undefined || options.commitGuard !== undefined))
421
504
  throw new FsError("ENOTSUP");
505
+ const ancestors = options.ancestors === undefined ? undefined : snapshotDirectoryAncestry(options.ancestors);
506
+ if (ancestors && ancestors.at(-1).path !== dirname(destination))
507
+ throw new FsError("EINVAL");
508
+ const root = await this.root(options);
422
509
  const paths = await this.stagingPaths(staging, options);
423
510
  const target = await this.path(destination, { ...options, followFinal: false, missing: "final" });
424
511
  options.signal?.throwIfAborted();
@@ -432,6 +519,12 @@ export class RealFileSystem {
432
519
  throw new FsError("EINVAL");
433
520
  if (existing && existing.type !== "file")
434
521
  throw new FsError("EAGAIN");
522
+ if (ancestors)
523
+ for (const entry of ancestors)
524
+ this.expectStaging(join(root, entry.path.slice(1)), entry.stat, true);
525
+ if (options.commitGuard)
526
+ runStagingGuard(options.commitGuard);
527
+ this.extractionGuard?.(target);
435
528
  immediate.renameSync(paths.file, target);
436
529
  }, destination, true);
437
530
  }
@@ -446,6 +539,7 @@ export class RealFileSystem {
446
539
  const children = immediate.readdirSync(paths.directory);
447
540
  if (children.length !== (file ? 1 : 0) || file && children[0] !== basename(paths.file))
448
541
  throw new FsError("ENOTEMPTY");
542
+ this.extractionGuard?.(paths.directory);
449
543
  if (file)
450
544
  immediate.unlinkSync(paths.file);
451
545
  immediate.rmdirSync(paths.directory);
@@ -463,6 +557,7 @@ export class RealFileSystem {
463
557
  const existing = this.expectStaging(target, options.expected);
464
558
  if (existing && existing.type !== "file")
465
559
  throw new FsError("EAGAIN");
560
+ this.extractionGuard?.(target);
466
561
  const fd = immediate.openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), options.mode ?? 0o666);
467
562
  try {
468
563
  if (!options.append)
@@ -501,6 +596,7 @@ export class RealFileSystem {
501
596
  const file = this.expectStaging(target, options.expected);
502
597
  if (file?.type !== "file" || file.nlink !== 1)
503
598
  throw new FsError("EAGAIN");
599
+ this.extractionGuard?.(target);
504
600
  immediate.unlinkSync(target);
505
601
  });
506
602
  }
@@ -515,6 +611,7 @@ export class RealFileSystem {
515
611
  const existing = this.expectStaging(target, options.expected, true);
516
612
  if (existing && existing.type !== "directory")
517
613
  throw new FsError("EAGAIN");
614
+ this.extractionGuard?.(target);
518
615
  if (!existing)
519
616
  immediate.mkdirSync(target, { mode: options.mode ?? 0o777 });
520
617
  if (options.mode !== undefined)
@@ -853,7 +950,15 @@ export class RealFileSystem {
853
950
  const source = await this.path(existingPath, { ...options, followFinal: false });
854
951
  const destination = await this.path(newPath, { ...options, followFinal: false, missing: "final" });
855
952
  options.signal?.throwIfAborted();
856
- await native.link(source, destination);
953
+ if (this.extractionGuard) {
954
+ this.extractionGuard(source);
955
+ this.extractionGuard(destination);
956
+ if (this.stagingSnapshot(source)?.type !== "file")
957
+ throw new FsError("EAGAIN");
958
+ immediate.linkSync(source, destination);
959
+ }
960
+ else
961
+ await native.link(source, destination);
857
962
  }, newPath);
858
963
  }
859
964
  async chmod(path, mode, options = {}) {
@@ -20,7 +20,7 @@ export function retargetScopedFileSystem(scoped, charge, signal, cleanupCharge =
20
20
  const operations = new Set([
21
21
  "prepareDirectoryAncestry", "prepareStagingResolution", "publishStagedFileSet",
22
22
  "publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
23
- "confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
23
+ "confineExtraction", "confineTrustedExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
24
24
  "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir", "iterateDirectory",
25
25
  "readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "unlink", "stat", "symlink", "truncate", "utimes",
26
26
  "writeFile", "writeStream",
@@ -507,7 +507,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
507
507
  throw error;
508
508
  }
509
509
  }
510
- : property === "confineExtraction"
510
+ : (property === "confineExtraction" || property === "confineTrustedExtraction")
511
511
  ? async (...args) => scopeFileSystem(await dispatch(...args), charge, signal, cleanupCharge, options)
512
512
  : property === "open"
513
513
  ? async (path, options) => {