@openclaw/fs-safe 0.19.0 → 0.20.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/CHANGELOG.md +27 -0
- package/README.md +15 -5
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-zip-entry.d.ts +11 -11
- package/dist/archive-zip-entry.js +3 -35
- package/dist/archive-zip-integrity.d.ts +2 -2
- package/dist/archive-zip-integrity.js +2 -12
- package/dist/archive-zip-loader.d.ts +7 -3
- package/dist/archive-zip-loader.js +10 -9
- package/dist/archive-zip-preflight.d.ts +2 -1
- package/dist/archive-zip-preflight.js +16 -7
- package/dist/archive.js +17 -16
- package/dist/directory-receipt.js +5 -7
- package/dist/effective-uid.js +1 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -2
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-store.d.ts +4 -7
- package/dist/json-document-store.d.ts +4 -9
- package/dist/local-file-access.js +2 -5
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +6 -1
- package/dist/native-staged-symlink.d.ts +13 -0
- package/dist/native-staged-symlink.js +303 -0
- package/dist/owner-dacl-batch-worker.d.ts +1 -0
- package/dist/owner-dacl-batch-worker.js +54 -0
- package/dist/owner-dacl-batch.d.ts +5 -0
- package/dist/owner-dacl-batch.js +64 -0
- package/dist/owner-dacl.d.ts +2 -0
- package/dist/owner-dacl.js +3 -0
- package/dist/path.js +17 -1
- package/dist/permission-exec.js +3 -6
- package/dist/permissions-public.d.ts +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/pinned-mutation-admission.d.ts +0 -1
- package/dist/pinned-open.d.ts +0 -1
- package/dist/pinned-open.js +1 -2
- package/dist/publish-copy-stage.js +4 -0
- package/dist/read-opened-file.d.ts +2 -5
- package/dist/regular-file.js +3 -3
- package/dist/replace-file-copy-fallback.d.ts +1 -2
- package/dist/root-impl.js +0 -3
- package/dist/root-observed-path.d.ts +0 -1
- package/dist/root-observed-path.js +0 -3
- package/dist/root-path-observation.d.ts +4 -11
- package/dist/root-path.js +7 -10
- package/dist/root-write-admission.js +0 -2
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock.js +5 -3
- package/dist/staged-symlink-types.d.ts +49 -0
- package/dist/staged-symlink-types.js +1 -0
- package/dist/symlink-parents.js +58 -7
- package/dist/temp-target.js +4 -2
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/text-atomic.d.ts +2 -1
- package/dist/text-atomic.js +2 -0
- package/dist/trash.js +27 -1
- package/dist/walk.d.ts +2 -5
- package/dist/windows-owner.d.ts +0 -1
- package/dist/windows-owner.js +0 -1
- package/dist/windows-security-bridge.cs +6 -4
- package/dist/windows-security-bridge.ps1 +78 -3
- package/dist/windows-security-command.d.ts +8 -0
- package/dist/windows-security-command.js +66 -12
- package/dist/windows-security-facts.d.ts +3 -0
- package/dist/windows-security-facts.js +4 -0
- package/docs/advanced.md +3 -2
- package/docs/archive.md +8 -0
- package/docs/atomic.md +11 -3
- package/docs/contributing.md +30 -0
- package/docs/install.md +28 -0
- package/docs/native-helper.md +5 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/security-model.md +4 -1
- package/docs/sidecar-lock.md +2 -0
- package/docs/staged-symlink.md +123 -0
- package/docs/store.md +3 -1
- package/docs/testing.md +28 -0
- package/docs/writing.md +10 -0
- package/package.json +9 -9
package/dist/regular-file.js
CHANGED
|
@@ -208,10 +208,10 @@ function inspectAppendPath(filePath) {
|
|
|
208
208
|
}
|
|
209
209
|
function prepareRegularAppend(filePath, rejectSymlinkParents) {
|
|
210
210
|
if (rejectSymlinkParents === true) {
|
|
211
|
-
const
|
|
211
|
+
const unresolvedDir = path.dirname(filePath);
|
|
212
212
|
assertNoSymlinkParentsSync({
|
|
213
|
-
rootDir: path.parse(
|
|
214
|
-
targetPath:
|
|
213
|
+
rootDir: path.parse(resolvePathPreservingWindowsRoot(unresolvedDir)).root,
|
|
214
|
+
targetPath: unresolvedDir,
|
|
215
215
|
allowMissing: false,
|
|
216
216
|
allowRootChildSymlink: true,
|
|
217
217
|
requireDirectories: true,
|
|
@@ -9,9 +9,8 @@ type AsyncFallbackFs = {
|
|
|
9
9
|
lstat: typeof import("node:fs/promises").lstat;
|
|
10
10
|
open: typeof import("node:fs/promises").open;
|
|
11
11
|
rm: typeof import("node:fs/promises").rm;
|
|
12
|
-
unlink: typeof import("node:fs/promises").unlink;
|
|
13
12
|
};
|
|
14
|
-
type SyncFallbackFs = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "ftruncateSync" | "lstatSync" | "openSync" | "readSync" | "rmSync" | "
|
|
13
|
+
type SyncFallbackFs = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "ftruncateSync" | "lstatSync" | "openSync" | "readSync" | "rmSync" | "writeSync">;
|
|
15
14
|
export declare function assertDestinationHardlinkPolicy(fsModule: AsyncFallbackFs, dest: string, policy?: ReplaceFileDestinationHardlinkPolicy): Promise<void>;
|
|
16
15
|
export declare function assertDestinationHardlinkPolicySync(fsModule: SyncFallbackFs, dest: string, policy?: ReplaceFileDestinationHardlinkPolicy): void;
|
|
17
16
|
export declare function copyFallbackReplace(params: {
|
package/dist/root-impl.js
CHANGED
|
@@ -798,7 +798,6 @@ async function mkdirPathInRoot(root, params) {
|
|
|
798
798
|
const prepared = policy && resolved.relativePosix !== ""
|
|
799
799
|
? await preparePinnedWriteMutationAdmission({
|
|
800
800
|
rootReal: resolved.rootReal,
|
|
801
|
-
rootWithSep: ensureTrailingSep(resolved.rootReal),
|
|
802
801
|
rootIdentity: root.rootIdentity,
|
|
803
802
|
resolvedTargetPath: resolved.resolved,
|
|
804
803
|
originalPath: params.relativePath,
|
|
@@ -1045,10 +1044,8 @@ async function resolvePinnedRootPathInRoot(root, params) {
|
|
|
1045
1044
|
throw err;
|
|
1046
1045
|
throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
|
|
1047
1046
|
}
|
|
1048
|
-
const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
|
|
1049
1047
|
return {
|
|
1050
1048
|
rootReal: resolved.rootCanonicalPath,
|
|
1051
|
-
rootWithSep,
|
|
1052
1049
|
canonicalPath: resolved.canonicalPath,
|
|
1053
1050
|
};
|
|
1054
1051
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { type RootPathObservationKind, type RootPathObservationReceipt } from "./root-path.js";
|
|
2
2
|
import { type RootContext } from "./root-context.js";
|
|
3
3
|
export type PinnedObservedPath = {
|
|
4
|
-
rootReal: string;
|
|
5
4
|
resolved: string;
|
|
6
5
|
receipt?: RootPathObservationReceipt;
|
|
7
6
|
};
|
|
@@ -63,7 +63,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
|
|
|
63
63
|
// A receipt is emitted only for the straight traversal whose lexical and
|
|
64
64
|
// canonical cursors stayed identical and inside the checked boundary.
|
|
65
65
|
return {
|
|
66
|
-
rootReal: resolved.rootCanonicalPath,
|
|
67
66
|
resolved: resolved.canonicalPath,
|
|
68
67
|
receipt: observed.receipt,
|
|
69
68
|
};
|
|
@@ -71,7 +70,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
|
|
|
71
70
|
const relativeResolved = path.relative(resolved.rootCanonicalPath, resolved.canonicalPath);
|
|
72
71
|
if (relativeResolved === "" || relativeResolved === ".") {
|
|
73
72
|
return {
|
|
74
|
-
rootReal: resolved.rootCanonicalPath,
|
|
75
73
|
resolved: resolved.canonicalPath,
|
|
76
74
|
};
|
|
77
75
|
}
|
|
@@ -87,7 +85,6 @@ export async function resolvePinnedObservedPathInRoot(root, relativePath, kind)
|
|
|
87
85
|
if (!admittedCanonicalPath)
|
|
88
86
|
throw outsideWorkspaceError();
|
|
89
87
|
return {
|
|
90
|
-
rootReal: resolved.rootCanonicalPath,
|
|
91
88
|
resolved: admittedCanonicalPath.path,
|
|
92
89
|
};
|
|
93
90
|
}
|
|
@@ -7,14 +7,11 @@ export type RootPathObservationKind = "stat" | "directory";
|
|
|
7
7
|
export type RootPathDirectoryObservationGuard = DirectoryObservationGuard | NativeDirectoryObservationGuard;
|
|
8
8
|
export type RootPathTargetObservation = StatObservationReceipt | NativeDirectoryObservationGuard;
|
|
9
9
|
/** An exact receipt owned by one stat/list operation. Never cache it. */
|
|
10
|
-
export type RootPathObservationReceipt = {
|
|
11
|
-
kind: RootPathObservationKind;
|
|
12
|
-
rootGuard: DirectoryObservationGuard;
|
|
10
|
+
export type RootPathObservationReceipt = Pick<RootPathObservationRequest & {
|
|
13
11
|
directoryGuard: RootPathDirectoryObservationGuard;
|
|
14
|
-
directoryObserver?: NativeDirectoryObservationBackend;
|
|
15
12
|
targetPath: string;
|
|
16
13
|
target: RootPathTargetObservation;
|
|
17
|
-
}
|
|
14
|
+
}, "kind" | "rootGuard" | "directoryGuard" | "directoryObserver" | "targetPath" | "target">;
|
|
18
15
|
/** A failed initial target lookup must not discard its already admitted parent. */
|
|
19
16
|
export type RootPathParentObservationReceipt = Omit<RootPathObservationReceipt, "kind" | "target"> & {
|
|
20
17
|
kind: "stat-parent";
|
|
@@ -24,16 +21,12 @@ export type RootPathObservationRequest = {
|
|
|
24
21
|
rootGuard: DirectoryObservationGuard;
|
|
25
22
|
directoryObserver?: NativeDirectoryObservationBackend;
|
|
26
23
|
};
|
|
27
|
-
export type RootPathTraversalObservation = {
|
|
24
|
+
export type RootPathTraversalObservation = Omit<Partial<RootPathObservationReceipt> & {
|
|
28
25
|
enabled: boolean;
|
|
29
26
|
request: RootPathObservationRequest;
|
|
30
27
|
targetIndex: number;
|
|
31
28
|
directoryIndex: number;
|
|
32
|
-
|
|
33
|
-
directoryObserver?: NativeDirectoryObservationBackend;
|
|
34
|
-
targetPath?: string;
|
|
35
|
-
target?: RootPathTargetObservation;
|
|
36
|
-
};
|
|
29
|
+
}, "kind" | "rootGuard">;
|
|
37
30
|
export type RootPathObservedTraversalEntry = StatObservationReceipt | {
|
|
38
31
|
stat: fs.Stats | BigIntStats;
|
|
39
32
|
identity?: undefined;
|
package/dist/root-path.js
CHANGED
|
@@ -214,8 +214,8 @@ function finalizeLexicalResolution(context, kind) {
|
|
|
214
214
|
rootPath: context.rootPath,
|
|
215
215
|
rootCanonicalPath: context.rootCanonicalPath,
|
|
216
216
|
relativePath: relativeInsideRoot(context.rootCanonicalPath, context.state.canonicalCursor),
|
|
217
|
-
exists: kind
|
|
218
|
-
kind
|
|
217
|
+
exists: kind !== "missing",
|
|
218
|
+
kind,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
function applyResolvedSymlinkHop(context, linkCanonical) {
|
|
@@ -374,12 +374,9 @@ function traverseRootPath(params, { mode = "native", observation: observationOut
|
|
|
374
374
|
const completeObservation = observation?.enabled === true && observation.directoryGuard !== undefined &&
|
|
375
375
|
observation.targetPath === state.canonicalCursor && observation.target !== undefined;
|
|
376
376
|
const kind = completeObservation
|
|
377
|
-
?
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
? "directory"
|
|
381
|
-
: toResolvedKind(observation.target.stat),
|
|
382
|
-
}
|
|
377
|
+
? isNativeDirectoryObservationGuard(observation.target)
|
|
378
|
+
? "directory"
|
|
379
|
+
: toResolvedKind(observation.target.stat)
|
|
383
380
|
: getPathKindSync(state.canonicalCursor, state.preserveFinalSymlink);
|
|
384
381
|
if (completeObservation && observationOutput) {
|
|
385
382
|
observationOutput.receipt = {
|
|
@@ -399,11 +396,11 @@ function getPathKindSync(absolutePath, preserveFinalSymlink) {
|
|
|
399
396
|
const stat = preserveFinalSymlink
|
|
400
397
|
? fs.lstatSync(operationPath)
|
|
401
398
|
: fs.statSync(operationPath);
|
|
402
|
-
return
|
|
399
|
+
return toResolvedKind(stat);
|
|
403
400
|
}
|
|
404
401
|
catch (error) {
|
|
405
402
|
if (isNotFoundPathError(error)) {
|
|
406
|
-
return
|
|
403
|
+
return "missing";
|
|
407
404
|
}
|
|
408
405
|
throw error;
|
|
409
406
|
}
|
|
@@ -201,7 +201,6 @@ export async function resolveGuardedWriteTargetInRoot(root, params) {
|
|
|
201
201
|
const relativeParent = path.relative(resolvedPath.rootReal, path.dirname(resolvedPath.resolved));
|
|
202
202
|
const prepared = await preparePinnedWriteMutationAdmission({
|
|
203
203
|
rootReal: resolvedPath.rootReal,
|
|
204
|
-
rootWithSep: resolvedPath.rootWithSep,
|
|
205
204
|
rootIdentity: root.rootIdentity,
|
|
206
205
|
resolvedTargetPath: resolvedPath.resolved,
|
|
207
206
|
originalPath: params.relativePath,
|
|
@@ -288,7 +287,6 @@ export async function resolvePinnedWriteTargetInRoot(root, relativePath, request
|
|
|
288
287
|
if (policy) {
|
|
289
288
|
({ relativeParentPath, mutationAdmission } = await preparePinnedWriteMutationAdmission({
|
|
290
289
|
rootReal,
|
|
291
|
-
rootWithSep,
|
|
292
290
|
rootIdentity: root.rootIdentity,
|
|
293
291
|
resolvedTargetPath: resolved,
|
|
294
292
|
originalPath: relativePath,
|
|
@@ -7,5 +7,6 @@ export declare function assertNoDriveRelativePathSegments(value: string, label:
|
|
|
7
7
|
export declare function trimHyphenEdges(value: string): string;
|
|
8
8
|
export declare function isSafePathSegment(segment: string, options?: SafePathSegmentOptions): boolean;
|
|
9
9
|
export declare function assertSafePathSegment(segment: string, options?: SafePathSegmentOptions): string;
|
|
10
|
+
export declare function normalizeSafePathSegment(value: string): string;
|
|
10
11
|
export declare function sanitizeSafePathSegment(value: string): string | undefined;
|
|
11
12
|
export declare function assertSafePathPrefix(prefix: string, options?: SafePathSegmentOptions): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isWindowsReservedDeviceName } from "./device-path.js";
|
|
1
2
|
import { FsSafeError } from "./errors.js";
|
|
2
3
|
const SAFE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
3
4
|
const SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
@@ -34,6 +35,8 @@ export function isSafePathSegment(segment, options = {}) {
|
|
|
34
35
|
!segment.includes("/") &&
|
|
35
36
|
!segment.includes("\\") &&
|
|
36
37
|
!segment.includes("\0") &&
|
|
38
|
+
// Segments are portable identifiers: CON.json and CON.<pid>.tmp name devices on Windows.
|
|
39
|
+
!isWindowsReservedDeviceName(segment) &&
|
|
37
40
|
(options.allowDotPrefix === true || !segment.startsWith(".")) &&
|
|
38
41
|
(options.allowDotPrefix === true
|
|
39
42
|
? SAFE_DOT_PREFIX_PATH_SEGMENT_PATTERN.test(segment)
|
|
@@ -47,13 +50,16 @@ export function assertSafePathSegment(segment, options = {}) {
|
|
|
47
50
|
}
|
|
48
51
|
return segment;
|
|
49
52
|
}
|
|
50
|
-
export function
|
|
53
|
+
export function normalizeSafePathSegment(value) {
|
|
51
54
|
const sanitized = value
|
|
52
55
|
.trim()
|
|
53
56
|
.replace(/[\\/]+/g, "-")
|
|
54
57
|
.replace(/\0/g, "")
|
|
55
58
|
.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
56
|
-
|
|
59
|
+
return trimHyphenEdges(sanitized);
|
|
60
|
+
}
|
|
61
|
+
export function sanitizeSafePathSegment(value) {
|
|
62
|
+
const trimmed = normalizeSafePathSegment(value);
|
|
57
63
|
return isSafePathSegment(trimmed, { allowDotPrefix: true }) ? trimmed : undefined;
|
|
58
64
|
}
|
|
59
65
|
export function assertSafePathPrefix(prefix, options = {}) {
|
package/dist/secure-file.js
CHANGED
|
@@ -202,7 +202,8 @@ function inspectOpenedPermissions(stat, platform) {
|
|
|
202
202
|
groupReadable: isGroupReadable(bits),
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
|
-
async function assertSecurePermissions(options,
|
|
205
|
+
async function assertSecurePermissions(options, opened, fd) {
|
|
206
|
+
const { pathStat: stat, realPath, identity } = opened;
|
|
206
207
|
if (options.permissions?.allowInsecure) {
|
|
207
208
|
return undefined;
|
|
208
209
|
}
|
|
@@ -282,7 +283,7 @@ export async function readSecureFile(options) {
|
|
|
282
283
|
const opened = await openSecureHandle(options, maxBytes);
|
|
283
284
|
try {
|
|
284
285
|
assertTrustedDirs(options, opened.realPath);
|
|
285
|
-
const permissions = await assertSecurePermissions(options, opened
|
|
286
|
+
const permissions = await assertSecurePermissions(options, opened, opened.handle.fd);
|
|
286
287
|
const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, maxBytes);
|
|
287
288
|
const finalIdentity = inspectFileIdentitySync(() => fsSync.fstatSync(opened.handle.fd, { bigint: true }), opened.identity);
|
|
288
289
|
if (!finalIdentity.isFile()) {
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import { FsSafeError } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
4
4
|
import { removeSidecarLockIfUnchanged, sidecarLockSnapshotMatches, } from "./sidecar-lock-reclaim.js";
|
|
5
5
|
import { acquireSidecarLock } from "./sidecar-lock-acquire.js";
|
|
6
6
|
import { createHeldSidecarLockHandle, stopSidecarLockMonitoring } from "./sidecar-lock-handle.js";
|
|
@@ -61,7 +61,9 @@ function snapshotMatchesSync(lockPath, observed) {
|
|
|
61
61
|
(typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
|
|
62
62
|
fd = fsSync.openSync(lockPath, openFlags);
|
|
63
63
|
const openedStat = fsSync.fstatSync(fd);
|
|
64
|
-
|
|
64
|
+
// Token-owned files can have different descriptor/path identities on VirtioFS.
|
|
65
|
+
// Require a known descriptor identity without rejecting that supported drift.
|
|
66
|
+
if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
|
|
65
67
|
return false;
|
|
66
68
|
}
|
|
67
69
|
if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
|
|
@@ -69,7 +71,7 @@ function snapshotMatchesSync(lockPath, observed) {
|
|
|
69
71
|
}
|
|
70
72
|
const raw = fsSync.readFileSync(fd, "utf8");
|
|
71
73
|
const afterStat = fsSync.lstatSync(lockPath);
|
|
72
|
-
if (!afterStat.isFile() || !
|
|
74
|
+
if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
|
|
73
75
|
return false;
|
|
74
76
|
}
|
|
75
77
|
return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { StagedFileReceipt } from "./staged-file-types.js";
|
|
2
|
+
/** Caller-captured admission evidence, never a same-target ownership heuristic. */
|
|
3
|
+
export type StagedSymlinkExpected = Readonly<{
|
|
4
|
+
dev: bigint;
|
|
5
|
+
ino: bigint;
|
|
6
|
+
uid: number;
|
|
7
|
+
gid: number;
|
|
8
|
+
ctimeNs: bigint;
|
|
9
|
+
target: string;
|
|
10
|
+
}>;
|
|
11
|
+
export type StagedSymlinkReceipt = StagedFileReceipt & Readonly<{
|
|
12
|
+
target: string;
|
|
13
|
+
}>;
|
|
14
|
+
export type PublishedSymlinkReceipt = Readonly<{
|
|
15
|
+
status: "published";
|
|
16
|
+
staged: StagedSymlinkReceipt;
|
|
17
|
+
basename: string;
|
|
18
|
+
overwrite: false;
|
|
19
|
+
}>;
|
|
20
|
+
export type StagedSymlinkPublication = Readonly<{
|
|
21
|
+
status: "not-published";
|
|
22
|
+
}> | PublishedSymlinkReceipt | Readonly<{
|
|
23
|
+
status: "indeterminate";
|
|
24
|
+
basename: string;
|
|
25
|
+
overwrite: false;
|
|
26
|
+
}>;
|
|
27
|
+
export type StagedSymlinkRemoval = "removed" | "name-absent" | "preserved";
|
|
28
|
+
export type StagedSymlinkCleanupReceipt = Readonly<{
|
|
29
|
+
temporaryBasename: string;
|
|
30
|
+
publication: StagedSymlinkPublication;
|
|
31
|
+
status: StagedSymlinkRemoval | "failed" | "not-needed";
|
|
32
|
+
resources: "closed" | "close-failed";
|
|
33
|
+
}>;
|
|
34
|
+
export type StagedSymlinkFailureDetails = Readonly<{
|
|
35
|
+
phase: "prepare" | "publish" | "remove-published" | "cleanup";
|
|
36
|
+
publication: StagedSymlinkPublication;
|
|
37
|
+
cleanup?: StagedSymlinkCleanupReceipt;
|
|
38
|
+
}>;
|
|
39
|
+
export interface StagedSymlink extends AsyncDisposable {
|
|
40
|
+
readonly receipt: StagedSymlinkReceipt;
|
|
41
|
+
assertCurrent(): Promise<void>;
|
|
42
|
+
publish(basename: string): Promise<PublishedSymlinkReceipt>;
|
|
43
|
+
/** Check the published name against the still-retained original symlink. */
|
|
44
|
+
assertPublished(): Promise<void>;
|
|
45
|
+
/** Explicit recovery only. Never removes an observed foreign replacement. */
|
|
46
|
+
removePublished(): Promise<StagedSymlinkRemoval>;
|
|
47
|
+
/** Closes the owner; only an unattempted, still-owned stage is removed. */
|
|
48
|
+
cleanup(): Promise<StagedSymlinkCleanupReceipt>;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/symlink-parents.js
CHANGED
|
@@ -3,24 +3,46 @@ import path from "node:path";
|
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { hasNodeErrorCode, isPathRelativeEscape } from "./path.js";
|
|
5
5
|
import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
|
|
6
|
+
function outsideRootError(params, root) {
|
|
7
|
+
return new Error(`${params.messagePrefix ?? "Path"} must stay under ${root}.`);
|
|
8
|
+
}
|
|
9
|
+
function pathSegments(value) {
|
|
10
|
+
return value.split(process.platform === "win32" ? /[/\\]+/ : /\/+/)
|
|
11
|
+
.filter((segment) => segment.length > 0 && segment !== ".");
|
|
12
|
+
}
|
|
13
|
+
function rawTargetSegments(root, targetPath) {
|
|
14
|
+
// Resolve only the drive/root or cwd, never the caller's dotdot segments.
|
|
15
|
+
const targetRoot = path.parse(targetPath).root;
|
|
16
|
+
const base = resolvePathPreservingWindowsRoot(targetRoot || ".");
|
|
17
|
+
const absoluteTarget = `${base}${path.sep}${targetPath.slice(targetRoot.length)}`;
|
|
18
|
+
const rootSegments = pathSegments(root);
|
|
19
|
+
const targetSegments = pathSegments(absoluteTarget);
|
|
20
|
+
const fold = (segment) => process.platform === "win32" ? segment.toLowerCase() : segment;
|
|
21
|
+
if (rootSegments.some((segment, index) => fold(segment) !== fold(targetSegments[index] ?? ""))) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return targetSegments.slice(rootSegments.length);
|
|
25
|
+
}
|
|
6
26
|
function resolvePathWalk(params) {
|
|
7
27
|
const rawRootDir = params.rootDir;
|
|
8
28
|
assertNoWindowsPathAlias(rawRootDir, "filesystem", "root dir uses a Windows filesystem namespace alias");
|
|
9
29
|
const rawTargetPath = params.targetPath;
|
|
10
30
|
assertNoWindowsPathAlias(rawTargetPath, "filesystem", "target path uses a Windows filesystem namespace alias");
|
|
11
31
|
const root = resolvePathPreservingWindowsRoot(rawRootDir);
|
|
12
|
-
const
|
|
13
|
-
const relative = path.relative(root,
|
|
32
|
+
const lexicalTarget = resolvePathPreservingWindowsRoot(rawTargetPath);
|
|
33
|
+
const relative = path.relative(root, lexicalTarget);
|
|
14
34
|
if (isPathRelativeEscape(relative)) {
|
|
15
35
|
if (params.allowOutsideRoot) {
|
|
16
36
|
return null;
|
|
17
37
|
}
|
|
18
|
-
throw
|
|
38
|
+
throw outsideRootError(params, root);
|
|
19
39
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
const segments = rawTargetSegments(root, rawTargetPath);
|
|
41
|
+
// A spelling outside the root must not fall back to a normalized walk that
|
|
42
|
+
// could erase a symlink before the filesystem receives the original path.
|
|
43
|
+
if (!segments)
|
|
44
|
+
throw outsideRootError(params, root);
|
|
45
|
+
return { root, segments };
|
|
24
46
|
}
|
|
25
47
|
function formatUnsafePath(params, current) {
|
|
26
48
|
return `${params.messagePrefix ?? "Path"} must not traverse symlinked directory: ${current}`;
|
|
@@ -28,18 +50,39 @@ function formatUnsafePath(params, current) {
|
|
|
28
50
|
export async function assertNoSymlinkParents(params) {
|
|
29
51
|
assertNoSymlinkParentsSync(params);
|
|
30
52
|
}
|
|
53
|
+
function isFilesystemRoot(root) {
|
|
54
|
+
return root === path.parse(root).root;
|
|
55
|
+
}
|
|
31
56
|
export function assertNoSymlinkParentsSync(params) {
|
|
32
57
|
const walk = resolvePathWalk(params);
|
|
33
58
|
if (!walk) {
|
|
34
59
|
return;
|
|
35
60
|
}
|
|
36
61
|
let current = walk.root;
|
|
62
|
+
// `..` may only undo a real directory this walk already lstat'd.
|
|
63
|
+
const walked = [];
|
|
37
64
|
for (const [index, segment] of walk.segments.entries()) {
|
|
65
|
+
if (segment === "..") {
|
|
66
|
+
const top = walked[walked.length - 1];
|
|
67
|
+
if (top?.kind === "dir") {
|
|
68
|
+
walked.pop();
|
|
69
|
+
current = walked[walked.length - 1]?.path ?? walk.root;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (top?.kind === "symlink") {
|
|
73
|
+
throw new Error(formatUnsafePath(params, top.path));
|
|
74
|
+
}
|
|
75
|
+
if (!isFilesystemRoot(walk.root)) {
|
|
76
|
+
throw outsideRootError(params, walk.root);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
38
80
|
current = path.join(current, segment);
|
|
39
81
|
try {
|
|
40
82
|
const stat = fsSync.lstatSync(current);
|
|
41
83
|
if (stat.isSymbolicLink()) {
|
|
42
84
|
if (params.allowRootChildSymlink && path.dirname(current) === walk.root) {
|
|
85
|
+
walked.push({ path: current, kind: "symlink" });
|
|
43
86
|
continue;
|
|
44
87
|
}
|
|
45
88
|
throw new Error(formatUnsafePath(params, current));
|
|
@@ -47,9 +90,17 @@ export function assertNoSymlinkParentsSync(params) {
|
|
|
47
90
|
if ((params.requireDirectories || index < walk.segments.length - 1) && !stat.isDirectory()) {
|
|
48
91
|
throw new FsSafeError("not-file", `${params.messagePrefix ?? "Path"} must traverse directories: ${current}`);
|
|
49
92
|
}
|
|
93
|
+
if (stat.isDirectory()) {
|
|
94
|
+
walked.push({ path: current, kind: "dir" });
|
|
95
|
+
}
|
|
50
96
|
}
|
|
51
97
|
catch (err) {
|
|
52
98
|
if (hasNodeErrorCode(err, "ENOENT") && params.allowMissing !== false) {
|
|
99
|
+
// Win32 can cancel a nonexistent component before filesystem lookup.
|
|
100
|
+
// Returning early would leave later, reachable symlinks unchecked.
|
|
101
|
+
if (process.platform === "win32" && walk.segments.slice(index + 1).includes("..")) {
|
|
102
|
+
throw new FsSafeError("invalid-path", `${params.messagePrefix ?? "Path"} must not cancel a missing directory: ${current}`);
|
|
103
|
+
}
|
|
53
104
|
return;
|
|
54
105
|
}
|
|
55
106
|
throw err;
|
package/dist/temp-target.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { suffixWindowsReservedDeviceName } from "./filename.js";
|
|
6
6
|
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
7
|
-
import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
|
|
7
|
+
import { assertSafePathSegment, normalizeSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
|
|
8
8
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
9
9
|
import { hasNodeErrorCode } from "./path.js";
|
|
10
10
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
@@ -60,7 +60,9 @@ function sanitizeExtension(extension) {
|
|
|
60
60
|
return token ? `.${token}` : "";
|
|
61
61
|
}
|
|
62
62
|
export function sanitizeTempFileName(fileName) {
|
|
63
|
-
|
|
63
|
+
// Suffix reserved stems before admission so CON.txt stays CON_.txt.
|
|
64
|
+
const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
|
|
65
|
+
return sanitizeSafePathSegment(suffixed) ?? "download.bin";
|
|
64
66
|
}
|
|
65
67
|
export function buildRandomTempFilePath(params) {
|
|
66
68
|
const rootDir = resolveTempRoot(params.rootDir);
|
|
@@ -14,13 +14,6 @@ function isNativeCleanupBinding(binding) {
|
|
|
14
14
|
typeof binding.removeOwnedTreeSync === "function" &&
|
|
15
15
|
typeof binding.ownedTreeRemovalAvailable === "function";
|
|
16
16
|
}
|
|
17
|
-
function nativeRemovalError(result) {
|
|
18
|
-
if (!result.errorCode)
|
|
19
|
-
return undefined;
|
|
20
|
-
return Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
|
|
21
|
-
code: result.errorCode,
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
17
|
export class TempWorkspaceCleanupCapability {
|
|
25
18
|
binding;
|
|
26
19
|
parent;
|
|
@@ -309,8 +302,10 @@ export class TempWorkspaceCleanupOwner {
|
|
|
309
302
|
this.#capability.assertCurrent();
|
|
310
303
|
}
|
|
311
304
|
#mapRemoval(result) {
|
|
312
|
-
|
|
313
|
-
|
|
305
|
+
if (result.errorCode) {
|
|
306
|
+
const error = Object.assign(new Error(result.errorMessage ?? "native owned-tree cleanup failed"), {
|
|
307
|
+
code: result.errorCode,
|
|
308
|
+
});
|
|
314
309
|
if (error.code === "path-mismatch")
|
|
315
310
|
return "indeterminate";
|
|
316
311
|
throw error;
|
package/dist/text-atomic.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ReplaceFileAtomicOptions } from "./replace-file.js";
|
|
2
|
+
export type WriteTextAtomicOptions = Pick<ReplaceFileAtomicOptions, "beforeRename" | "tempPrefix"> & {
|
|
2
3
|
mode?: number;
|
|
3
4
|
dirMode?: number;
|
|
4
5
|
trailingNewline?: boolean;
|
package/dist/text-atomic.js
CHANGED
package/dist/trash.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertSyncDirectoryGuard, createSyncDirectoryGuard } from "./directory-guard.js";
|
|
4
5
|
import { sameFileIdentity } from "./file-identity.js";
|
|
5
6
|
import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
|
|
6
7
|
import { realpathSync } from "./realpath.js";
|
|
@@ -72,6 +73,22 @@ function resolveTrashTargetPath(targetPath) {
|
|
|
72
73
|
assertNoTrashPathAlias(resolvedPath, "target path");
|
|
73
74
|
return { path: resolvedPath, resolved: true };
|
|
74
75
|
}
|
|
76
|
+
function resolveTrashEntryParent(lexicalTarget, targetPath) {
|
|
77
|
+
const lexicalParent = path.dirname(lexicalTarget);
|
|
78
|
+
assertNoTrashPathAlias(lexicalParent, "target path");
|
|
79
|
+
let realParent;
|
|
80
|
+
try {
|
|
81
|
+
// The renamed name lives in this parent. rename follows intermediate
|
|
82
|
+
// symlinks, so a lexical parent inside an allowed root is not enough.
|
|
83
|
+
realParent = realpathSync.native(lexicalParent);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
|
|
87
|
+
}
|
|
88
|
+
const resolvedParent = path.resolve(realParent);
|
|
89
|
+
assertNoTrashPathAlias(resolvedParent, "target path");
|
|
90
|
+
return resolvedParent;
|
|
91
|
+
}
|
|
75
92
|
function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
76
93
|
assertNoTrashPathAlias(targetPath, "target path");
|
|
77
94
|
const lexicalTarget = path.resolve(targetPath);
|
|
@@ -79,11 +96,19 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
|
79
96
|
const stat = fs.lstatSync(lexicalTarget);
|
|
80
97
|
const resolvedTarget = resolveTrashTargetPath(targetPath);
|
|
81
98
|
const resolvedTargetPath = resolvedTarget.path;
|
|
82
|
-
const
|
|
99
|
+
const parent = createSyncDirectoryGuard(path.dirname(lexicalTarget));
|
|
100
|
+
// Admit the directory entry only when its parent really stays inside an
|
|
101
|
+
// allowed root. Do not admit it because the symlink target is inside.
|
|
102
|
+
const resolvedParent = resolveTrashEntryParent(lexicalTarget, targetPath);
|
|
103
|
+
const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => isSameOrChildPath(resolvedParent, root));
|
|
83
104
|
if (!isAllowed) {
|
|
84
105
|
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
|
|
85
106
|
}
|
|
107
|
+
// Sync and native realpath can use different Windows short-name spellings.
|
|
108
|
+
// Recheck the retained guard around native containment instead of comparing them.
|
|
109
|
+
assertSyncDirectoryGuard(parent);
|
|
86
110
|
return {
|
|
111
|
+
parent,
|
|
87
112
|
path: lexicalTarget,
|
|
88
113
|
realPath: resolvedTargetPath,
|
|
89
114
|
realPathResolved: resolvedTarget.resolved,
|
|
@@ -91,6 +116,7 @@ function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
|
91
116
|
};
|
|
92
117
|
}
|
|
93
118
|
function assertTrashTargetGuard(guard) {
|
|
119
|
+
assertSyncDirectoryGuard(guard.parent);
|
|
94
120
|
const stat = fs.lstatSync(guard.path);
|
|
95
121
|
if (!sameFileIdentity(stat, guard.stat)) {
|
|
96
122
|
throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
|
package/dist/walk.d.ts
CHANGED
|
@@ -20,12 +20,9 @@ export type AsyncWalkDirectoryOptions = Omit<WalkDirectoryOptions, "include" | "
|
|
|
20
20
|
include?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
|
|
21
21
|
descend?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
|
|
22
22
|
};
|
|
23
|
-
export type WalkDirectoryFailure = {
|
|
24
|
-
path: string;
|
|
25
|
-
relativePath: string;
|
|
26
|
-
depth: number;
|
|
23
|
+
export type WalkDirectoryFailure = Pick<WalkDirectoryEntry & {
|
|
27
24
|
error: unknown;
|
|
28
|
-
}
|
|
25
|
+
}, "path" | "relativePath" | "depth" | "error">;
|
|
29
26
|
export type WalkDirectoryResult = {
|
|
30
27
|
entries: WalkDirectoryEntry[];
|
|
31
28
|
scannedEntryCount: number;
|
package/dist/windows-owner.d.ts
CHANGED
package/dist/windows-owner.js
CHANGED
|
@@ -312,6 +312,10 @@ public static partial class FsSafeWindowsBridge {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
|
+
static object InspectPath(string path) {
|
|
316
|
+
// Raw reporting retains ACL facts when locality is unknown; admission remains strict.
|
|
317
|
+
using(var handle=Open(path,0x00020080,false)) return Security(handle,false);
|
|
318
|
+
}
|
|
315
319
|
public static object Execute(string operation,string path) {
|
|
316
320
|
try {
|
|
317
321
|
object result;
|
|
@@ -323,10 +327,8 @@ public static partial class FsSafeWindowsBridge {
|
|
|
323
327
|
Require(!handle.IsInvalid,"EBADF","inherited file handle is unavailable");
|
|
324
328
|
result=Row("identity",Identity(handle),"security",Security(handle));
|
|
325
329
|
}
|
|
326
|
-
} else if(operation=="path")
|
|
327
|
-
|
|
328
|
-
using(var handle=Open(path,0x00020080,false)) result=Security(handle,false);
|
|
329
|
-
} else throw new Failure("EINVAL","unknown Windows security operation");
|
|
330
|
+
} else if(operation=="path") result=InspectPath(path);
|
|
331
|
+
else throw new Failure("EINVAL","unknown Windows security operation");
|
|
330
332
|
return Row("ok",true,"result",result);
|
|
331
333
|
} catch(Failure error) { return Row("ok",false,"code",error.Code,"message",error.Message); }
|
|
332
334
|
catch(Exception) { return Row("ok",false,"code","EIO","message","Windows security descriptor processing failed"); }
|