@openclaw/fs-safe 0.4.7 → 0.5.1
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 +58 -0
- package/README.md +82 -43
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +5 -4
- package/dist/archive-errors.d.ts +11 -0
- package/dist/archive-errors.d.ts.map +1 -0
- package/dist/archive-errors.js +16 -0
- package/dist/archive-input.d.ts +12 -0
- package/dist/archive-input.d.ts.map +1 -0
- package/dist/archive-input.js +72 -0
- package/dist/archive-kind.d.ts +1 -1
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +18 -0
- package/dist/archive-limits.d.ts +10 -0
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +18 -0
- package/dist/archive-native.d.ts +18 -0
- package/dist/archive-native.d.ts.map +1 -0
- package/dist/archive-native.js +115 -0
- package/dist/archive-options.d.ts +21 -0
- package/dist/archive-options.d.ts.map +1 -0
- package/dist/archive-options.js +1 -0
- package/dist/archive-policy.d.ts +20 -0
- package/dist/archive-policy.d.ts.map +1 -0
- package/dist/archive-policy.js +29 -0
- package/dist/archive-read.d.ts +6 -0
- package/dist/archive-read.d.ts.map +1 -0
- package/dist/archive-read.js +200 -0
- package/dist/archive-staging.d.ts +2 -5
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +19 -10
- package/dist/archive-tar-meta.d.ts +6 -0
- package/dist/archive-tar-meta.d.ts.map +1 -0
- package/dist/archive-tar-meta.js +143 -0
- package/dist/archive-tar-runtime.d.ts +43 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -0
- package/dist/archive-tar-runtime.js +8 -0
- package/dist/archive-tar.d.ts +5 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +24 -6
- package/dist/archive-zip-entry.d.ts +15 -0
- package/dist/archive-zip-entry.d.ts.map +1 -0
- package/dist/archive-zip-entry.js +17 -0
- package/dist/archive.d.ts +7 -17
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +111 -108
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/containment.d.ts +2 -0
- package/dist/containment.d.ts.map +1 -0
- package/dist/containment.js +1 -0
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +24 -2
- package/dist/durability.d.ts +2 -0
- package/dist/durability.d.ts.map +1 -1
- package/dist/durability.js +2 -0
- package/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/file-hash.d.ts +10 -0
- package/dist/file-hash.d.ts.map +1 -0
- package/dist/file-hash.js +73 -0
- package/dist/file-lock-sync.d.ts +35 -0
- package/dist/file-lock-sync.d.ts.map +1 -0
- package/dist/file-lock-sync.js +233 -0
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +1 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +4 -1
- package/dist/guarded-mkdir.d.ts +7 -1
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +45 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +30 -15
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +101 -17
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +79 -0
- package/dist/native-binding.d.ts.map +1 -0
- package/dist/native-binding.js +1 -0
- package/dist/native-config.d.ts +17 -0
- package/dist/native-config.d.ts.map +1 -0
- package/dist/native-config.js +71 -0
- package/dist/native-operations.d.ts +21 -0
- package/dist/native-operations.d.ts.map +1 -0
- package/dist/native-operations.js +115 -0
- package/dist/native-pinned-write.d.ts +5 -0
- package/dist/native-pinned-write.d.ts.map +1 -0
- package/dist/native-pinned-write.js +115 -0
- package/dist/native.d.ts +14 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +204 -0
- package/dist/output-sibling.d.ts +8 -0
- package/dist/output-sibling.d.ts.map +1 -0
- package/dist/output-sibling.js +122 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +32 -5
- package/dist/owner-dacl.d.ts +31 -0
- package/dist/owner-dacl.d.ts.map +1 -0
- package/dist/owner-dacl.js +27 -0
- package/dist/path-policy.js +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +3 -2
- package/dist/permissions-public.d.ts +2 -0
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-public.js +2 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +6 -0
- package/dist/pinned-operation.d.ts +2 -0
- package/dist/pinned-operation.d.ts.map +1 -0
- package/dist/pinned-operation.js +35 -0
- package/dist/pinned-write.d.ts +3 -16
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +20 -85
- package/dist/private-directory.d.ts +5 -0
- package/dist/private-directory.d.ts.map +1 -0
- package/dist/private-directory.js +13 -0
- package/dist/private-temp-workspace.d.ts +6 -2
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +40 -13
- package/dist/publish-file-failure.d.ts +27 -0
- package/dist/publish-file-failure.d.ts.map +1 -0
- package/dist/publish-file-failure.js +21 -0
- package/dist/publish-file.d.ts +21 -0
- package/dist/publish-file.d.ts.map +1 -0
- package/dist/publish-file.js +351 -0
- package/dist/read-opened-file.d.ts +3 -0
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +1 -0
- package/dist/replace-file-copy-fallback.d.ts +34 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -0
- package/dist/replace-file-copy-fallback.js +339 -0
- package/dist/replace-file.d.ts +6 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +39 -90
- package/dist/root-context.d.ts +4 -1
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +20 -1
- package/dist/root-impl.d.ts +5 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +128 -225
- package/dist/root-path-existing.d.ts +3 -0
- package/dist/root-path-existing.d.ts.map +1 -0
- package/dist/root-path-existing.js +69 -0
- package/dist/root-path.d.ts +1 -2
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +67 -81
- package/dist/root-walk.d.ts +41 -0
- package/dist/root-walk.d.ts.map +1 -0
- package/dist/root-walk.js +114 -0
- package/dist/root.d.ts +2 -0
- package/dist/root.d.ts.map +1 -1
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +16 -4
- package/dist/secret-file.d.ts +5 -2
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +20 -2
- package/dist/secret-read-async.d.ts +4 -0
- package/dist/secret-read-async.d.ts.map +1 -0
- package/dist/secret-read-async.js +95 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +1 -0
- package/dist/sidecar-lock-handle.d.ts +20 -0
- package/dist/sidecar-lock-handle.d.ts.map +1 -0
- package/dist/sidecar-lock-handle.js +28 -0
- package/dist/sidecar-lock-policy.d.ts +10 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -0
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts +22 -6
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +106 -20
- package/dist/sidecar-lock-types.d.ts +56 -0
- package/dist/sidecar-lock-types.d.ts.map +1 -0
- package/dist/sidecar-lock-types.js +1 -0
- package/dist/sidecar-lock.d.ts +2 -45
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +94 -55
- package/dist/temp-cleanup.d.ts +7 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +35 -5
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +51 -3
- package/dist/temp.d.ts +2 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +3 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-permissions-native.d.ts +9 -0
- package/dist/windows-permissions-native.d.ts.map +1 -0
- package/dist/windows-permissions-native.js +31 -0
- package/docs/archive.md +131 -13
- package/docs/atomic.md +30 -1
- package/docs/config.md +51 -21
- package/docs/durability.md +211 -1
- package/docs/errors.md +36 -6
- package/docs/filename.md +0 -0
- package/docs/index.md +11 -8
- package/docs/install.md +27 -26
- package/docs/json-store.md +18 -3
- package/docs/migrating-to-0.5.md +196 -0
- package/docs/native-helper.md +85 -0
- package/docs/native.md +134 -0
- package/docs/output.md +37 -11
- package/docs/path.md +1 -1
- package/docs/permissions.md +84 -1
- package/docs/quickstart.md +5 -2
- package/docs/reading.md +4 -4
- package/docs/root.md +31 -15
- package/docs/secret-file.md +50 -1
- package/docs/security-model.md +29 -8
- package/docs/sidecar-lock.md +92 -7
- package/docs/temp.md +24 -1
- package/docs/test-hooks.md +22 -2
- package/docs/testing.md +18 -7
- package/docs/types.md +3 -1
- package/docs/walk.md +55 -0
- package/docs/writing.md +3 -3
- package/package.json +10 -4
- package/dist/pinned-helper.d.ts +0 -9
- package/dist/pinned-helper.d.ts.map +0 -1
- package/dist/pinned-helper.js +0 -19
- package/dist/pinned-path.d.ts +0 -7
- package/dist/pinned-path.d.ts.map +0 -1
- package/dist/pinned-path.js +0 -21
- package/dist/pinned-python-config.d.ts +0 -9
- package/dist/pinned-python-config.d.ts.map +0 -1
- package/dist/pinned-python-config.js +0 -38
- package/dist/pinned-python.d.ts +0 -12
- package/dist/pinned-python.d.ts.map +0 -1
- package/dist/pinned-python.js +0 -688
- package/docs/python-helper.md +0 -103
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import syncFs, {} from "node:fs";
|
|
2
|
+
import { FsSafeError } from "./errors.js";
|
|
3
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
4
|
+
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
|
|
5
|
+
const NOFOLLOW = SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0;
|
|
6
|
+
const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | NOFOLLOW;
|
|
7
|
+
const OPEN_READ_WRITE_FLAGS = syncFs.constants.O_RDWR | NOFOLLOW;
|
|
8
|
+
const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY | syncFs.constants.O_CREAT | syncFs.constants.O_EXCL | NOFOLLOW;
|
|
9
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
10
|
+
function notFound(error) {
|
|
11
|
+
return error.code === "ENOENT";
|
|
12
|
+
}
|
|
13
|
+
function assertPinnedDestination(pathname, opened, dest, hardlinks) {
|
|
14
|
+
if (pathname.isSymbolicLink()) {
|
|
15
|
+
throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
|
|
16
|
+
}
|
|
17
|
+
if (!pathname.isFile() || !opened.isFile()) {
|
|
18
|
+
throw new FsSafeError("not-file", `Copy fallback destination must be a regular file: ${dest}`);
|
|
19
|
+
}
|
|
20
|
+
if (!sameFileIdentity(pathname, opened)) {
|
|
21
|
+
throw new FsSafeError("path-mismatch", `Copy fallback destination changed while opening: ${dest}`);
|
|
22
|
+
}
|
|
23
|
+
if (hardlinks === "reject" && opened.nlink > 1) {
|
|
24
|
+
throw new FsSafeError("hardlink", `Hardlinked copy fallback destination not allowed: ${dest}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function openPinnedDestination(fsModule, dest, hardlinks) {
|
|
28
|
+
const preview = await fsModule.lstat(dest).catch((error) => {
|
|
29
|
+
if (notFound(error))
|
|
30
|
+
return null;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
if (!preview)
|
|
34
|
+
return null;
|
|
35
|
+
if (preview.isSymbolicLink()) {
|
|
36
|
+
throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
|
|
37
|
+
}
|
|
38
|
+
const handle = await fsModule.open(dest, OPEN_READ_WRITE_FLAGS);
|
|
39
|
+
try {
|
|
40
|
+
const opened = await handle.stat();
|
|
41
|
+
const current = await fsModule.lstat(dest);
|
|
42
|
+
assertPinnedDestination(current, opened, dest, hardlinks);
|
|
43
|
+
return { handle, stat: opened };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await handle.close().catch(() => undefined);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function openPinnedDestinationSync(fsModule, dest, hardlinks) {
|
|
51
|
+
let preview;
|
|
52
|
+
try {
|
|
53
|
+
preview = fsModule.lstatSync(dest);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (notFound(error))
|
|
57
|
+
return null;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (preview.isSymbolicLink()) {
|
|
61
|
+
throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
|
|
62
|
+
}
|
|
63
|
+
const fd = fsModule.openSync(dest, OPEN_READ_WRITE_FLAGS);
|
|
64
|
+
try {
|
|
65
|
+
const opened = fsModule.fstatSync(fd);
|
|
66
|
+
assertPinnedDestination(fsModule.lstatSync(dest), opened, dest, hardlinks);
|
|
67
|
+
return { fd, stat: opened };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
fsModule.closeSync(fd);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function assertDestinationHardlinkPolicy(fsModule, dest, policy) {
|
|
75
|
+
if (policy !== "reject")
|
|
76
|
+
return;
|
|
77
|
+
const preview = await fsModule.lstat(dest).catch((error) => {
|
|
78
|
+
if (notFound(error))
|
|
79
|
+
return null;
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
if (!preview || preview.isSymbolicLink() || !preview.isFile())
|
|
83
|
+
return;
|
|
84
|
+
const handle = await fsModule.open(dest, OPEN_READ_FLAGS);
|
|
85
|
+
try {
|
|
86
|
+
const opened = await handle.stat();
|
|
87
|
+
const current = await fsModule.lstat(dest);
|
|
88
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(current, opened)) {
|
|
89
|
+
throw new FsSafeError("path-mismatch", `Atomic replace destination changed while opening: ${dest}`);
|
|
90
|
+
}
|
|
91
|
+
if (opened.nlink > 1) {
|
|
92
|
+
throw new FsSafeError("hardlink", `Hardlinked atomic replace destination not allowed: ${dest}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await handle.close().catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function assertDestinationHardlinkPolicySync(fsModule, dest, policy) {
|
|
100
|
+
if (policy !== "reject")
|
|
101
|
+
return;
|
|
102
|
+
let preview;
|
|
103
|
+
try {
|
|
104
|
+
preview = fsModule.lstatSync(dest);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (notFound(error))
|
|
108
|
+
return;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (preview.isSymbolicLink() || !preview.isFile())
|
|
112
|
+
return;
|
|
113
|
+
const fd = fsModule.openSync(dest, OPEN_READ_FLAGS);
|
|
114
|
+
try {
|
|
115
|
+
const opened = fsModule.fstatSync(fd);
|
|
116
|
+
const current = fsModule.lstatSync(dest);
|
|
117
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(current, opened)) {
|
|
118
|
+
throw new FsSafeError("path-mismatch", `Atomic replace destination changed while opening: ${dest}`);
|
|
119
|
+
}
|
|
120
|
+
if (opened.nlink > 1) {
|
|
121
|
+
throw new FsSafeError("hardlink", `Hardlinked atomic replace destination not allowed: ${dest}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fsModule.closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function readBounded(handle, maxBytes) {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let position = 0;
|
|
131
|
+
while (position <= maxBytes) {
|
|
132
|
+
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes - position + 1));
|
|
133
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
134
|
+
if (bytesRead === 0)
|
|
135
|
+
break;
|
|
136
|
+
position += bytesRead;
|
|
137
|
+
if (position > maxBytes) {
|
|
138
|
+
throw new FsSafeError("too-large", `Atomic replace restore snapshot exceeds maxRestoreBytes (${maxBytes})`);
|
|
139
|
+
}
|
|
140
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
141
|
+
}
|
|
142
|
+
return Buffer.concat(chunks, position);
|
|
143
|
+
}
|
|
144
|
+
function readBoundedSync(fsModule, fd, maxBytes) {
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let position = 0;
|
|
147
|
+
while (position <= maxBytes) {
|
|
148
|
+
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes - position + 1));
|
|
149
|
+
const bytesRead = fsModule.readSync(fd, buffer, 0, buffer.length, position);
|
|
150
|
+
if (bytesRead === 0)
|
|
151
|
+
break;
|
|
152
|
+
position += bytesRead;
|
|
153
|
+
if (position > maxBytes) {
|
|
154
|
+
throw new FsSafeError("too-large", `Atomic replace restore snapshot exceeds maxRestoreBytes (${maxBytes})`);
|
|
155
|
+
}
|
|
156
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
157
|
+
}
|
|
158
|
+
return Buffer.concat(chunks, position);
|
|
159
|
+
}
|
|
160
|
+
async function writeAll(handle, data) {
|
|
161
|
+
await handle.truncate(0);
|
|
162
|
+
let written = 0;
|
|
163
|
+
while (written < data.length) {
|
|
164
|
+
const result = await handle.write(data, written, data.length - written, written);
|
|
165
|
+
if (result.bytesWritten === 0)
|
|
166
|
+
throw new Error("Copy fallback write made no progress");
|
|
167
|
+
written += result.bytesWritten;
|
|
168
|
+
}
|
|
169
|
+
await handle.truncate(data.length);
|
|
170
|
+
}
|
|
171
|
+
function writeAllSync(fsModule, fd, data) {
|
|
172
|
+
fsModule.ftruncateSync(fd, 0);
|
|
173
|
+
let written = 0;
|
|
174
|
+
while (written < data.length) {
|
|
175
|
+
const bytesWritten = fsModule.writeSync(fd, data, written, data.length - written, written);
|
|
176
|
+
if (bytesWritten === 0)
|
|
177
|
+
throw new Error("Copy fallback write made no progress");
|
|
178
|
+
written += bytesWritten;
|
|
179
|
+
}
|
|
180
|
+
fsModule.ftruncateSync(fd, data.length);
|
|
181
|
+
}
|
|
182
|
+
function restoreFailure(writeError, cleanup, restoreError) {
|
|
183
|
+
const primary = writeError instanceof Error ? writeError : new Error(String(writeError));
|
|
184
|
+
const details = { cleanup };
|
|
185
|
+
const cause = restoreError
|
|
186
|
+
? new AggregateError([primary, restoreError], "copy fallback and original restoration both failed")
|
|
187
|
+
: primary;
|
|
188
|
+
return new FsSafeError("helper-failed", cleanup === "restored"
|
|
189
|
+
? `Atomic copy fallback failed; original destination restored: ${primary.message}`
|
|
190
|
+
: `Atomic copy fallback failed and original restoration failed: ${primary.message}`, { cause, details });
|
|
191
|
+
}
|
|
192
|
+
async function replacePinnedWithRestore(handle, replacement, maxRestoreBytes) {
|
|
193
|
+
const original = await readBounded(handle, maxRestoreBytes);
|
|
194
|
+
try {
|
|
195
|
+
await writeAll(handle, replacement);
|
|
196
|
+
await handle.sync();
|
|
197
|
+
}
|
|
198
|
+
catch (writeError) {
|
|
199
|
+
try {
|
|
200
|
+
await writeAll(handle, original);
|
|
201
|
+
await handle.sync();
|
|
202
|
+
throw restoreFailure(writeError, "restored");
|
|
203
|
+
}
|
|
204
|
+
catch (restoreError) {
|
|
205
|
+
if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
|
|
206
|
+
throw restoreError;
|
|
207
|
+
}
|
|
208
|
+
throw restoreFailure(writeError, "restore-failed", restoreError);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes) {
|
|
213
|
+
const original = readBoundedSync(fsModule, fd, maxRestoreBytes);
|
|
214
|
+
try {
|
|
215
|
+
writeAllSync(fsModule, fd, replacement);
|
|
216
|
+
fsModule.fsyncSync(fd);
|
|
217
|
+
}
|
|
218
|
+
catch (writeError) {
|
|
219
|
+
try {
|
|
220
|
+
writeAllSync(fsModule, fd, original);
|
|
221
|
+
fsModule.fsyncSync(fd);
|
|
222
|
+
throw restoreFailure(writeError, "restored");
|
|
223
|
+
}
|
|
224
|
+
catch (restoreError) {
|
|
225
|
+
if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
|
|
226
|
+
throw restoreError;
|
|
227
|
+
}
|
|
228
|
+
throw restoreFailure(writeError, "restore-failed", restoreError);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export async function copyFallbackReplace(params) {
|
|
233
|
+
const sourcePreview = await params.fsModule.lstat(params.src);
|
|
234
|
+
if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
|
|
235
|
+
throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
|
|
236
|
+
}
|
|
237
|
+
const sourceHandle = await params.fsModule.open(params.src, OPEN_READ_FLAGS);
|
|
238
|
+
let destHandle = null;
|
|
239
|
+
try {
|
|
240
|
+
const sourceStat = await sourceHandle.stat();
|
|
241
|
+
const sourceCurrent = await params.fsModule.lstat(params.src);
|
|
242
|
+
if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
|
|
243
|
+
throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
|
|
244
|
+
}
|
|
245
|
+
const replacement = await sourceHandle.readFile();
|
|
246
|
+
if (params.restore === "restore-original") {
|
|
247
|
+
const pinned = await openPinnedDestination(params.fsModule, params.dest, params.destinationHardlinks);
|
|
248
|
+
if (pinned) {
|
|
249
|
+
destHandle = pinned.handle;
|
|
250
|
+
await replacePinnedWithRestore(destHandle, replacement, params.maxRestoreBytes);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!destHandle) {
|
|
254
|
+
const destStat = await params.fsModule.lstat(params.dest).catch((error) => {
|
|
255
|
+
if (notFound(error))
|
|
256
|
+
return null;
|
|
257
|
+
throw error;
|
|
258
|
+
});
|
|
259
|
+
if (destStat?.isSymbolicLink()) {
|
|
260
|
+
throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${params.dest}`);
|
|
261
|
+
}
|
|
262
|
+
if (destStat) {
|
|
263
|
+
await assertDestinationHardlinkPolicy(params.fsModule, params.dest, params.destinationHardlinks);
|
|
264
|
+
await params.fsModule.rm(params.dest, { force: true });
|
|
265
|
+
}
|
|
266
|
+
destHandle = await params.fsModule.open(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
|
|
267
|
+
await destHandle.writeFile(replacement);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await destHandle?.close().catch(() => undefined);
|
|
272
|
+
await sourceHandle.close().catch(() => undefined);
|
|
273
|
+
}
|
|
274
|
+
await params.fsModule.unlink(params.src).catch(() => undefined);
|
|
275
|
+
}
|
|
276
|
+
export function copyFallbackReplaceSync(params) {
|
|
277
|
+
const sourcePreview = params.fsModule.lstatSync(params.src);
|
|
278
|
+
if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
|
|
279
|
+
throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
|
|
280
|
+
}
|
|
281
|
+
const sourceFd = params.fsModule.openSync(params.src, OPEN_READ_FLAGS);
|
|
282
|
+
let destFd;
|
|
283
|
+
try {
|
|
284
|
+
const sourceStat = params.fsModule.fstatSync(sourceFd);
|
|
285
|
+
const sourceCurrent = params.fsModule.lstatSync(params.src);
|
|
286
|
+
if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
|
|
287
|
+
throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
|
|
288
|
+
}
|
|
289
|
+
const replacement = readBoundedSync(params.fsModule, sourceFd, Number.MAX_SAFE_INTEGER);
|
|
290
|
+
if (params.restore === "restore-original") {
|
|
291
|
+
const pinned = openPinnedDestinationSync(params.fsModule, params.dest, params.destinationHardlinks);
|
|
292
|
+
if (pinned) {
|
|
293
|
+
destFd = pinned.fd;
|
|
294
|
+
replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (destFd === undefined) {
|
|
298
|
+
let destStat = null;
|
|
299
|
+
try {
|
|
300
|
+
destStat = params.fsModule.lstatSync(params.dest);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (!notFound(error))
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
if (destStat?.isSymbolicLink()) {
|
|
307
|
+
throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${params.dest}`);
|
|
308
|
+
}
|
|
309
|
+
if (destStat) {
|
|
310
|
+
assertDestinationHardlinkPolicySync(params.fsModule, params.dest, params.destinationHardlinks);
|
|
311
|
+
params.fsModule.rmSync(params.dest, { force: true });
|
|
312
|
+
}
|
|
313
|
+
destFd = params.fsModule.openSync(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
|
|
314
|
+
writeAllSync(params.fsModule, destFd, replacement);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
if (destFd !== undefined) {
|
|
319
|
+
try {
|
|
320
|
+
params.fsModule.closeSync(destFd);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Best-effort close after fallback replacement.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
params.fsModule.closeSync(sourceFd);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Best-effort close after fallback replacement.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
params.fsModule.unlinkSync(params.src);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Best-effort cleanup after fallback replacement.
|
|
338
|
+
}
|
|
339
|
+
}
|
package/dist/replace-file.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import syncFs from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import { type ReplaceFileAtomicRestoreCleanup, type ReplaceFileAtomicRestoreFailureDetails, type ReplaceFileCopyFallbackRestorePolicy, type ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
|
|
3
4
|
export type ReplaceFileAtomicFileSystem = {
|
|
4
5
|
promises: Pick<typeof fs, "mkdir" | "chmod" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat">;
|
|
5
6
|
};
|
|
6
|
-
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
|
|
7
|
+
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync">;
|
|
8
|
+
export type { ReplaceFileAtomicRestoreCleanup, ReplaceFileAtomicRestoreFailureDetails, ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy, };
|
|
7
9
|
type ReplaceFileAtomicBaseOptions = {
|
|
8
10
|
filePath: string;
|
|
9
11
|
content: string | Uint8Array;
|
|
@@ -14,6 +16,9 @@ type ReplaceFileAtomicBaseOptions = {
|
|
|
14
16
|
renameMaxRetries?: number;
|
|
15
17
|
renameRetryBaseDelayMs?: number;
|
|
16
18
|
copyFallbackOnPermissionError?: boolean;
|
|
19
|
+
copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
|
|
20
|
+
maxRestoreBytes?: number;
|
|
21
|
+
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
|
|
17
22
|
syncTempFile?: boolean;
|
|
18
23
|
syncParentDir?: boolean;
|
|
19
24
|
throwOnCleanupError?: boolean;
|
|
@@ -37,5 +42,4 @@ export type ReplaceFileAtomicResult = {
|
|
|
37
42
|
};
|
|
38
43
|
export declare function replaceFileAtomic(options: ReplaceFileAtomicOptions): Promise<ReplaceFileAtomicResult>;
|
|
39
44
|
export declare function replaceFileAtomicSync(options: ReplaceFileAtomicSyncOptions): ReplaceFileAtomicResult;
|
|
40
|
-
export {};
|
|
41
45
|
//# sourceMappingURL=replace-file.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAElC,OAAO,EAKL,KAAK,+BAA+B,EACpC,KAAK,sCAAsC,EAC3C,KAAK,oCAAoC,EACzC,KAAK,oCAAoC,EAC1C,MAAM,iCAAiC,CAAC;AAKzC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,cAAc,GACd,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,GACX,eAAe,GACf,UAAU,GACV,WAAW,CACd,CAAC;AAEF,YAAY,EACV,+BAA+B,EAC/B,sCAAsC,EACtC,oCAAoC,EACpC,oCAAoC,GACrC,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,mBAAmB,CAAC,EAAE,oCAAoC,CAAC;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oBAAoB,CAAC,EAAE,oCAAoC,CAAC;IAC5D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AAoOF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAOlC;AA6DD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAsEzB"}
|
package/dist/replace-file.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import syncFs from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { assertDestinationHardlinkPolicy, assertDestinationHardlinkPolicySync, copyFallbackReplace, copyFallbackReplaceSync, } from "./replace-file-copy-fallback.js";
|
|
5
6
|
import { assertSafePathPrefix } from "./safe-path-segment.js";
|
|
6
7
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
7
8
|
import { serializePathWrite } from "./write-queue.js";
|
|
@@ -12,12 +13,6 @@ function isPermissionRenameError(error) {
|
|
|
12
13
|
const code = error.code;
|
|
13
14
|
return code === "EPERM" || code === "EEXIST";
|
|
14
15
|
}
|
|
15
|
-
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
|
|
16
|
-
const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
|
|
17
|
-
const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY |
|
|
18
|
-
syncFs.constants.O_CREAT |
|
|
19
|
-
syncFs.constants.O_EXCL |
|
|
20
|
-
(SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
|
|
21
16
|
async function sleep(ms) {
|
|
22
17
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
18
|
}
|
|
@@ -33,7 +28,14 @@ async function renameWithRetry(params) {
|
|
|
33
28
|
continue;
|
|
34
29
|
}
|
|
35
30
|
if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
|
|
36
|
-
await copyFallbackReplace(
|
|
31
|
+
await copyFallbackReplace({
|
|
32
|
+
fsModule: params.fsModule,
|
|
33
|
+
src: params.src,
|
|
34
|
+
dest: params.dest,
|
|
35
|
+
destinationHardlinks: params.destinationHardlinks,
|
|
36
|
+
restore: params.copyFallbackRestore,
|
|
37
|
+
maxRestoreBytes: params.maxRestoreBytes,
|
|
38
|
+
});
|
|
37
39
|
return { method: "copy-fallback" };
|
|
38
40
|
}
|
|
39
41
|
throw error;
|
|
@@ -59,7 +61,14 @@ function renameWithRetrySync(params) {
|
|
|
59
61
|
continue;
|
|
60
62
|
}
|
|
61
63
|
if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
|
|
62
|
-
copyFallbackReplaceSync(
|
|
64
|
+
copyFallbackReplaceSync({
|
|
65
|
+
fsModule: params.fsModule,
|
|
66
|
+
src: params.src,
|
|
67
|
+
dest: params.dest,
|
|
68
|
+
destinationHardlinks: params.destinationHardlinks,
|
|
69
|
+
restore: params.copyFallbackRestore,
|
|
70
|
+
maxRestoreBytes: params.maxRestoreBytes,
|
|
71
|
+
});
|
|
63
72
|
return { method: "copy-fallback" };
|
|
64
73
|
}
|
|
65
74
|
throw error;
|
|
@@ -67,93 +76,21 @@ function renameWithRetrySync(params) {
|
|
|
67
76
|
}
|
|
68
77
|
throw new Error("Atomic rename retry loop exhausted.");
|
|
69
78
|
}
|
|
70
|
-
async function copyFallbackReplace(fsModule, src, dest) {
|
|
71
|
-
const sourceStat = await fsModule.lstat(src);
|
|
72
|
-
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
|
73
|
-
throw new Error(`Refusing copy fallback from non-file source: ${src}`);
|
|
74
|
-
}
|
|
75
|
-
const destStat = await fsModule.lstat(dest).catch((lstatError) => {
|
|
76
|
-
if (lstatError.code === "ENOENT") {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
throw lstatError;
|
|
80
|
-
});
|
|
81
|
-
if (destStat?.isSymbolicLink()) {
|
|
82
|
-
throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
|
|
83
|
-
}
|
|
84
|
-
if (destStat) {
|
|
85
|
-
await fsModule.rm(dest, { force: true });
|
|
86
|
-
}
|
|
87
|
-
let sourceHandle = null;
|
|
88
|
-
let destHandle = null;
|
|
89
|
-
try {
|
|
90
|
-
sourceHandle = await fsModule.open(src, OPEN_READ_FLAGS);
|
|
91
|
-
destHandle = await fsModule.open(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
|
|
92
|
-
await destHandle.writeFile(await sourceHandle.readFile());
|
|
93
|
-
}
|
|
94
|
-
finally {
|
|
95
|
-
await destHandle?.close().catch(() => undefined);
|
|
96
|
-
await sourceHandle?.close().catch(() => undefined);
|
|
97
|
-
}
|
|
98
|
-
await fsModule.unlink(src).catch(() => undefined);
|
|
99
|
-
}
|
|
100
|
-
function copyFallbackReplaceSync(fsModule, src, dest) {
|
|
101
|
-
const sourceStat = fsModule.lstatSync(src);
|
|
102
|
-
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
|
103
|
-
throw new Error(`Refusing copy fallback from non-file source: ${src}`);
|
|
104
|
-
}
|
|
105
|
-
let destStat = null;
|
|
106
|
-
try {
|
|
107
|
-
destStat = fsModule.lstatSync(dest);
|
|
108
|
-
}
|
|
109
|
-
catch (lstatError) {
|
|
110
|
-
if (lstatError.code !== "ENOENT") {
|
|
111
|
-
throw lstatError;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (destStat?.isSymbolicLink()) {
|
|
115
|
-
throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
|
|
116
|
-
}
|
|
117
|
-
if (destStat) {
|
|
118
|
-
fsModule.rmSync(dest, { force: true });
|
|
119
|
-
}
|
|
120
|
-
let sourceFd;
|
|
121
|
-
let destFd;
|
|
122
|
-
try {
|
|
123
|
-
sourceFd = fsModule.openSync(src, OPEN_READ_FLAGS);
|
|
124
|
-
destFd = fsModule.openSync(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
|
|
125
|
-
fsModule.writeFileSync(destFd, fsModule.readFileSync(sourceFd));
|
|
126
|
-
}
|
|
127
|
-
finally {
|
|
128
|
-
if (destFd !== undefined) {
|
|
129
|
-
try {
|
|
130
|
-
fsModule.closeSync(destFd);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
// Best-effort close after fallback replacement.
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (sourceFd !== undefined) {
|
|
137
|
-
try {
|
|
138
|
-
fsModule.closeSync(sourceFd);
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
// Best-effort close after fallback replacement.
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
fsModule.unlinkSync(src);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
// Best-effort cleanup after fallback replacement.
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
79
|
function validateReplaceFilePath(filePath) {
|
|
153
80
|
if (!filePath || filePath.includes("\0")) {
|
|
154
81
|
throw new Error("Atomic replace file path must be non-empty.");
|
|
155
82
|
}
|
|
156
83
|
}
|
|
84
|
+
function validateRestoreOptions(options) {
|
|
85
|
+
if (options.copyFallbackRestore !== "restore-original")
|
|
86
|
+
return;
|
|
87
|
+
if (options.maxRestoreBytes === undefined) {
|
|
88
|
+
throw new RangeError("maxRestoreBytes is required when copyFallbackRestore is restore-original");
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isSafeInteger(options.maxRestoreBytes) || options.maxRestoreBytes < 0) {
|
|
91
|
+
throw new RangeError("maxRestoreBytes must be a non-negative safe integer");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
157
94
|
function buildReplaceTempPath(filePath, tempPrefix) {
|
|
158
95
|
const dir = path.dirname(filePath);
|
|
159
96
|
const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-replace", { label: "atomic replace temp prefix" });
|
|
@@ -261,6 +198,7 @@ async function cleanupTempFile(params) {
|
|
|
261
198
|
export async function replaceFileAtomic(options) {
|
|
262
199
|
const filePath = options.filePath;
|
|
263
200
|
validateReplaceFilePath(filePath);
|
|
201
|
+
validateRestoreOptions(options);
|
|
264
202
|
return await serializePathWrite(path.resolve(filePath), async () => {
|
|
265
203
|
return await replaceFileAtomicUnserialized(options);
|
|
266
204
|
});
|
|
@@ -280,12 +218,14 @@ async function replaceFileAtomicUnserialized(options) {
|
|
|
280
218
|
try {
|
|
281
219
|
tempExists = true;
|
|
282
220
|
await fsModule.writeFile(tempPath, options.content, { mode, flag: "wx" });
|
|
221
|
+
unregisterTempPath.setIdentity(await fsModule.lstat(tempPath));
|
|
283
222
|
if (options.syncTempFile) {
|
|
284
223
|
await syncTempFile(fsModule, tempPath);
|
|
285
224
|
}
|
|
286
225
|
if (options.beforeRename) {
|
|
287
226
|
await options.beforeRename({ filePath, tempPath });
|
|
288
227
|
}
|
|
228
|
+
await assertDestinationHardlinkPolicy(fsModule, filePath, options.destinationHardlinks);
|
|
289
229
|
const result = await renameWithRetry({
|
|
290
230
|
fsModule,
|
|
291
231
|
src: tempPath,
|
|
@@ -293,6 +233,9 @@ async function replaceFileAtomicUnserialized(options) {
|
|
|
293
233
|
maxRetries: options.renameMaxRetries ?? 0,
|
|
294
234
|
baseDelayMs: options.renameRetryBaseDelayMs ?? 50,
|
|
295
235
|
copyFallbackOnPermissionError: options.copyFallbackOnPermissionError === true,
|
|
236
|
+
copyFallbackRestore: options.copyFallbackRestore ?? "none",
|
|
237
|
+
maxRestoreBytes: options.maxRestoreBytes,
|
|
238
|
+
destinationHardlinks: options.destinationHardlinks,
|
|
296
239
|
});
|
|
297
240
|
tempExists = false;
|
|
298
241
|
unregisterTempPath();
|
|
@@ -321,6 +264,7 @@ async function replaceFileAtomicUnserialized(options) {
|
|
|
321
264
|
export function replaceFileAtomicSync(options) {
|
|
322
265
|
const filePath = options.filePath;
|
|
323
266
|
validateReplaceFilePath(filePath);
|
|
267
|
+
validateRestoreOptions(options);
|
|
324
268
|
const fsModule = options.fileSystem ?? syncFs;
|
|
325
269
|
const dir = path.dirname(filePath);
|
|
326
270
|
const dirMode = options.dirMode ?? 0o700;
|
|
@@ -339,12 +283,14 @@ export function replaceFileAtomicSync(options) {
|
|
|
339
283
|
try {
|
|
340
284
|
tempExists = true;
|
|
341
285
|
fsModule.writeFileSync(tempPath, options.content, { mode, flag: "wx" });
|
|
286
|
+
unregisterTempPath.setIdentity(fsModule.lstatSync(tempPath));
|
|
342
287
|
if (options.syncTempFile) {
|
|
343
288
|
syncTempFileSync(fsModule, tempPath);
|
|
344
289
|
}
|
|
345
290
|
if (options.beforeRename) {
|
|
346
291
|
options.beforeRename({ filePath, tempPath });
|
|
347
292
|
}
|
|
293
|
+
assertDestinationHardlinkPolicySync(fsModule, filePath, options.destinationHardlinks);
|
|
348
294
|
const result = renameWithRetrySync({
|
|
349
295
|
fsModule,
|
|
350
296
|
src: tempPath,
|
|
@@ -352,6 +298,9 @@ export function replaceFileAtomicSync(options) {
|
|
|
352
298
|
maxRetries: options.renameMaxRetries ?? 0,
|
|
353
299
|
baseDelayMs: options.renameRetryBaseDelayMs ?? 50,
|
|
354
300
|
copyFallbackOnPermissionError: options.copyFallbackOnPermissionError === true,
|
|
301
|
+
copyFallbackRestore: options.copyFallbackRestore ?? "none",
|
|
302
|
+
maxRestoreBytes: options.maxRestoreBytes,
|
|
303
|
+
destinationHardlinks: options.destinationHardlinks,
|
|
355
304
|
});
|
|
356
305
|
tempExists = false;
|
|
357
306
|
unregisterTempPath();
|
package/dist/root-context.d.ts
CHANGED
|
@@ -7,7 +7,10 @@ export declare const ensureTrailingSep: (value: string) => string;
|
|
|
7
7
|
export declare function assertValidRootRelativePath(relativePath: string): void;
|
|
8
8
|
export declare function expandRelativePathWithHome(relativePath: string): Promise<string>;
|
|
9
9
|
export declare function resolveRootContext(rootDir: string): Promise<RootContext>;
|
|
10
|
-
export declare function resolvePathInRoot(root: RootContext, relativePath: string
|
|
10
|
+
export declare function resolvePathInRoot(root: RootContext, relativePath: string, options?: {
|
|
11
|
+
aliasErrorCode?: "outside-workspace" | "path-alias";
|
|
12
|
+
allowFinalSymlink?: boolean;
|
|
13
|
+
}): Promise<{
|
|
11
14
|
rootReal: string;
|
|
12
15
|
rootWithSep: string;
|
|
13
16
|
resolved: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,MAAM,WACM,CAAC;AAEtD,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAsB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYtF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAuB9E;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE;IACR,cAAc,CAAC,EAAE,mBAAmB,GAAG,YAAY,CAAC;IACpD,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B,GACA,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CA6BtE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAEvE"}
|
package/dist/root-context.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { expandHomePrefix } from "./home-dir.js";
|
|
6
6
|
import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js";
|
|
7
|
+
import { ROOT_PATH_ALIAS_POLICIES, resolveRootPath } from "./root-path.js";
|
|
7
8
|
export const ensureTrailingSep = (value) => value.endsWith(path.sep) ? value : value + path.sep;
|
|
8
9
|
export function assertValidRootRelativePath(relativePath) {
|
|
9
10
|
assertNoNulPathInput(relativePath, "relative path contains a NUL byte");
|
|
@@ -48,13 +49,31 @@ export async function resolveRootContext(rootDir) {
|
|
|
48
49
|
rootWithSep: ensureTrailingSep(rootReal),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
export async function resolvePathInRoot(root, relativePath) {
|
|
52
|
+
export async function resolvePathInRoot(root, relativePath, options) {
|
|
52
53
|
assertValidRootRelativePath(relativePath);
|
|
53
54
|
const expanded = await expandRelativePathWithHome(relativePath);
|
|
54
55
|
const resolved = path.resolve(root.rootWithSep, expanded);
|
|
55
56
|
if (!isPathInside(root.rootWithSep, resolved)) {
|
|
56
57
|
throw new FsSafeError("outside-workspace", "file is outside workspace root");
|
|
57
58
|
}
|
|
59
|
+
const rawAbsolutePath = path.isAbsolute(expanded)
|
|
60
|
+
? expanded
|
|
61
|
+
: `${root.rootWithSep}${expanded}`;
|
|
62
|
+
try {
|
|
63
|
+
await resolveRootPath({
|
|
64
|
+
absolutePath: rawAbsolutePath,
|
|
65
|
+
rootPath: root.rootReal,
|
|
66
|
+
rootCanonicalPath: root.rootReal,
|
|
67
|
+
boundaryLabel: "root",
|
|
68
|
+
policy: options?.allowFinalSymlink ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const code = options?.aliasErrorCode ?? "outside-workspace";
|
|
73
|
+
throw new FsSafeError(code, code === "path-alias" ? "path alias escape blocked" : "file is outside workspace root", {
|
|
74
|
+
cause: error instanceof Error ? error : undefined,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
58
77
|
return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved };
|
|
59
78
|
}
|
|
60
79
|
export async function resolvePathWithinRoot(params) {
|