@openclaw/fs-safe 0.18.2 → 0.19.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 +20 -0
- package/dist/archive-durability.js +1 -1
- package/dist/archive-merge.js +1 -1
- package/dist/archive-staging.js +3 -1
- package/dist/file-lock-sync-root-held.d.ts +4 -11
- package/dist/file-lock-sync-root-io.d.ts +1 -4
- package/dist/file-lock-sync-root.d.ts +2 -4
- package/dist/file-store-boundary.d.ts +3 -7
- package/dist/file-store-boundary.js +7 -10
- package/dist/file-store-prune.js +3 -2
- package/dist/file-store.js +19 -21
- package/dist/guest-native-python.js +23 -31
- package/dist/guest.js +21 -12
- package/dist/json-durable-queue.js +2 -6
- package/dist/local-file-descriptor.d.ts +2 -5
- package/dist/local-roots.d.ts +2 -7
- package/dist/native-binding.d.ts +12 -13
- package/dist/pinned-mutation-shared-route.d.ts +2 -8
- package/dist/root-context.js +3 -2
- package/dist/root-move-noreplace.d.ts +2 -7
- package/dist/root-paths.d.ts +2 -6
- package/dist/root-remove-identity.d.ts +1 -3
- package/dist/root-walk.js +8 -3
- package/dist/root-write-admission.js +1 -4
- package/dist/root-write-complete-parent.d.ts +2 -0
- package/dist/root-write-complete-parent.js +1 -1
- package/dist/secret-file.d.ts +6 -2
- package/dist/secret-file.js +1 -0
- package/dist/secure-file-windows.js +1 -5
- package/dist/sidecar-lock-admission-parser.d.ts +1 -2
- package/dist/sidecar-lock-handle.d.ts +2 -8
- package/dist/sidecar-lock-policy.d.ts +2 -7
- package/dist/sidecar-lock-stale-admission.d.ts +1 -5
- package/dist/test-hooks.d.ts +1 -1
- package/dist/windows-security-command.js +1 -4
- package/dist/windows-security-facts.d.ts +1 -0
- package/dist/windows-security-facts.js +2 -2
- package/docs/archive.md +2 -0
- package/docs/copy.md +2 -0
- package/docs/file-store.md +5 -0
- package/docs/guest.md +7 -1
- package/docs/native-helper.md +6 -0
- package/docs/root.md +10 -1
- package/docs/secret-file.md +10 -0
- package/docs/walk.md +12 -0
- package/docs/writing.md +11 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.19.0 - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
|
|
9
|
+
- **Strict secret-file durability:** `createSecretFileAtomic()` accepts `durable: "file"` to require every file flush to succeed, including on `EPERM`; parent-directory synchronization remains best effort. ([#644](https://github.com/openclaw/fs-safe/pull/644))
|
|
10
|
+
- **Literal `~` names:** keep FileStore keys, absolute Root reads, discovered walk entries, and ZIP/TAR entries literal instead of treating them as home-directory shorthand. Reads, writes, removal, pruning, extraction, and durable publication select the intended entry. ([#633](https://github.com/openclaw/fs-safe/pull/633))
|
|
11
|
+
- **Native Unix descriptor safety:** reject negative descriptors in low-level root, query, hash, copy, clone, staging, and cleanup calls without Rust panics or working-directory operations. Modern macOS beneath opens report `EBADF` for negative roots instead of `EIO`. ([#640](https://github.com/openclaw/fs-safe/pull/640), [#643](https://github.com/openclaw/fs-safe/pull/643), [#646](https://github.com/openclaw/fs-safe/pull/646))
|
|
12
|
+
|
|
13
|
+
### Fixes
|
|
14
|
+
|
|
15
|
+
- **Guest directory permissions:** cross-device moves preserve mode `000`, including nested directories, without changing other modes' umask behavior. Restore top-level permissions through the retained directory descriptor and preserve published entries on failure. Thanks @SebTardif. ([#616](https://github.com/openclaw/fs-safe/pull/616))
|
|
16
|
+
- **ReFS clone cleanup:** remove ordinary partial output when Windows rejects the ignore-readonly deletion flag. Keep readonly attributes intact and include cleanup failures in the original clone error; readonly files or other processes' open handles can still leave output behind. ([#648](https://github.com/openclaw/fs-safe/pull/648))
|
|
17
|
+
|
|
18
|
+
### Compatibility and documentation
|
|
19
|
+
|
|
20
|
+
- Secret creation's default and boolean durability options retain their behavior, and `writeSecretFileAtomic()` remains boolean-only. Strict file synchronization preserves existing publication and cleanup semantics: a failure after publication can leave a complete file present. ([#644](https://github.com/openclaw/fs-safe/pull/644))
|
|
21
|
+
- `Root.walk("~")` and `Root.walk("~/dir")` expand home shorthand when iteration starts and return admitted canonical paths relative to the Root. Use `./~/dir` for a literal tilde directory, and prefix returned entry paths with `./` when passing them to another Root method. Relative Root `~/name` inputs still expand home; FileStore keys do not. ([#633](https://github.com/openclaw/fs-safe/pull/633))
|
|
22
|
+
- Guest moves still require OS permission to read mode-000 source directories; they do not widen source permissions. If permission restoration fails after publication, the source and published copy remain for caller reconciliation. ([#616](https://github.com/openclaw/fs-safe/pull/616))
|
|
23
|
+
- Clarify that `Root.append()` and `openWritable()` creation modes remain subject to the process umask and do not chmod existing files. This documents existing behavior. ([#636](https://github.com/openclaw/fs-safe/pull/636))
|
|
24
|
+
|
|
5
25
|
## 0.18.2 - 2026-09-22
|
|
6
26
|
|
|
7
27
|
### Fixes
|
|
@@ -78,7 +78,7 @@ export async function finalizeArchivePublication(params) {
|
|
|
78
78
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
79
79
|
try {
|
|
80
80
|
await assertGuards(file.guards);
|
|
81
|
-
const opened = __addDisposableResource(env_1, await params.targetRoot.open(file.relativePath
|
|
81
|
+
const opened = __addDisposableResource(env_1, await params.targetRoot.open(`./${file.relativePath}`, { hardlinks: "reject", symlinks: "reject" })
|
|
82
82
|
.catch((error) => {
|
|
83
83
|
if (error instanceof FsSafeError && (error.code === "hardlink" || error.code === "path-alias")) {
|
|
84
84
|
throw createArchiveSymlinkTraversalError(file.relativePath);
|
package/dist/archive-merge.js
CHANGED
|
@@ -250,7 +250,7 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
|
|
|
250
250
|
}
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
|
-
await targetRoot.copyIn(relPath
|
|
253
|
+
await targetRoot.copyIn(`./${relPath}`, sourcePath, options);
|
|
254
254
|
check();
|
|
255
255
|
await assertGuards();
|
|
256
256
|
await assertResolvedInsideDestination({
|
package/dist/archive-staging.js
CHANGED
|
@@ -133,7 +133,9 @@ export async function assertResolvedInsideDestination(params) {
|
|
|
133
133
|
}
|
|
134
134
|
async function mkdirArchiveOutput(params) {
|
|
135
135
|
try {
|
|
136
|
-
|
|
136
|
+
const relativePath = params.relativePath;
|
|
137
|
+
// Archive names are literal; retain Root.mkdir's admission without home expansion.
|
|
138
|
+
await params.targetRoot.mkdir(relativePath === "~" || relativePath.startsWith("~/") ? `./${relativePath}` : relativePath);
|
|
137
139
|
}
|
|
138
140
|
catch (error) {
|
|
139
141
|
if (error instanceof FsSafeError) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type SyncHeldLock } from "./file-lock-sync-admission.js";
|
|
2
|
+
import type { SidecarLockOptionFields } from "./sidecar-lock-types.js";
|
|
1
3
|
import type { FileLockSyncHandle } from "./file-lock-sync.js";
|
|
2
|
-
import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
|
|
3
4
|
import { type FileLockSyncRootAuthority, type FileLockSyncRootPath } from "./file-lock-sync-root.js";
|
|
4
5
|
import { type FileLockSyncRootFileReceipt, type FileLockSyncRootSnapshot } from "./file-lock-sync-root-io.js";
|
|
5
6
|
type RootSyncHeldLockReleaseState = "active" | "releasing" | "exit-cleaning" | "released";
|
|
@@ -7,22 +8,14 @@ type RootSyncHeldLockHandleDisposition = {
|
|
|
7
8
|
held: RootSyncHeldLock;
|
|
8
9
|
released: boolean;
|
|
9
10
|
};
|
|
10
|
-
export type RootSyncHeldLock = {
|
|
11
|
+
export type RootSyncHeldLock = SidecarLockOptionFields<SyncHeldLock & {
|
|
11
12
|
deferredExitReleases?: Set<RootSyncHeldLockHandleDisposition>;
|
|
12
|
-
fd: number | undefined;
|
|
13
|
-
lockPath: string;
|
|
14
|
-
normalizedTargetPath: string;
|
|
15
|
-
parsePayload?: (raw: string) => unknown;
|
|
16
|
-
refCount: number;
|
|
17
|
-
reentrantOwner?: string;
|
|
18
13
|
releaseState: RootSyncHeldLockReleaseState;
|
|
19
14
|
revision: number;
|
|
20
15
|
rootAuthority: FileLockSyncRootAuthority;
|
|
21
16
|
rootPath: FileLockSyncRootPath;
|
|
22
17
|
rootReceipt: FileLockSyncRootFileReceipt;
|
|
23
|
-
|
|
24
|
-
timer?: NodeJS.Timeout;
|
|
25
|
-
};
|
|
18
|
+
}>;
|
|
26
19
|
export declare function readRootSidecarSnapshotSync(rootPath: FileLockSyncRootPath, parsePayload?: (raw: string) => unknown, onOpenFailure?: (error: unknown) => void, expectedReceipt?: FileLockSyncRootFileReceipt): FileLockSyncRootSnapshot | null;
|
|
27
20
|
export declare function getRootSyncHeldLocks(): Map<string, RootSyncHeldLock>;
|
|
28
21
|
export declare function ensureRootSyncExitCleanupRegistered(): void;
|
|
@@ -14,10 +14,7 @@ export type FileLockSyncRootFileReceipt = Readonly<{
|
|
|
14
14
|
identity: ExactIdentity;
|
|
15
15
|
parent: DirectoryReceipt;
|
|
16
16
|
}>;
|
|
17
|
-
export type FileLockSyncRootDirectoryReceipt =
|
|
18
|
-
identity: ExactIdentity;
|
|
19
|
-
parent: DirectoryReceipt;
|
|
20
|
-
}>;
|
|
17
|
+
export type FileLockSyncRootDirectoryReceipt = FileLockSyncRootFileReceipt;
|
|
21
18
|
export type FileLockSyncRootDiskSnapshot = {
|
|
22
19
|
ownershipToken?: never;
|
|
23
20
|
payload: unknown;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DenyMutationPolicy } from "./deny-mutations.js";
|
|
1
2
|
import { type RootContext } from "./root-context.js";
|
|
2
3
|
import type { Root } from "./root-impl.js";
|
|
3
4
|
import type { RootDefaults } from "./root-options.js";
|
|
@@ -7,10 +8,7 @@ export type FileLockSyncRootAuthority = Readonly<{
|
|
|
7
8
|
adapter: object;
|
|
8
9
|
context: RootContext;
|
|
9
10
|
assertBeforeMutation?: () => void;
|
|
10
|
-
denyMutations?: Readonly<
|
|
11
|
-
paths?: readonly string[];
|
|
12
|
-
prefixes?: readonly string[];
|
|
13
|
-
}>;
|
|
11
|
+
denyMutations?: Readonly<DenyMutationPolicy>;
|
|
14
12
|
hardlinks?: RootDefaults["hardlinks"];
|
|
15
13
|
mutationPolicy: RootPathResolutionPolicy;
|
|
16
14
|
readPolicy: RootPathResolutionPolicy;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Readable } from "node:stream";
|
|
2
|
-
import { type SyncStoreDirectoryReceipt } from "./file-store-sync-directory.js";
|
|
2
|
+
import { ensureSyncStoreDirectory, type SyncStoreDirectoryReceipt } from "./file-store-sync-directory.js";
|
|
3
3
|
import { type Root } from "./root.js";
|
|
4
4
|
import { prepareSecretFileWrite } from "./secret-file.js";
|
|
5
5
|
export type SyncParentGuard = SyncStoreDirectoryReceipt;
|
|
@@ -23,12 +23,8 @@ export declare function ensureParentSync(params: {
|
|
|
23
23
|
filePath: string;
|
|
24
24
|
mode: number;
|
|
25
25
|
}): SyncParentGuard;
|
|
26
|
-
export declare function ensureStoreDirectorySync(params:
|
|
27
|
-
|
|
28
|
-
targetDir: string;
|
|
29
|
-
mode: number;
|
|
30
|
-
messagePrefix: "private store" | "store";
|
|
31
|
-
}): SyncParentGuard;
|
|
26
|
+
export declare function ensureStoreDirectorySync(params: Parameters<typeof ensureSyncStoreDirectory>[0]): SyncParentGuard;
|
|
27
|
+
export declare function literalStoreRootPath(relativePath: string): string;
|
|
32
28
|
export declare function assertRelativePath(relativePath: string): string;
|
|
33
29
|
export declare function resolveStorePath(rootDir: string, relativePath: string): string;
|
|
34
30
|
export declare function assertFileStoreMaxBytes(size: number, limit: number | undefined): void;
|
|
@@ -7,8 +7,7 @@ import { FsSafeError } from "./errors.js";
|
|
|
7
7
|
import { assertSyncStoreDirectoryReceipt, ensureSyncStoreDirectory, } from "./file-store-sync-directory.js";
|
|
8
8
|
import { isPathInside, splitSafeRelativePath } from "./path.js";
|
|
9
9
|
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
|
|
10
|
-
import {
|
|
11
|
-
import { RootHandle } from "./root-impl.js";
|
|
10
|
+
import { rootFromDirectoryGuard } from "./root-impl.js";
|
|
12
11
|
import { prepareSecretFileWrite } from "./secret-file.js";
|
|
13
12
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
14
13
|
import { recursiveMkdirPath } from "./recursive-mkdir-path.js";
|
|
@@ -16,7 +15,7 @@ import { readRegularFile } from "./regular-file.js";
|
|
|
16
15
|
import { errorCauseOptions } from "./root-errors.js";
|
|
17
16
|
import { assertNoWindowsPathAlias } from "./windows-path-alias.js";
|
|
18
17
|
export async function ensureParentInRoot(scopedRoot, relativePath, mode) {
|
|
19
|
-
const parent = path.posix.dirname(relativePath);
|
|
18
|
+
const parent = literalStoreRootPath(path.posix.dirname(relativePath));
|
|
20
19
|
if (parent === ".") {
|
|
21
20
|
return;
|
|
22
21
|
}
|
|
@@ -32,13 +31,7 @@ export async function openWritableStoreRoot(params) {
|
|
|
32
31
|
export async function openPrivateStoreLockRoot(params) {
|
|
33
32
|
const { parentGuard } = await prepareSecretFileWrite(params);
|
|
34
33
|
// Bind to the admitted parent, never resolve a replacement into a fresh capability.
|
|
35
|
-
return
|
|
36
|
-
rootDir: parentGuard.dir,
|
|
37
|
-
rootGuard: { dir: parentGuard.realPath, realPath: parentGuard.realPath, stat: parentGuard.stat },
|
|
38
|
-
rootReal: parentGuard.realPath,
|
|
39
|
-
rootWithSep: ensureTrailingSep(parentGuard.realPath),
|
|
40
|
-
rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
|
|
41
|
-
}, { hardlinks: "reject" });
|
|
34
|
+
return rootFromDirectoryGuard(parentGuard, { hardlinks: "reject" });
|
|
42
35
|
}
|
|
43
36
|
async function chmodDirectoryInRootBestEffort(scopedRoot, relativePath, mode) {
|
|
44
37
|
const dirPath = await scopedRoot.resolve(relativePath);
|
|
@@ -120,6 +113,10 @@ export function ensureStoreDirectorySync(params) {
|
|
|
120
113
|
assertSyncStoreDirectoryReceipt(guard);
|
|
121
114
|
return guard;
|
|
122
115
|
}
|
|
116
|
+
// Store keys and directory-entry names are literal, unlike Root's home syntax.
|
|
117
|
+
export function literalStoreRootPath(relativePath) {
|
|
118
|
+
return relativePath === "~" || relativePath.startsWith("~/") ? `./${relativePath}` : relativePath;
|
|
119
|
+
}
|
|
123
120
|
export function assertRelativePath(relativePath) {
|
|
124
121
|
const raw = relativePath.trim();
|
|
125
122
|
if (!raw || raw !== relativePath) {
|
package/dist/file-store-prune.js
CHANGED
|
@@ -2,6 +2,7 @@ import fsSync, {} from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
|
+
import { literalStoreRootPath } from "./file-store-boundary.js";
|
|
5
6
|
import { isPathInside } from "./path.js";
|
|
6
7
|
import { realpathSync } from "./realpath.js";
|
|
7
8
|
import { recursiveMkdirPath } from "./recursive-mkdir-path.js";
|
|
@@ -74,13 +75,13 @@ export async function pruneExpiredStoreEntries(params) {
|
|
|
74
75
|
await assertRootGuard();
|
|
75
76
|
// Keep empty-dir pruning on the same root-bounded remove path as files;
|
|
76
77
|
// the Root fallback handles empty directories without recursive delete.
|
|
77
|
-
await scopedRoot.remove(relativePath, REMOVE_EMPTY_DIRECTORY_OPTIONS).catch(() => undefined);
|
|
78
|
+
await scopedRoot.remove(literalStoreRootPath(relativePath), REMOVE_EMPTY_DIRECTORY_OPTIONS).catch(() => undefined);
|
|
78
79
|
}
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
82
|
if (stat.isFile() && now - stat.mtimeMs > params.options.ttlMs) {
|
|
82
83
|
await assertRootGuard();
|
|
83
|
-
await scopedRoot.remove(relativePath, {
|
|
84
|
+
await scopedRoot.remove(literalStoreRootPath(relativePath), {
|
|
84
85
|
assertBeforeMutation: () => {
|
|
85
86
|
// Removal preparation can outlive the expiry observation above.
|
|
86
87
|
const current = fsSync.lstatSync(fullPath);
|
package/dist/file-store.js
CHANGED
|
@@ -3,7 +3,7 @@ import { normalizeMaxBytes } from "./byte-budget.js";
|
|
|
3
3
|
import { readFileDescriptorBoundedSync } from "./bounded-read.js";
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { pruneExpiredStoreEntries } from "./file-store-prune.js";
|
|
6
|
-
import { assertFileStoreMaxBytes, assertRelativePath, ensureParentInRoot, openPrivateStoreLockRoot, openWritableStoreRoot, readFileStoreCopySource, resolveStorePath, writeStreamToTempSource, } from "./file-store-boundary.js";
|
|
6
|
+
import { assertFileStoreMaxBytes, assertRelativePath, ensureParentInRoot, literalStoreRootPath, openPrivateStoreLockRoot, openWritableStoreRoot, readFileStoreCopySource, resolveStorePath, writeStreamToTempSource, } from "./file-store-boundary.js";
|
|
7
7
|
import { writeFileSyncAtomic } from "./file-store-sync-write.js";
|
|
8
8
|
import { createJsonStore } from "./json-document-store.js";
|
|
9
9
|
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
@@ -61,7 +61,7 @@ function handleSyncStoreReadOpenFailure(opened) {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
async function copyIntoRoot(params) {
|
|
64
|
-
const relativePath =
|
|
64
|
+
const relativePath = params.relativePath;
|
|
65
65
|
const destination = resolveStorePath(params.rootDir, relativePath);
|
|
66
66
|
assertNoWindowsPathAlias(params.sourcePath, "filesystem", "source path uses a Windows filesystem namespace alias");
|
|
67
67
|
const sourceStat = syncFs.lstatSync(params.sourcePath);
|
|
@@ -75,7 +75,7 @@ async function copyIntoRoot(params) {
|
|
|
75
75
|
maxBytes: params.maxBytes,
|
|
76
76
|
});
|
|
77
77
|
await ensureParentInRoot(scopedRoot, relativePath, params.dirMode);
|
|
78
|
-
await scopedRoot.copyIn(relativePath, params.sourcePath, {
|
|
78
|
+
await scopedRoot.copyIn(literalStoreRootPath(relativePath), params.sourcePath, {
|
|
79
79
|
durable: params.durable,
|
|
80
80
|
maxBytes: params.maxBytes,
|
|
81
81
|
mkdir: false,
|
|
@@ -97,8 +97,7 @@ export function fileStore(options) {
|
|
|
97
97
|
return await root(rootDir, { hardlinks: "reject", maxBytes });
|
|
98
98
|
}
|
|
99
99
|
async function write(relativePath, data, writeOptions) {
|
|
100
|
-
const
|
|
101
|
-
const destination = resolveStorePath(rootDir, safeRelativePath);
|
|
100
|
+
const destination = resolveStorePath(rootDir, relativePath);
|
|
102
101
|
const content = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
103
102
|
const writeMaxBytes = normalizeMaxBytes(writeOptions?.maxBytes, { defaultValue: maxBytes });
|
|
104
103
|
assertFileStoreMaxBytes(content.byteLength, writeMaxBytes);
|
|
@@ -121,8 +120,8 @@ export function fileStore(options) {
|
|
|
121
120
|
dirMode: writeDirMode,
|
|
122
121
|
maxBytes: writeMaxBytes,
|
|
123
122
|
});
|
|
124
|
-
await ensureParentInRoot(scopedRoot,
|
|
125
|
-
await scopedRoot.write(
|
|
123
|
+
await ensureParentInRoot(scopedRoot, relativePath, writeDirMode);
|
|
124
|
+
await scopedRoot.write(literalStoreRootPath(relativePath), content, {
|
|
126
125
|
mkdir: false,
|
|
127
126
|
mode: writeMode,
|
|
128
127
|
durable: writeDurable,
|
|
@@ -135,8 +134,7 @@ export function fileStore(options) {
|
|
|
135
134
|
root: openRoot,
|
|
136
135
|
write,
|
|
137
136
|
writeStream: async (relativePath, stream, writeOptions) => {
|
|
138
|
-
const
|
|
139
|
-
const destination = resolveStorePath(rootDir, safeRelativePath);
|
|
137
|
+
const destination = resolveStorePath(rootDir, relativePath);
|
|
140
138
|
const configuredLimit = normalizeMaxBytes(writeOptions?.maxBytes, { defaultValue: maxBytes });
|
|
141
139
|
const limit = configuredLimit ?? (privateMode ? DEFAULT_ROOT_MAX_BYTES : undefined);
|
|
142
140
|
if (privateMode) {
|
|
@@ -172,7 +170,7 @@ export function fileStore(options) {
|
|
|
172
170
|
try {
|
|
173
171
|
await copyIntoRoot({
|
|
174
172
|
rootDir,
|
|
175
|
-
relativePath
|
|
173
|
+
relativePath,
|
|
176
174
|
sourcePath: staged.path,
|
|
177
175
|
durable: writeDurable,
|
|
178
176
|
maxBytes: limit,
|
|
@@ -214,17 +212,17 @@ export function fileStore(options) {
|
|
|
214
212
|
tempPrefix: writeOptions?.tempPrefix,
|
|
215
213
|
});
|
|
216
214
|
},
|
|
217
|
-
open: async (relativePath, readOptions) => await (await openRoot()).open(assertRelativePath(relativePath), readOptions),
|
|
218
|
-
read: async (relativePath, readOptions) => await (await openRoot()).read(assertRelativePath(relativePath), readOptions),
|
|
219
|
-
readBytes: async (relativePath, readOptions) => await (await openRoot()).readBytes(assertRelativePath(relativePath), readOptions),
|
|
215
|
+
open: async (relativePath, readOptions) => await (await openRoot()).open(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
|
|
216
|
+
read: async (relativePath, readOptions) => await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
|
|
217
|
+
readBytes: async (relativePath, readOptions) => await (await openRoot()).readBytes(literalStoreRootPath(assertRelativePath(relativePath)), readOptions),
|
|
220
218
|
readText: async (relativePath, readOptions) => {
|
|
221
219
|
const { encoding = "utf8", ...options } = readOptions ?? {};
|
|
222
|
-
return (await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
|
|
220
|
+
return (await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), options)).buffer
|
|
223
221
|
.toString(encoding);
|
|
224
222
|
},
|
|
225
223
|
readTextIfExists: async (relativePath, readOptions) => {
|
|
226
224
|
try {
|
|
227
|
-
return await (await openRoot()).readText(assertRelativePath(relativePath), readOptions);
|
|
225
|
+
return await (await openRoot()).readText(literalStoreRootPath(assertRelativePath(relativePath)), readOptions);
|
|
228
226
|
}
|
|
229
227
|
catch (error) {
|
|
230
228
|
if (isNotFound(error)) {
|
|
@@ -235,12 +233,12 @@ export function fileStore(options) {
|
|
|
235
233
|
},
|
|
236
234
|
readJson: async (relativePath, readOptions) => {
|
|
237
235
|
const { encoding = "utf8", ...options } = readOptions ?? {};
|
|
238
|
-
return JSON.parse((await (await openRoot()).read(assertRelativePath(relativePath), options)).buffer
|
|
236
|
+
return JSON.parse((await (await openRoot()).read(literalStoreRootPath(assertRelativePath(relativePath)), options)).buffer
|
|
239
237
|
.toString(encoding));
|
|
240
238
|
},
|
|
241
239
|
readJsonIfExists: async (relativePath, readOptions) => {
|
|
242
240
|
try {
|
|
243
|
-
return await (await openRoot()).readJson(assertRelativePath(relativePath), readOptions);
|
|
241
|
+
return await (await openRoot()).readJson(literalStoreRootPath(assertRelativePath(relativePath)), readOptions);
|
|
244
242
|
}
|
|
245
243
|
catch (error) {
|
|
246
244
|
if (isNotFound(error)) {
|
|
@@ -250,9 +248,9 @@ export function fileStore(options) {
|
|
|
250
248
|
}
|
|
251
249
|
},
|
|
252
250
|
remove: async (relativePath) => {
|
|
253
|
-
await (await openRoot()).remove(assertRelativePath(relativePath));
|
|
251
|
+
await (await openRoot()).remove(literalStoreRootPath(assertRelativePath(relativePath)));
|
|
254
252
|
},
|
|
255
|
-
exists: async (relativePath) => await (await openRoot()).exists(assertRelativePath(relativePath)),
|
|
253
|
+
exists: async (relativePath) => await (await openRoot()).exists(literalStoreRootPath(assertRelativePath(relativePath))),
|
|
256
254
|
writeText: async (relativePath, data, writeOptions) => await write(relativePath, data, writeOptions),
|
|
257
255
|
writeJson: async (relativePath, data, writeOptions) => {
|
|
258
256
|
const trailingNewline = writeOptions?.trailingNewline;
|
|
@@ -269,7 +267,7 @@ export function fileStore(options) {
|
|
|
269
267
|
} : {}),
|
|
270
268
|
readIfExists: async () => {
|
|
271
269
|
try {
|
|
272
|
-
return await (await openRoot()).readJson(
|
|
270
|
+
return await (await openRoot()).readJson(literalStoreRootPath(relativePath));
|
|
273
271
|
}
|
|
274
272
|
catch (error) {
|
|
275
273
|
if (isNotFound(error)) {
|
|
@@ -278,7 +276,7 @@ export function fileStore(options) {
|
|
|
278
276
|
throw error;
|
|
279
277
|
}
|
|
280
278
|
},
|
|
281
|
-
readRequired: async () => await (await openRoot()).readJson(
|
|
279
|
+
readRequired: async () => await (await openRoot()).readJson(literalStoreRootPath(relativePath)),
|
|
282
280
|
write: async (value, options) => {
|
|
283
281
|
const json = stringifyJsonDocument(value, null, 2);
|
|
284
282
|
await write(relativePath, options?.trailingNewline === false ? json : `${json}\n`, { durable: options?.durable });
|
|
@@ -36,15 +36,6 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
|
|
|
36
36
|
" is_linux = sys.platform.startswith('linux')",
|
|
37
37
|
" if is_linux:",
|
|
38
38
|
" rename_fn = getattr(libc, 'renameat2', None)",
|
|
39
|
-
" if rename_fn is None:",
|
|
40
|
-
" os.link(",
|
|
41
|
-
" src_basename,",
|
|
42
|
-
" dst_basename,",
|
|
43
|
-
" src_dir_fd=src_parent_fd,",
|
|
44
|
-
" dst_dir_fd=dst_parent_fd,",
|
|
45
|
-
" follow_symlinks=False,",
|
|
46
|
-
" )",
|
|
47
|
-
" return",
|
|
48
39
|
" flags = 1 # RENAME_NOREPLACE",
|
|
49
40
|
" elif sys.platform == 'darwin':",
|
|
50
41
|
" rename_fn = getattr(libc, 'renameatx_np', None)",
|
|
@@ -52,32 +43,33 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
|
|
|
52
43
|
" else:",
|
|
53
44
|
" rename_fn = None",
|
|
54
45
|
" flags = 0",
|
|
55
|
-
" if rename_fn is None:",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
46
|
+
" if rename_fn is not None:",
|
|
47
|
+
" rename_fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]",
|
|
48
|
+
" rename_fn.restype = ctypes.c_int",
|
|
49
|
+
" result = rename_fn(",
|
|
50
|
+
" src_parent_fd,",
|
|
51
|
+
" os.fsencode(src_basename),",
|
|
52
|
+
" dst_parent_fd,",
|
|
53
|
+
" os.fsencode(dst_basename),",
|
|
54
|
+
" flags,",
|
|
55
|
+
" )",
|
|
56
|
+
" if result == 0:",
|
|
57
|
+
" return",
|
|
67
58
|
" error_code = ctypes.get_errno()",
|
|
68
59
|
" unsupported_codes = {errno.ENOSYS, errno.EINVAL, errno.ENOTSUP}",
|
|
69
60
|
" if hasattr(errno, 'EOPNOTSUPP'):",
|
|
70
61
|
" unsupported_codes.add(errno.EOPNOTSUPP)",
|
|
71
|
-
" if is_linux
|
|
72
|
-
" os.
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
62
|
+
" if not is_linux or error_code not in unsupported_codes:",
|
|
63
|
+
" raise OSError(error_code, os.strerror(error_code), dst_basename)",
|
|
64
|
+
" elif not is_linux:",
|
|
65
|
+
" raise OSError(errno.ENOSYS, 'atomic no-replace rename is unavailable')",
|
|
66
|
+
" os.link(",
|
|
67
|
+
" src_basename,",
|
|
68
|
+
" dst_basename,",
|
|
69
|
+
" src_dir_fd=src_parent_fd,",
|
|
70
|
+
" dst_dir_fd=dst_parent_fd,",
|
|
71
|
+
" follow_symlinks=False,",
|
|
72
|
+
" )",
|
|
81
73
|
].join("\n");
|
|
82
74
|
export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
|
|
83
75
|
"def create_exclusive(parent_fd, basename, stdin_buffer):",
|
package/dist/guest.js
CHANGED
|
@@ -270,13 +270,15 @@ export const GUEST_FILESYSTEM_PYTHON = [
|
|
|
270
270
|
"def copy_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
|
|
271
271
|
" src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
|
|
272
272
|
" if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
|
|
273
|
-
" os.mkdir(dst_basename, stat.S_IMODE(src_stat.st_mode) or 0o755, dir_fd=dst_parent_fd)",
|
|
274
273
|
" copied_children = []",
|
|
275
274
|
" src_dir_fd = None",
|
|
276
275
|
" dst_dir_fd = None",
|
|
276
|
+
" destination_created = False",
|
|
277
277
|
" try:",
|
|
278
278
|
" src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
|
|
279
279
|
" src_stat = os.fstat(src_dir_fd)",
|
|
280
|
+
" os.mkdir(dst_basename, stat.S_IMODE(src_stat.st_mode), dir_fd=dst_parent_fd)",
|
|
281
|
+
" destination_created = True",
|
|
280
282
|
" dst_dir_fd = open_dir(dst_basename, dir_fd=dst_parent_fd)",
|
|
281
283
|
" for child in os.listdir(src_dir_fd):",
|
|
282
284
|
" copied_children.append((child, copy_entry(src_dir_fd, child, dst_dir_fd, child)))",
|
|
@@ -284,10 +286,11 @@ export const GUEST_FILESYSTEM_PYTHON = [
|
|
|
284
286
|
" if dst_dir_fd is not None:",
|
|
285
287
|
" os.close(dst_dir_fd)",
|
|
286
288
|
" dst_dir_fd = None",
|
|
287
|
-
"
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
"
|
|
289
|
+
" if destination_created:",
|
|
290
|
+
" try:",
|
|
291
|
+
" remove_tree(dst_parent_fd, dst_basename)",
|
|
292
|
+
" except FileNotFoundError:",
|
|
293
|
+
" pass",
|
|
291
294
|
" raise",
|
|
292
295
|
" finally:",
|
|
293
296
|
" if src_dir_fd is not None:",
|
|
@@ -328,30 +331,36 @@ export const GUEST_FILESYSTEM_PYTHON = [
|
|
|
328
331
|
" raise",
|
|
329
332
|
" src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
|
|
330
333
|
" if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
|
|
331
|
-
" temp_dir_name =
|
|
334
|
+
" temp_dir_name = None",
|
|
332
335
|
" copied_children = []",
|
|
333
336
|
" temp_dir_fd = None",
|
|
334
337
|
" src_dir_fd = None",
|
|
335
338
|
" try:",
|
|
336
|
-
" temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
|
|
337
339
|
" src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
|
|
338
340
|
" src_stat = os.fstat(src_dir_fd)",
|
|
341
|
+
" source_mode = stat.S_IMODE(src_stat.st_mode)",
|
|
342
|
+
" temp_dir_name = create_temp_dir(dst_parent_fd, source_mode or 0o700)",
|
|
343
|
+
" temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
|
|
339
344
|
" for child in os.listdir(src_dir_fd):",
|
|
340
345
|
" copied_children.append((child, copy_entry(src_dir_fd, child, temp_dir_fd, child)))",
|
|
341
346
|
" os.close(src_dir_fd)",
|
|
342
347
|
" src_dir_fd = None",
|
|
348
|
+
" os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
|
|
349
|
+
" temp_dir_name = None",
|
|
350
|
+
" if source_mode == 0:",
|
|
351
|
+
" os.fchmod(temp_dir_fd, 0)",
|
|
343
352
|
" os.close(temp_dir_fd)",
|
|
344
353
|
" temp_dir_fd = None",
|
|
345
|
-
" os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
|
|
346
354
|
" except Exception:",
|
|
347
355
|
" if src_dir_fd is not None:",
|
|
348
356
|
" os.close(src_dir_fd)",
|
|
349
357
|
" if temp_dir_fd is not None:",
|
|
350
358
|
" os.close(temp_dir_fd)",
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
"
|
|
354
|
-
"
|
|
359
|
+
" if temp_dir_name is not None:",
|
|
360
|
+
" try:",
|
|
361
|
+
" remove_tree(dst_parent_fd, temp_dir_name)",
|
|
362
|
+
" except FileNotFoundError:",
|
|
363
|
+
" pass",
|
|
355
364
|
" raise",
|
|
356
365
|
" remove_copied_entry(src_parent_fd, src_basename, ('dir', entry_identity(src_stat), copied_children))",
|
|
357
366
|
" os.fsync(dst_parent_fd)",
|
|
@@ -145,8 +145,7 @@ export async function loadPendingJsonDurableQueueEntries(options) {
|
|
|
145
145
|
await unlinkStaleTmpBestEffort(path.join(queueDir, file), now, options.cleanupTmpMaxAgeMs);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
-
const ids =
|
|
149
|
-
const seenIds = new Set();
|
|
148
|
+
const ids = new Set();
|
|
150
149
|
for (const file of files) {
|
|
151
150
|
const suffix = file.endsWith(".processing")
|
|
152
151
|
? ".processing"
|
|
@@ -162,10 +161,7 @@ export async function loadPendingJsonDurableQueueEntries(options) {
|
|
|
162
161
|
catch {
|
|
163
162
|
continue;
|
|
164
163
|
}
|
|
165
|
-
|
|
166
|
-
continue;
|
|
167
|
-
seenIds.add(id);
|
|
168
|
-
ids.push(id);
|
|
164
|
+
ids.add(id);
|
|
169
165
|
}
|
|
170
166
|
const entries = [];
|
|
171
167
|
for (const id of ids) {
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import type { BigIntStats, Stats } from "node:fs";
|
|
2
2
|
import type { FileHandle } from "node:fs/promises";
|
|
3
|
-
import type {
|
|
4
|
-
import type { SymlinkPolicy } from "./root-symlink-policy.js";
|
|
3
|
+
import type { RootReadOptions } from "./root-options.js";
|
|
5
4
|
type OwnedLocalFile = {
|
|
6
5
|
handle: FileHandle;
|
|
7
6
|
stat: Stats;
|
|
8
7
|
identity: BigIntStats;
|
|
9
8
|
preOpenStat: BigIntStats | undefined;
|
|
10
9
|
};
|
|
11
|
-
export declare function openLocalFileDescriptor(filePath: string, options?: {
|
|
12
|
-
hardlinks?: HardlinkPolicy;
|
|
13
|
-
symlinks?: SymlinkPolicy;
|
|
10
|
+
export declare function openLocalFileDescriptor(filePath: string, options?: Pick<RootReadOptions, "hardlinks" | "symlinks"> & {
|
|
14
11
|
readWrite?: true;
|
|
15
12
|
}): Promise<OwnedLocalFile>;
|
|
16
13
|
export {};
|
package/dist/local-roots.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ReadResult, type RootReadOptions } from "./root.js";
|
|
2
2
|
export type LocalRootsPathResult = {
|
|
3
3
|
path: string;
|
|
4
4
|
root: string;
|
|
@@ -15,11 +15,6 @@ export type ResolveLocalPathFromRootsSyncOptions = LocalRootsInputOptions & {
|
|
|
15
15
|
allowMissing?: boolean;
|
|
16
16
|
requireFile?: boolean;
|
|
17
17
|
};
|
|
18
|
-
export type ReadLocalFileFromRootsOptions = LocalRootsInputOptions &
|
|
19
|
-
hardlinks?: HardlinkPolicy;
|
|
20
|
-
maxBytes?: number;
|
|
21
|
-
nonBlockingRead?: boolean;
|
|
22
|
-
symlinks?: SymlinkPolicy;
|
|
23
|
-
};
|
|
18
|
+
export type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & RootReadOptions;
|
|
24
19
|
export declare function resolveLocalPathFromRootsSync(options: ResolveLocalPathFromRootsSyncOptions): LocalRootsPathResult | null;
|
|
25
20
|
export declare function readLocalFileFromRoots(options: ReadLocalFileFromRootsOptions): Promise<LocalRootsReadResult | null>;
|
package/dist/native-binding.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TarMeterLimits } from "./archive-limits.js";
|
|
2
2
|
import type { ArchiveMemberKind } from "./archive-plan.js";
|
|
3
3
|
import type { CopyCloneMode } from "./copy-policy.js";
|
|
4
|
+
import type { WindowsAceFlags } from "./owner-dacl.js";
|
|
4
5
|
export interface NativeFileHash {
|
|
5
6
|
bytes: number;
|
|
6
7
|
digest: string;
|
|
@@ -58,16 +59,7 @@ export interface NativeWindowsAccessControlEntry {
|
|
|
58
59
|
sid: string;
|
|
59
60
|
mask: number;
|
|
60
61
|
aceType: string;
|
|
61
|
-
flags:
|
|
62
|
-
raw: number;
|
|
63
|
-
objectInherit: boolean;
|
|
64
|
-
containerInherit: boolean;
|
|
65
|
-
noPropagateInherit: boolean;
|
|
66
|
-
inheritOnly: boolean;
|
|
67
|
-
inherited: boolean;
|
|
68
|
-
successfulAccess: boolean;
|
|
69
|
-
failedAccess: boolean;
|
|
70
|
-
};
|
|
62
|
+
flags: WindowsAceFlags;
|
|
71
63
|
}
|
|
72
64
|
export interface NativeWindowsSecurityFacts {
|
|
73
65
|
ownerSid: string;
|
|
@@ -96,6 +88,12 @@ export interface NativeWindowsDirectoryReceipt {
|
|
|
96
88
|
export interface NativeDarwinAclFacts {
|
|
97
89
|
state: "absent" | "empty" | "present";
|
|
98
90
|
}
|
|
91
|
+
type NativeTwoPathArgs = [
|
|
92
|
+
sourceRootFd: number,
|
|
93
|
+
sourceRelPath: string,
|
|
94
|
+
targetRootFd: number,
|
|
95
|
+
targetRelPath: string
|
|
96
|
+
];
|
|
99
97
|
export interface NativeBinding {
|
|
100
98
|
/** Internal: consumes only a descriptor returned by this binding. */
|
|
101
99
|
closeOwnedFd(fd: number): void;
|
|
@@ -141,7 +139,7 @@ export interface NativeBinding {
|
|
|
141
139
|
extractArchiveNative(path: string, kind: string, rootFd: number, plan: NativeArchivePlanEntry[], limits: TarMeterLimits, signal: AbortSignal): Promise<void>;
|
|
142
140
|
fstatIdentity(fd: number): NativeFileIdentity;
|
|
143
141
|
inspectArchiveNative(path: string, kind: string, limits: TarMeterLimits, signal: AbortSignal): Promise<NativeArchiveEntry[]>;
|
|
144
|
-
linkBeneath(
|
|
142
|
+
linkBeneath(...args: NativeTwoPathArgs): void;
|
|
145
143
|
/** Direct-child mkdir; true is receipt provenance only, never cleanup ownership. */
|
|
146
144
|
mkdirChildBeneath?(parentFd: number, basename: string, mode: number): boolean;
|
|
147
145
|
mkdirBeneath(rootFd: number, relPath: string, mode: number): void;
|
|
@@ -153,10 +151,11 @@ export interface NativeBinding {
|
|
|
153
151
|
ownedTreeRemovalAvailable?(parentFd: number): boolean;
|
|
154
152
|
removeOwnedTree?(parentFd: number, basename: string, directoryFd: number): Promise<NativeOwnedTreeRemovalResult>;
|
|
155
153
|
removeOwnedTreeSync?(parentFd: number, basename: string, directoryFd: number): NativeOwnedTreeRemovalResult;
|
|
156
|
-
renameNoReplace(
|
|
154
|
+
renameNoReplace(...args: NativeTwoPathArgs): void;
|
|
157
155
|
/** Identity-fenced retained-directory rename capability. */
|
|
158
156
|
renameNoReplaceWithIdentity?(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string, expectedSourceDev: bigint, expectedSourceIno: bigint): void;
|
|
159
|
-
renameReplace(
|
|
157
|
+
renameReplace(...args: NativeTwoPathArgs): void;
|
|
160
158
|
sha256File(fd: number, maxBytes?: number, signal?: AbortSignal): Promise<NativeFileHash>;
|
|
161
159
|
}
|
|
162
160
|
export declare function captureNativeFdClose(binding: NativeBinding): (fd: number) => void;
|
|
161
|
+
export {};
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PinnedMutationPolicySnapshot } from "./pinned-mutation-admission.js";
|
|
2
2
|
import type { RootBoundaryIdentity } from "./root-boundary.js";
|
|
3
|
-
import type { MutationSymlinkPolicy } from "./root-symlink-policy.js";
|
|
4
3
|
export type ExactRootIdentity = Readonly<{
|
|
5
4
|
dev: bigint;
|
|
6
5
|
ino: bigint;
|
|
7
6
|
}>;
|
|
8
|
-
type SharedMutationPolicy = Readonly<{
|
|
9
|
-
denyMutations?: DenyMutationPolicy;
|
|
10
|
-
mutationSymlinks?: MutationSymlinkPolicy;
|
|
11
|
-
}>;
|
|
12
7
|
export declare function ordinaryWindowsSegments(relativePath: string): boolean;
|
|
13
8
|
export declare function ordinarySharedAbsoluteInsideRoot(rootReal: string, candidatePath: string, rootIdentity: ExactRootIdentity): boolean;
|
|
14
9
|
export declare function simpleSharedRoute(params: {
|
|
@@ -16,9 +11,8 @@ export declare function simpleSharedRoute(params: {
|
|
|
16
11
|
rootIdentity?: RootBoundaryIdentity;
|
|
17
12
|
originalPath?: string;
|
|
18
13
|
selectedTarget: string;
|
|
19
|
-
policy:
|
|
14
|
+
policy: PinnedMutationPolicySnapshot;
|
|
20
15
|
}): {
|
|
21
16
|
route: string;
|
|
22
17
|
rootIdentity: ExactRootIdentity;
|
|
23
18
|
} | undefined;
|
|
24
|
-
export {};
|
package/dist/root-context.js
CHANGED
|
@@ -107,7 +107,7 @@ export function rootRelativeReadPath(root, filePath) {
|
|
|
107
107
|
resolveCandidateRoot: base === root.rootDir && root.rootDir !== root.rootReal,
|
|
108
108
|
});
|
|
109
109
|
if (admitted)
|
|
110
|
-
return admitted.relativePath
|
|
110
|
+
return `.${path.sep}${admitted.relativePath}`;
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
const prefix = ensureTrailingSep(base);
|
|
@@ -116,7 +116,8 @@ export function rootRelativeReadPath(root, filePath) {
|
|
|
116
116
|
let start = prefix.length;
|
|
117
117
|
while (raw[start] === path.sep)
|
|
118
118
|
start += 1;
|
|
119
|
-
|
|
119
|
+
// An admitted absolute tail stays literal, including a leading home marker.
|
|
120
|
+
return `.${path.sep}${raw.slice(start)}`;
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
return raw;
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { type BigIntStats, type Stats } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import type { RootMoveOptions } from "./root-options.js";
|
|
3
3
|
import { type RootContext } from "./root-context.js";
|
|
4
|
-
import { type MutationSymlinkPolicy } from "./root-symlink-policy.js";
|
|
5
4
|
export declare function admitMoveSourceStat<T extends Stats | BigIntStats>(stat: T, overwrite?: boolean): T;
|
|
6
|
-
export declare function movePathNoReplaceNative(root: RootContext, params: {
|
|
7
|
-
assertBeforeMutation?: () => void;
|
|
8
|
-
denyMutations?: DenyMutationPolicy;
|
|
9
|
-
mutationSymlinks?: MutationSymlinkPolicy;
|
|
10
|
-
}, paths: {
|
|
5
|
+
export declare function movePathNoReplaceNative(root: RootContext, params: Pick<RootMoveOptions, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">, paths: {
|
|
11
6
|
sourcePath: string;
|
|
12
7
|
sourceParentPath: string;
|
|
13
8
|
targetPath: string;
|
package/dist/root-paths.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResolvePathWithinRootParams } from "./path-scope-lexical.js";
|
|
1
2
|
import { type DirectoryResult } from "./root-directory.js";
|
|
2
3
|
export { resolvePathWithinRoot } from "./root-paths-lexical.js";
|
|
3
4
|
export { ensureDirectoryWithinRoot } from "./root-directory.js";
|
|
@@ -44,12 +45,7 @@ export type PathScope = {
|
|
|
44
45
|
mode?: number;
|
|
45
46
|
}): Promise<DirectoryResult>;
|
|
46
47
|
};
|
|
47
|
-
export declare function resolveWritablePathWithinRoot(params: {
|
|
48
|
-
rootDir: string;
|
|
49
|
-
requestedPath: string;
|
|
50
|
-
scopeLabel: string;
|
|
51
|
-
defaultFileName?: string;
|
|
52
|
-
}): Promise<{
|
|
48
|
+
export declare function resolveWritablePathWithinRoot(params: ResolvePathWithinRootParams): Promise<{
|
|
53
49
|
ok: true;
|
|
54
50
|
path: string;
|
|
55
51
|
} | {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { BigIntStats } from "node:fs";
|
|
2
2
|
type ExactDirectoryIdentity = Readonly<Pick<BigIntStats, "dev" | "ino">>;
|
|
3
|
-
export type RemovalDirectoryAssertion = Readonly<{
|
|
3
|
+
export type RemovalDirectoryAssertion = ExactDirectoryIdentity & Readonly<{
|
|
4
4
|
path: string;
|
|
5
|
-
dev: bigint;
|
|
6
|
-
ino: bigint;
|
|
7
5
|
numericDev?: number;
|
|
8
6
|
numericIno?: number;
|
|
9
7
|
platform: NodeJS.Platform;
|
package/dist/root-walk.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { expandRelativePathWithHome } from "./root-context.js";
|
|
3
4
|
import { resolveRootPath, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
|
|
4
5
|
import { createSuppressedError } from "./suppressed-error.js";
|
|
5
6
|
function validateBudget(name, value) {
|
|
@@ -65,9 +66,10 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
65
66
|
options.signal?.throwIfAborted();
|
|
66
67
|
let listing;
|
|
67
68
|
try {
|
|
69
|
+
const expandedDirectory = depth === 0 ? await expandRelativePathWithHome(directory) : directory;
|
|
68
70
|
const skipChildSymlinks = depth > 0 && options.symlinkPolicy === "skip";
|
|
69
71
|
const resolvedDirectory = await resolveRootPath({
|
|
70
|
-
absolutePath: path.resolve(root.rootReal,
|
|
72
|
+
absolutePath: path.resolve(root.rootReal, expandedDirectory),
|
|
71
73
|
rootPath: root.rootReal,
|
|
72
74
|
rootCanonicalPath: root.rootReal,
|
|
73
75
|
boundaryLabel: "root walk",
|
|
@@ -87,7 +89,10 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
87
89
|
.relative(root.rootReal, resolvedDirectory.canonicalPath)
|
|
88
90
|
.split(path.sep)
|
|
89
91
|
.join(path.posix.sep);
|
|
90
|
-
|
|
92
|
+
if (depth === 0 && expandedDirectory !== directory)
|
|
93
|
+
directory = listingDirectory;
|
|
94
|
+
// Resolved filesystem names are literal, not caller home-directory shorthand.
|
|
95
|
+
listing = await root.list(`./${listingDirectory}`, {
|
|
91
96
|
order: options.order ?? "sorted",
|
|
92
97
|
signal: options.signal,
|
|
93
98
|
snapshot: maxEntries === Number.POSITIVE_INFINITY,
|
|
@@ -139,7 +144,7 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
139
144
|
if (!resolved.exists) {
|
|
140
145
|
continue;
|
|
141
146
|
}
|
|
142
|
-
const target = await root.stat(path.relative(root.rootReal, resolved.canonicalPath));
|
|
147
|
+
const target = await root.stat(`./${path.relative(root.rootReal, resolved.canonicalPath)}`);
|
|
143
148
|
kind = target.isDirectory ? "directory" : target.isFile ? "file" : "other";
|
|
144
149
|
size = target.size;
|
|
145
150
|
}
|
|
@@ -14,7 +14,7 @@ import { mutationSymlinkResolution, } from "./root-symlink-policy.js";
|
|
|
14
14
|
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
15
15
|
import { realpathSync } from "./realpath.js";
|
|
16
16
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
17
|
-
import { assertPreparedRootWriteParentCurrent, } from "./root-write-complete-parent.js";
|
|
17
|
+
import { assertPreparedRootWriteParentCurrent, writeSelectionChanged, } from "./root-write-complete-parent.js";
|
|
18
18
|
export function createRootWriteSelectionForFd(selection, fd) {
|
|
19
19
|
const stat = inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }));
|
|
20
20
|
return Object.freeze({
|
|
@@ -22,9 +22,6 @@ export function createRootWriteSelectionForFd(selection, fd) {
|
|
|
22
22
|
identity: Object.freeze({ dev: stat.dev, ino: stat.ino }),
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
-
function writeSelectionChanged(cause) {
|
|
26
|
-
return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
|
|
27
|
-
}
|
|
28
25
|
function inspectRegularSelectionPath(pathname, expected, follow) {
|
|
29
26
|
let observed;
|
|
30
27
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BigIntStats } from "node:fs";
|
|
2
2
|
import { type AsyncDirectoryGuard } from "./directory-guard.js";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
3
4
|
import type { RootContext } from "./root-context.js";
|
|
4
5
|
import type { GuardedRootWriteTarget } from "./root-write-admission.js";
|
|
5
6
|
type PreparedWriteTargetObservation = Readonly<{
|
|
@@ -36,6 +37,7 @@ export type SharedRootWriteTarget = Readonly<{
|
|
|
36
37
|
mutationAdmission: GuardedRootWriteTarget["mutationAdmission"];
|
|
37
38
|
preparedParent?: PreparedRootWriteParent;
|
|
38
39
|
}>;
|
|
40
|
+
export declare function writeSelectionChanged(cause?: unknown): FsSafeError;
|
|
39
41
|
export declare function assertPreparedRootWriteParentCurrent(prepared: PreparedRootWriteParent, verifyTarget?: boolean): void;
|
|
40
42
|
export declare function prepareSharedRootWriteTarget(root: RootContext, params: SharedRootWriteTargetParams): Promise<SharedRootWriteTarget>;
|
|
41
43
|
export {};
|
|
@@ -12,7 +12,7 @@ import { errorCauseOptions } from "./root-errors.js";
|
|
|
12
12
|
import { canReuseParentWithMutationAssertion } from "./root-write-lock-binding.js";
|
|
13
13
|
import { inspectFileIdentitySync } from "./strict-file-identity.js";
|
|
14
14
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
15
|
-
function writeSelectionChanged(cause) {
|
|
15
|
+
export function writeSelectionChanged(cause) {
|
|
16
16
|
return new FsSafeError("path-mismatch", "write target changed during operation", errorCauseOptions(cause));
|
|
17
17
|
}
|
|
18
18
|
function ordinarySharedWriteRoute(root, relativePath, operationTargetPath) {
|
package/dist/secret-file.d.ts
CHANGED
|
@@ -13,7 +13,11 @@ type SecretFileWriteParams = {
|
|
|
13
13
|
dirMode?: number;
|
|
14
14
|
durable?: boolean;
|
|
15
15
|
};
|
|
16
|
-
|
|
16
|
+
type SecretFileCreateParams = Omit<SecretFileWriteParams, "durable"> & {
|
|
17
|
+
/** "file" requires file synchronization; directory synchronization remains best effort. */
|
|
18
|
+
durable?: boolean | "file";
|
|
19
|
+
};
|
|
20
|
+
export declare function prepareSecretFileWrite(params: Pick<SecretFileWriteParams, "rootDir" | "filePath" | "mode" | "dirMode">): Promise<{
|
|
17
21
|
mode: number;
|
|
18
22
|
rootGuard: AsyncDirectoryGuard<BigIntStats>;
|
|
19
23
|
parentGuard: AsyncDirectoryGuard<BigIntStats>;
|
|
@@ -21,5 +25,5 @@ export declare function prepareSecretFileWrite(params: Omit<SecretFileWriteParam
|
|
|
21
25
|
finalFilePath: string;
|
|
22
26
|
}>;
|
|
23
27
|
export declare function writeSecretFileAtomic(params: SecretFileWriteParams): Promise<void>;
|
|
24
|
-
export declare function createSecretFileAtomic(params:
|
|
28
|
+
export declare function createSecretFileAtomic(params: SecretFileCreateParams): Promise<void>;
|
|
25
29
|
export {};
|
package/dist/secret-file.js
CHANGED
|
@@ -285,6 +285,7 @@ async function materializeSecretFileAtomic(params, createOnly) {
|
|
|
285
285
|
mode,
|
|
286
286
|
verifyPosixMode: true,
|
|
287
287
|
sync: params.durable !== false,
|
|
288
|
+
strictFileSync: createOnly && params.durable === "file",
|
|
288
289
|
overwrite: !createOnly,
|
|
289
290
|
input: { kind: "buffer", data: typeof params.content === "string" ? params.content : Buffer.from(params.content) },
|
|
290
291
|
rootIdentity: { dev: parentGuard.stat.dev, ino: parentGuard.stat.ino },
|
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
import { FsSafeError } from "./errors.js";
|
|
2
1
|
import { fileIdentityMismatchError } from "./strict-file-identity.js";
|
|
3
2
|
import { getNativeBinding } from "./native.js";
|
|
4
3
|
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
5
4
|
import { warnNativeFallback } from "./native-fallback-warning.js";
|
|
6
5
|
import { inspectWindowsDescriptorCommand } from "./windows-security-command.js";
|
|
7
|
-
import { validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
|
|
6
|
+
import { unverified as permissionUnverified, validateSecureWindowsSecurityFacts } from "./windows-security-facts.js";
|
|
8
7
|
const IDENTITY_RE = /^([0-9a-f]{8}):([0-9a-f]{16})$/;
|
|
9
8
|
const TRUSTED_OWNER_CLASSES = new Set(["current-user", "system", "administrators"]);
|
|
10
|
-
function permissionUnverified(message, cause) {
|
|
11
|
-
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
12
|
-
}
|
|
13
9
|
function isRecord(value) {
|
|
14
10
|
return typeof value === "object" && value !== null;
|
|
15
11
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Root } from "./root-impl.js";
|
|
2
2
|
import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
|
|
3
3
|
import { readSidecarLockRawSnapshot, type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
|
|
4
|
-
type SidecarAdmissionRunner = {
|
|
4
|
+
export type SidecarAdmissionRunner = {
|
|
5
5
|
hasToken(): boolean;
|
|
6
6
|
run<T>(callback: () => T): T;
|
|
7
7
|
};
|
|
@@ -39,4 +39,3 @@ export declare function observeHeldSidecarParser(params: {
|
|
|
39
39
|
parserAccessor(): ((raw: string) => unknown) | undefined;
|
|
40
40
|
isTransientDenial(error: unknown): boolean;
|
|
41
41
|
}): Promise<HeldSidecarParserObservation>;
|
|
42
|
-
export {};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
|
|
1
|
+
import type { HeldSidecarLock } from "./sidecar-lock-admission.js";
|
|
3
2
|
import type { SidecarLockHandle } from "./sidecar-lock-types.js";
|
|
4
3
|
export declare function stopSidecarLockMonitoring(held: {
|
|
5
4
|
compromiseTimer?: NodeJS.Timeout;
|
|
@@ -14,12 +13,7 @@ export declare function createSidecarLockHandle(params: {
|
|
|
14
13
|
}): SidecarLockHandle;
|
|
15
14
|
export declare function createHeldSidecarLockHandle(params: {
|
|
16
15
|
normalizedTargetPath: string;
|
|
17
|
-
held:
|
|
18
|
-
lockPath: string;
|
|
19
|
-
snapshot: SidecarLockSnapshot;
|
|
20
|
-
lockRoot?: Root;
|
|
21
|
-
parsePayload?: (raw: string) => unknown;
|
|
22
|
-
};
|
|
16
|
+
held: Pick<HeldSidecarLock, "lockPath" | "snapshot" | "lockRoot" | "parsePayload">;
|
|
23
17
|
release: (options?: {
|
|
24
18
|
retry?: boolean;
|
|
25
19
|
}) => Promise<unknown>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
|
|
1
|
+
import type { SidecarLockReclaimParams, SidecarLockRetryOptions } from "./sidecar-lock-types.js";
|
|
2
2
|
export declare function validateSidecarLockRetryOptions(retry: SidecarLockRetryOptions): void;
|
|
3
3
|
export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefined): void;
|
|
4
4
|
export declare function validateSidecarLockStaleMs(staleMs: number | undefined): void;
|
|
@@ -11,9 +11,4 @@ export declare function sidecarLockRetryDelay(retry: SidecarLockRetryOptions, ti
|
|
|
11
11
|
export declare const maxTransientLockDenials = 8;
|
|
12
12
|
export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
|
|
13
13
|
export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
|
|
14
|
-
export declare function defaultSidecarLockShouldReclaim(params:
|
|
15
|
-
lockPath: string;
|
|
16
|
-
payload: unknown;
|
|
17
|
-
staleMs: number;
|
|
18
|
-
nowMs: number;
|
|
19
|
-
}): Promise<boolean>;
|
|
14
|
+
export declare function defaultSidecarLockShouldReclaim(params: Pick<SidecarLockReclaimParams, "lockPath" | "payload" | "staleMs" | "nowMs">): Promise<boolean>;
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { type SidecarReclaimGuard } from "./sidecar-lock-reclaim.js";
|
|
2
|
-
import { type SidecarLockParserState } from "./sidecar-lock-admission-parser.js";
|
|
2
|
+
import { type SidecarLockParserState, type SidecarAdmissionRunner } from "./sidecar-lock-admission-parser.js";
|
|
3
3
|
import type { Root } from "./root-impl.js";
|
|
4
4
|
import type { SidecarLockAcquireOptions } from "./sidecar-lock-types.js";
|
|
5
|
-
type SidecarAdmissionRunner = {
|
|
6
|
-
hasToken(): boolean;
|
|
7
|
-
run<T>(callback: () => T): T;
|
|
8
|
-
};
|
|
9
5
|
type StaleOptions = Pick<SidecarLockAcquireOptions<Record<string, unknown>>, "shouldReclaim" | "shouldRemoveStaleLock" | "staleRecovery">;
|
|
10
6
|
export type SidecarLockStaleOptionsState = {
|
|
11
7
|
shouldReclaimObserved?: boolean;
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type FsSafeTestHooks = {
|
|
|
25
25
|
beforeTempWorkspaceNativeRemovalSync?: (quarantinePath: string) => void;
|
|
26
26
|
beforeTrashMove?: (targetPath: string, destPath: string) => void;
|
|
27
27
|
afterPublishTargetCreated?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
|
|
28
|
-
beforePublishDirectorySync?:
|
|
28
|
+
beforePublishDirectorySync?: NonNullable<FsSafeTestHooks["afterPublishTargetCreated"]>;
|
|
29
29
|
};
|
|
30
30
|
export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
|
|
31
31
|
export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
|
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { FsSafeError } from "./errors.js";
|
|
4
4
|
import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, PermissionCommandError } from "./permission-exec.js";
|
|
5
5
|
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
6
|
-
import { parseWindowsSecurityCommandFacts } from "./windows-security-facts.js";
|
|
6
|
+
import { parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
|
|
7
7
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
8
8
|
const TERMINATION_GRACE_MS = 1_000;
|
|
9
9
|
const FULL_IDENTITY = /^[0-9a-f]{16}:[0-9a-f]{32}$/;
|
|
@@ -50,9 +50,6 @@ function command(operation, params) {
|
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
function unverified(message, cause) {
|
|
54
|
-
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
55
|
-
}
|
|
56
53
|
function record(value) {
|
|
57
54
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
58
55
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeWindowsSecurityFacts } from "./native-binding.js";
|
|
2
|
+
export declare function unverified(message: string, cause?: unknown): never;
|
|
2
3
|
/** Raw reporting retains unknown flag bits; secure admission validates them below. */
|
|
3
4
|
export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
|
|
4
5
|
/** Native and command observations share the same fail-closed admission policy. */
|
|
@@ -6,8 +6,8 @@ const WORLD_SIDS = new Set([
|
|
|
6
6
|
"s-1-1-0", "s-1-5-11", "s-1-5-32-545", "s-1-5-7",
|
|
7
7
|
"s-1-5-32-546", "s-1-5-4", "s-1-5-2",
|
|
8
8
|
]);
|
|
9
|
-
function unverified(message) {
|
|
10
|
-
throw new FsSafeError("permission-unverified", message);
|
|
9
|
+
export function unverified(message, cause) {
|
|
10
|
+
throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
|
|
11
11
|
}
|
|
12
12
|
function record(value) {
|
|
13
13
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
package/docs/archive.md
CHANGED
|
@@ -238,6 +238,8 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
|
|
|
238
238
|
`pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
|
|
239
239
|
spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
|
|
240
240
|
Unicode Path names use the same canonicalization.
|
|
241
|
+
A leading `~` is a literal archive entry name and is never expanded to the
|
|
242
|
+
user's home directory during extraction, publication, or durability checks.
|
|
241
243
|
Callbacks follow physical archive order, including ZIP names that look like
|
|
242
244
|
integer object keys. The public ZIP loader's `files` object retains ordinary
|
|
243
245
|
JavaScript object enumeration and mutation behavior.
|
package/docs/copy.md
CHANGED
|
@@ -74,6 +74,8 @@ Native Windows byte copies can store large zero-filled chunks as sparse ranges w
|
|
|
74
74
|
|
|
75
75
|
The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
|
|
76
76
|
|
|
77
|
+
Failed ReFS clones attempt to remove their partial output through the retained directory handles. On Windows versions that reject the ignore-readonly deletion flag, clone rollback retries without that flag so ordinary output can be removed. It never clears readonly attributes: readonly output can remain, and the original clone error includes the cleanup failure. Other processes retaining output handles can delay deletion beyond settlement; a failed call does not guarantee an absent destination.
|
|
78
|
+
|
|
77
79
|
XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
|
|
78
80
|
|
|
79
81
|
`readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
|
package/docs/file-store.md
CHANGED
|
@@ -101,6 +101,11 @@ such as `internal space/a b.txt` are accepted. On POSIX, colons elsewhere, such
|
|
|
101
101
|
as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid;
|
|
102
102
|
Windows rejects that spelling as stream syntax.
|
|
103
103
|
|
|
104
|
+
Keys such as `~` and `~/state.json` name literal entries inside the store; they
|
|
105
|
+
do not expand the user's home directory. Reads, writes, removal, and pruning
|
|
106
|
+
all use that literal identity. The `Root` returned by `root()` retains its own
|
|
107
|
+
home-expansion behavior.
|
|
108
|
+
|
|
104
109
|
Validation retains each method's operation order. Async reads, `exists`, and
|
|
105
110
|
`remove` open the root first: if the root is missing, strict methods report
|
|
106
111
|
`not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
|
package/docs/guest.md
CHANGED
|
@@ -119,7 +119,13 @@ publication failure preserves the existing destination and source link; ordinary
|
|
|
119
119
|
failure cleanup removes the staging directory.
|
|
120
120
|
|
|
121
121
|
Cross-device directory moves build a copy manifest and check it during source
|
|
122
|
-
cleanup.
|
|
122
|
+
cleanup. Directory creation keeps the source mode subject to the guest's umask;
|
|
123
|
+
mode `000` is not replaced with a default. A top-level mode-000 directory uses
|
|
124
|
+
owner-only staging until publication, then restores zero through its retained
|
|
125
|
+
descriptor. Reading a mode-000 source still requires sufficient OS privileges;
|
|
126
|
+
the guest does not change source permissions to gain access. A permission error
|
|
127
|
+
after publication preserves the source and published copy for reconciliation.
|
|
128
|
+
Source changes can leave the published destination and some or all
|
|
123
129
|
of the source. Regular-file and symlink move fallbacks unlink the source
|
|
124
130
|
pathname after publication; they do not perform the directory manifest's
|
|
125
131
|
identity checks. Directory cleanup also has check-to-unlink race windows.
|
package/docs/native-helper.md
CHANGED
|
@@ -107,6 +107,12 @@ normalization, and the decision to fall back.
|
|
|
107
107
|
- macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
|
|
108
108
|
- Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
|
|
109
109
|
|
|
110
|
+
Linux root lookups reject negative descriptor sentinels before borrowing a handle or resolving a relative path; they never substitute the process working directory for an admitted root. Public Root operations already supply retained, admitted handles.
|
|
111
|
+
|
|
112
|
+
On Linux and macOS, native asynchronous file-copy admission rejects negative source and parent descriptors before creating a stage, preserving source-before-parent error ordering. Callers must keep nonnegative source and parent descriptors open until the operation settles.
|
|
113
|
+
|
|
114
|
+
Low-level Unix query, hash, copy, clone, staging, and owned-tree cleanup calls also reject negative descriptors before using them, preserving each operation's path-validation, cancellation, and cleanup order. Descriptor-relative mutations never accept a working-directory sentinel as a retained capability. On modern macOS, beneath opens reject negative roots with `EBADF` before calling `openat`, rather than returning `EIO` after an OS failure or working-directory operation. This check does not establish the validity of arbitrary nonnegative integers: callers must supply live descriptors and retain them until synchronous calls return or asynchronous operations settle.
|
|
115
|
+
|
|
110
116
|
`replaceDirectoryAtomic()` requires `renameNoReplaceWithIdentity` before it
|
|
111
117
|
creates a missing target parent. On POSIX the dedicated entry point keeps the
|
|
112
118
|
existing pre-dispatch exact receipt fence but dispatches direct-child names
|
package/docs/root.md
CHANGED
|
@@ -24,7 +24,7 @@ type RootDefaults = {
|
|
|
24
24
|
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
25
25
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
26
26
|
mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
|
|
27
|
-
mode?: number; // file mode
|
|
27
|
+
mode?: number; // requested file mode; per-call override available
|
|
28
28
|
nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
|
|
29
29
|
renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
|
|
30
30
|
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
|
|
@@ -117,6 +117,10 @@ created through a directory symlink or Windows junction. An absolute path
|
|
|
117
117
|
outside the root is still rejected. On Windows, alternate casing is accepted
|
|
118
118
|
only when the differently cased Root prefix has the Root's exact directory
|
|
119
119
|
identity; the operation then continues under the trusted Root spelling.
|
|
120
|
+
Absolute paths keep literal `~` components: `readAbsolute("/srv/root/~/file")`
|
|
121
|
+
reads that entry under the root, without expanding the user's home directory.
|
|
122
|
+
Relative `~/file` inputs still expand the home directory and must remain inside
|
|
123
|
+
the Root; use `./~/file` for a literal relative `~` directory.
|
|
120
124
|
|
|
121
125
|
### Writes
|
|
122
126
|
|
|
@@ -153,6 +157,11 @@ await fs.create("private-data/credential", "synthetic credential", { private: tr
|
|
|
153
157
|
|
|
154
158
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
155
159
|
|
|
160
|
+
For `append` and `openWritable`, `mode` only affects new-file creation: POSIX
|
|
161
|
+
permissions remain subject to the process umask. These methods do not chmod
|
|
162
|
+
existing files. Replacement and create-only writes apply their final mode
|
|
163
|
+
through the retained descriptor; see [Writing](writing.md#write-options).
|
|
164
|
+
|
|
156
165
|
Buffered `create` and `createJson` also accept `atomic?: boolean`. With `true`,
|
|
157
166
|
complete content is staged before exclusive publication even in native-off mode;
|
|
158
167
|
the fallback requires hardlinks. Omitted or `false` keeps the existing buffered
|
package/docs/secret-file.md
CHANGED
|
@@ -228,6 +228,15 @@ if anything already occupies the target path it throws
|
|
|
228
228
|
`FsSafeError("secret-exists")` without modifying that entry. Use the distinct
|
|
229
229
|
name when first-writer-wins is part of the credential protocol.
|
|
230
230
|
|
|
231
|
+
Its `durable` option also accepts `"file"`, matching `Root.create()`. This
|
|
232
|
+
requires every file `fsync` to succeed, including on `EPERM`, while parent-directory
|
|
233
|
+
synchronization remains best effort. The default and boolean options retain their
|
|
234
|
+
existing behavior. `writeSecretFileAtomic()` continues to accept boolean durability.
|
|
235
|
+
|
|
236
|
+
Strict file synchronization preserves the existing publication strategy and
|
|
237
|
+
identity-checked cleanup. A failed file flush before staged publication prevents
|
|
238
|
+
publication; a failure after publication can leave the complete file present.
|
|
239
|
+
|
|
231
240
|
Distinct leaves can share missing-parent creation without a `secret-exists`
|
|
232
241
|
error. Concurrent creates at the same leaf still have exactly one winner;
|
|
233
242
|
the loser receives `secret-exists` and leaves the winner's bytes intact.
|
|
@@ -244,6 +253,7 @@ try {
|
|
|
244
253
|
rootDir: "/var/lib/app/credentials",
|
|
245
254
|
filePath: "/var/lib/app/credentials/provider.refresh-token",
|
|
246
255
|
content: refreshToken,
|
|
256
|
+
durable: "file",
|
|
247
257
|
});
|
|
248
258
|
} catch (error) {
|
|
249
259
|
if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
|
package/docs/walk.md
CHANGED
|
@@ -113,6 +113,18 @@ typed `FsSafeError("too-large")` instead.
|
|
|
113
113
|
|
|
114
114
|
For followed symlinks, both `kind` and `size` describe the resolved target.
|
|
115
115
|
|
|
116
|
+
The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
|
|
117
|
+
the home directory when iteration starts and must resolve inside the Root.
|
|
118
|
+
Home-started walks report actual Root-relative paths, such as `home/dir/file`,
|
|
119
|
+
rather than `~/dir/file`. Use `./~/dir` to start at a literal `~` directory.
|
|
120
|
+
An alias within a home-started path is reported under its admitted canonical
|
|
121
|
+
target; ordinary non-home starting aliases retain their caller-supplied spelling.
|
|
122
|
+
|
|
123
|
+
Entry names remain literal filesystem data, including a directory named `~`
|
|
124
|
+
and its descendants. To reuse an entry path in another Root method without
|
|
125
|
+
home-directory expansion, prefix it with `./`, as in
|
|
126
|
+
`capability.open("./" + entry.relativePath)`.
|
|
127
|
+
|
|
116
128
|
The default `order: "sorted"` visits each directory's names in lexicographic
|
|
117
129
|
order before descending depth first. It reads and sorts all names in each
|
|
118
130
|
visited directory. With `maxEntries`, it prepares small metadata batches capped
|
package/docs/writing.md
CHANGED
|
@@ -301,6 +301,12 @@ type RootWriteJsonOptions = RootWriteOptions & {
|
|
|
301
301
|
|
|
302
302
|
Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. `durable: false` skips both syncs. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
|
|
303
303
|
|
|
304
|
+
`mode` selects the creation mode, defaulting to `0o600` when neither the call nor
|
|
305
|
+
the Root supplies it. On POSIX, the process umask can further restrict that mode;
|
|
306
|
+
for example, `mode: 0o640` with umask `0o077` creates a `0o600` file. Existing
|
|
307
|
+
files are not chmodded, even when an explicit `mode` is supplied. Empty appends
|
|
308
|
+
use the same creation rules.
|
|
309
|
+
|
|
304
310
|
```ts
|
|
305
311
|
await fs.append("logs/today.log", `[${ts}] ${line}\n`);
|
|
306
312
|
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
|
|
@@ -522,6 +528,11 @@ destination — there is no atomic-rename step. For exclusive publication of a
|
|
|
522
528
|
complete stream, use [`create()`](#streamed-creation). For streamed replacement,
|
|
523
529
|
the [`atomic`](atomic.md) helpers provide a staged writer.
|
|
524
530
|
|
|
531
|
+
For all three write modes, `mode` only selects new-file creation permissions,
|
|
532
|
+
defaulting to `0o600` when neither the call nor the Root supplies it. POSIX
|
|
533
|
+
permissions remain subject to the process umask; existing files are not chmodded.
|
|
534
|
+
The returned numeric `stat` records the admitted descriptor before caller writes.
|
|
535
|
+
|
|
525
536
|
On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
|
|
526
537
|
a no-reader FIFO cannot stall regular-file validation. This does not change
|
|
527
538
|
ordinary regular-file write semantics. `replace` and `update` remain write-only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -169,13 +169,13 @@
|
|
|
169
169
|
"archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
|
|
170
170
|
},
|
|
171
171
|
"optionalDependencies": {
|
|
172
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
173
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
174
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
175
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
176
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
177
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
178
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.
|
|
172
|
+
"@openclaw/fs-safe-darwin-arm64": "0.19.0",
|
|
173
|
+
"@openclaw/fs-safe-darwin-x64": "0.19.0",
|
|
174
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
|
|
175
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
|
|
176
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
|
|
177
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.19.0",
|
|
178
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
|
|
179
179
|
"jszip": "^3.10.2"
|
|
180
180
|
},
|
|
181
181
|
"devDependencies": {
|