@openclaw/fs-safe 0.1.2 → 0.2.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 +51 -0
- package/README.md +39 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +60 -53
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +38 -33
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +62 -53
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +28 -7
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
4
|
+
import { replaceFileAtomic } from "./replace-file.js";
|
|
5
|
+
import { assertSafePathSegment } from "./safe-path-segment.js";
|
|
6
|
+
export const DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
function getErrnoCode(error) {
|
|
8
|
+
return error && typeof error === "object" && "code" in error
|
|
9
|
+
? String(error.code)
|
|
10
|
+
: null;
|
|
11
|
+
}
|
|
12
|
+
function assertSafeQueueEntryId(id) {
|
|
13
|
+
assertSafePathSegment(id, { label: "queue entry id" });
|
|
14
|
+
}
|
|
15
|
+
export async function unlinkBestEffort(filePath) {
|
|
16
|
+
await fs.promises.unlink(filePath).catch(() => undefined);
|
|
17
|
+
}
|
|
18
|
+
export async function jsonDurableQueueEntryExists(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
const stat = await fs.promises.lstat(filePath);
|
|
21
|
+
return stat.isFile();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (getErrnoCode(error) === "ENOENT") {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function unlinkStaleTmpBestEffort(filePath, now, maxAgeMs) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = await fs.promises.stat(filePath);
|
|
33
|
+
if (stat.isFile() && now - stat.mtimeMs >= maxAgeMs) {
|
|
34
|
+
await unlinkBestEffort(filePath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (getErrnoCode(error) !== "ENOENT") {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function resolveJsonDurableQueueEntryPaths(queueDir, id) {
|
|
44
|
+
assertSafeQueueEntryId(id);
|
|
45
|
+
return {
|
|
46
|
+
jsonPath: path.join(queueDir, `${id}.json`),
|
|
47
|
+
deliveredPath: path.join(queueDir, `${id}.delivered`),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function ensureJsonDurableQueueDirs(params) {
|
|
51
|
+
await fs.promises.mkdir(params.queueDir, { recursive: true, mode: 0o700 });
|
|
52
|
+
await fs.promises.mkdir(params.failedDir, { recursive: true, mode: 0o700 });
|
|
53
|
+
}
|
|
54
|
+
export async function writeJsonDurableQueueEntry(params) {
|
|
55
|
+
await replaceFileAtomic({
|
|
56
|
+
filePath: params.filePath,
|
|
57
|
+
content: JSON.stringify(params.entry, null, 2),
|
|
58
|
+
mode: 0o600,
|
|
59
|
+
tempPrefix: params.tempPrefix,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function readBoundedUtf8File(params) {
|
|
63
|
+
const initialStat = await fs.promises.lstat(params.filePath);
|
|
64
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
|
|
65
|
+
throw new Error("queue entry is not a regular file");
|
|
66
|
+
}
|
|
67
|
+
if (initialStat.size > params.maxBytes) {
|
|
68
|
+
throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
|
|
69
|
+
}
|
|
70
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" && process.platform !== "win32"
|
|
71
|
+
? fs.constants.O_NOFOLLOW
|
|
72
|
+
: 0;
|
|
73
|
+
const handle = await fs.promises.open(params.filePath, fs.constants.O_RDONLY | noFollow);
|
|
74
|
+
try {
|
|
75
|
+
const openedStat = await handle.stat();
|
|
76
|
+
const pathStat = await fs.promises.lstat(params.filePath);
|
|
77
|
+
if (!openedStat.isFile() ||
|
|
78
|
+
pathStat.isSymbolicLink() ||
|
|
79
|
+
!pathStat.isFile() ||
|
|
80
|
+
!sameFileIdentity(initialStat, openedStat) ||
|
|
81
|
+
!sameFileIdentity(pathStat, openedStat)) {
|
|
82
|
+
throw new Error("queue entry changed during read");
|
|
83
|
+
}
|
|
84
|
+
const chunks = [];
|
|
85
|
+
const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, params.maxBytes + 1));
|
|
86
|
+
let total = 0;
|
|
87
|
+
while (true) {
|
|
88
|
+
const { bytesRead } = await handle.read(scratch, 0, scratch.length, null);
|
|
89
|
+
if (bytesRead === 0) {
|
|
90
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
91
|
+
}
|
|
92
|
+
total += bytesRead;
|
|
93
|
+
if (total > params.maxBytes) {
|
|
94
|
+
throw new Error(`queue entry exceeds ${params.maxBytes} bytes`);
|
|
95
|
+
}
|
|
96
|
+
chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await handle.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function readJsonDurableQueueEntry(filePath, options = {}) {
|
|
104
|
+
return JSON.parse(await readBoundedUtf8File({
|
|
105
|
+
filePath,
|
|
106
|
+
maxBytes: options.maxBytes ?? DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
export async function ackJsonDurableQueueEntry(paths) {
|
|
110
|
+
try {
|
|
111
|
+
await fs.promises.rename(paths.jsonPath, paths.deliveredPath);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (getErrnoCode(error) === "ENOENT") {
|
|
115
|
+
await unlinkBestEffort(paths.deliveredPath);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
await unlinkBestEffort(paths.deliveredPath);
|
|
121
|
+
}
|
|
122
|
+
export async function loadJsonDurableQueueEntry(params) {
|
|
123
|
+
try {
|
|
124
|
+
const stat = await fs.promises.lstat(params.paths.jsonPath);
|
|
125
|
+
if (!stat.isFile()) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const raw = await readJsonDurableQueueEntry(params.paths.jsonPath, {
|
|
129
|
+
maxBytes: params.maxBytes,
|
|
130
|
+
});
|
|
131
|
+
const result = params.read ? await params.read(raw, params.paths.jsonPath) : { entry: raw };
|
|
132
|
+
if (result.migrated) {
|
|
133
|
+
await writeJsonDurableQueueEntry({
|
|
134
|
+
filePath: params.paths.jsonPath,
|
|
135
|
+
entry: result.entry,
|
|
136
|
+
tempPrefix: params.tempPrefix,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return result.entry;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (getErrnoCode(error) === "ENOENT") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function loadPendingJsonDurableQueueEntries(options) {
|
|
149
|
+
let files;
|
|
150
|
+
try {
|
|
151
|
+
files = await fs.promises.readdir(options.queueDir);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (getErrnoCode(error) === "ENOENT") {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
if (file.endsWith(".delivered")) {
|
|
162
|
+
await unlinkBestEffort(path.join(options.queueDir, file));
|
|
163
|
+
}
|
|
164
|
+
else if (options.cleanupTmpMaxAgeMs !== undefined && file.endsWith(".tmp")) {
|
|
165
|
+
await unlinkStaleTmpBestEffort(path.join(options.queueDir, file), now, options.cleanupTmpMaxAgeMs);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const entries = [];
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
if (!file.endsWith(".json")) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const filePath = path.join(options.queueDir, file);
|
|
174
|
+
try {
|
|
175
|
+
const stat = await fs.promises.lstat(filePath);
|
|
176
|
+
if (!stat.isFile()) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const raw = await readJsonDurableQueueEntry(filePath, { maxBytes: options.maxBytes });
|
|
180
|
+
const result = options.read ? await options.read(raw, filePath) : { entry: raw };
|
|
181
|
+
if (result.migrated) {
|
|
182
|
+
await writeJsonDurableQueueEntry({
|
|
183
|
+
filePath,
|
|
184
|
+
entry: result.entry,
|
|
185
|
+
tempPrefix: options.tempPrefix,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
entries.push(result.entry);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return entries;
|
|
195
|
+
}
|
|
196
|
+
export async function moveJsonDurableQueueEntryToFailed(params) {
|
|
197
|
+
assertSafeQueueEntryId(params.id);
|
|
198
|
+
await fs.promises.mkdir(params.failedDir, { recursive: true, mode: 0o700 });
|
|
199
|
+
await fs.promises.rename(path.join(params.queueDir, `${params.id}.json`), path.join(params.failedDir, `${params.id}.json`));
|
|
200
|
+
}
|
package/dist/json.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import { type RootFileOpenFailure } from "./root-file.js";
|
|
3
|
+
import { type WriteTextAtomicOptions } from "./text-atomic.js";
|
|
1
4
|
export declare function tryReadJsonSync<T = unknown>(pathname: string): T | null;
|
|
2
5
|
export declare function writeJsonSync(pathname: string, data: unknown): void;
|
|
3
6
|
export declare class JsonFileReadError extends Error {
|
|
@@ -5,13 +8,40 @@ export declare class JsonFileReadError extends Error {
|
|
|
5
8
|
readonly reason: "read" | "parse";
|
|
6
9
|
constructor(filePath: string, reason: "read" | "parse", cause: unknown);
|
|
7
10
|
}
|
|
11
|
+
export type RootStructuredFileReadResult<T> = {
|
|
12
|
+
ok: true;
|
|
13
|
+
value: T;
|
|
14
|
+
stat: fsSync.Stats;
|
|
15
|
+
path: string;
|
|
16
|
+
rootRealPath: string;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: "open";
|
|
20
|
+
failure: RootFileOpenFailure;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: "invalid" | "parse";
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
export type ReadRootStructuredFileSyncOptions<T> = {
|
|
27
|
+
rootDir: string;
|
|
28
|
+
rootRealPath?: string;
|
|
29
|
+
relativePath: string;
|
|
30
|
+
boundaryLabel: string;
|
|
31
|
+
rejectHardlinks?: boolean;
|
|
32
|
+
maxBytes?: number;
|
|
33
|
+
parse: (raw: string) => unknown;
|
|
34
|
+
validate?: (value: unknown) => value is T;
|
|
35
|
+
invalidMessage?: string | ((relativePath: string) => string);
|
|
36
|
+
};
|
|
37
|
+
export type ReadRootJsonSyncOptions = Omit<ReadRootStructuredFileSyncOptions<unknown>, "parse" | "validate" | "invalidMessage">;
|
|
38
|
+
export declare function readRootStructuredFileSync<T>(options: ReadRootStructuredFileSyncOptions<T>): RootStructuredFileReadResult<T>;
|
|
39
|
+
export declare function readRootJsonSync<T = unknown>(options: ReadRootJsonSyncOptions): RootStructuredFileReadResult<T>;
|
|
40
|
+
export declare function readRootJsonObjectSync(options: ReadRootJsonSyncOptions): RootStructuredFileReadResult<Record<string, unknown>>;
|
|
8
41
|
export declare function tryReadJson<T>(filePath: string): Promise<T | null>;
|
|
9
42
|
export declare function readJson<T>(filePath: string): Promise<T>;
|
|
10
43
|
export declare function readJsonIfExists<T>(filePath: string): Promise<T | null>;
|
|
11
44
|
export declare function readJsonSync<T = unknown>(filePath: string): T;
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
trailingNewline?: boolean;
|
|
15
|
-
dirMode?: number;
|
|
16
|
-
}): Promise<void>;
|
|
45
|
+
export type WriteJsonOptions = Pick<WriteTextAtomicOptions, "dirMode" | "durable" | "mode" | "trailingNewline">;
|
|
46
|
+
export declare function writeJson(filePath: string, value: unknown, options?: WriteJsonOptions): Promise<void>;
|
|
17
47
|
//# sourceMappingURL=json.d.ts.map
|
package/dist/json.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAG7B,OAAO,EAAoB,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC5E,OAAO,EAAmB,KAAK,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AA0FhF,wBAAgB,eAAe,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAOvE;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAmB5D;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;gBAEtB,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,EAAE,KAAK,EAAE,OAAO;CAMvE;AAED,MAAM,MAAM,4BAA4B,CAAC,CAAC,IACtC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAC9E;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,mBAAmB,CAAA;CAAE,GAC3D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9D,MAAM,MAAM,iCAAiC,CAAC,CAAC,IAAI;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAChC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC;IAC1C,cAAc,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;CAC9D,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,IAAI,CACxC,iCAAiC,CAAC,OAAO,CAAC,EAC1C,OAAO,GAAG,UAAU,GAAG,gBAAgB,CACxC,CAAC;AAgBF,wBAAgB,0BAA0B,CAAC,CAAC,EAC1C,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,GAC5C,4BAA4B,CAAC,CAAC,CAAC,CAwCjC;AAED,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,OAAO,EAC1C,OAAO,EAAE,uBAAuB,GAC/B,4BAA4B,CAAC,CAAC,CAAC,CAKjC;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,4BAA4B,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAOvD;AAED,wBAAsB,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAOxE;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAY9D;AAED,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAe7E;AAED,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,CAY7D;AAED,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,sBAAsB,EACtB,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,iBAAiB,CACnD,CAAC;AAEF,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,gBAAgB,iBAS3B"}
|
package/dist/json.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fsSync from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readRegularFile, readRegularFileSync } from "./regular-file.js";
|
|
5
|
+
import { openRootFileSync } from "./root-file.js";
|
|
5
6
|
import { writeTextAtomic } from "./text-atomic.js";
|
|
6
7
|
const JSON_FILE_MODE = 0o600;
|
|
7
8
|
const JSON_DIR_MODE = 0o700;
|
|
@@ -74,8 +75,8 @@ function renameJsonFileWithFallback(tmpPath, pathname) {
|
|
|
74
75
|
fsSync.renameSync(tmpPath, pathname);
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
|
-
fsSync.
|
|
78
|
-
fsSync.
|
|
78
|
+
fsSync.rmSync(pathname, { force: true });
|
|
79
|
+
fsSync.renameSync(tmpPath, pathname);
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
81
82
|
throw error;
|
|
@@ -131,6 +132,71 @@ export class JsonFileReadError extends Error {
|
|
|
131
132
|
this.reason = reason;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
function isRecord(value) {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
function resolveInvalidMessage(invalidMessage, relativePath) {
|
|
139
|
+
if (typeof invalidMessage === "function") {
|
|
140
|
+
return invalidMessage(relativePath);
|
|
141
|
+
}
|
|
142
|
+
return invalidMessage ?? `${relativePath} has an unexpected shape`;
|
|
143
|
+
}
|
|
144
|
+
export function readRootStructuredFileSync(options) {
|
|
145
|
+
const absolutePath = path.resolve(options.rootDir, options.relativePath);
|
|
146
|
+
const opened = openRootFileSync({
|
|
147
|
+
absolutePath,
|
|
148
|
+
rootPath: options.rootDir,
|
|
149
|
+
...(options.rootRealPath !== undefined ? { rootRealPath: options.rootRealPath } : {}),
|
|
150
|
+
boundaryLabel: options.boundaryLabel,
|
|
151
|
+
rejectHardlinks: options.rejectHardlinks,
|
|
152
|
+
maxBytes: options.maxBytes,
|
|
153
|
+
allowedType: "file",
|
|
154
|
+
});
|
|
155
|
+
if (!opened.ok) {
|
|
156
|
+
return { ok: false, reason: "open", failure: opened };
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const parsed = options.parse(fsSync.readFileSync(opened.fd, "utf8"));
|
|
160
|
+
if (options.validate && !options.validate(parsed)) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
reason: "invalid",
|
|
164
|
+
error: resolveInvalidMessage(options.invalidMessage, options.relativePath),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
value: parsed,
|
|
170
|
+
stat: opened.stat,
|
|
171
|
+
path: opened.path,
|
|
172
|
+
rootRealPath: opened.rootRealPath,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: "parse",
|
|
179
|
+
error: `failed to parse ${options.relativePath}: ${String(error)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
fsSync.closeSync(opened.fd);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function readRootJsonSync(options) {
|
|
187
|
+
return readRootStructuredFileSync({
|
|
188
|
+
...options,
|
|
189
|
+
parse: (raw) => JSON.parse(raw),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
export function readRootJsonObjectSync(options) {
|
|
193
|
+
return readRootStructuredFileSync({
|
|
194
|
+
...options,
|
|
195
|
+
parse: (raw) => JSON.parse(raw),
|
|
196
|
+
validate: isRecord,
|
|
197
|
+
invalidMessage: (relativePath) => `${relativePath} must contain a JSON object`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
134
200
|
export async function tryReadJson(filePath) {
|
|
135
201
|
try {
|
|
136
202
|
const raw = (await readRegularFile({ filePath })).buffer.toString("utf8");
|
|
@@ -194,5 +260,6 @@ export async function writeJson(filePath, value, options) {
|
|
|
194
260
|
mode: options?.mode,
|
|
195
261
|
dirMode: options?.dirMode,
|
|
196
262
|
trailingNewline: options?.trailingNewline,
|
|
263
|
+
durable: options?.durable,
|
|
197
264
|
});
|
|
198
265
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAMA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;
|
|
1
|
+
{"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAMA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;AAmGF,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,oCAAoC,GAC5C,oBAAoB,GAAG,IAAI,CA6B7B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAqCtC"}
|
package/dist/local-roots.js
CHANGED
|
@@ -52,8 +52,10 @@ function resolveRootRealSync(rootDir) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function resolveCandidateCanonicalSync(filePath) {
|
|
55
|
+
let sawExistingLeaf = false;
|
|
55
56
|
try {
|
|
56
57
|
const stat = fsSync.lstatSync(filePath);
|
|
58
|
+
sawExistingLeaf = true;
|
|
57
59
|
return {
|
|
58
60
|
exists: true,
|
|
59
61
|
canonicalPath: fsSync.realpathSync(filePath),
|
|
@@ -65,6 +67,11 @@ function resolveCandidateCanonicalSync(filePath) {
|
|
|
65
67
|
throw err;
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
if (sawExistingLeaf) {
|
|
71
|
+
// lstat succeeded but realpath failed: this is an existing dangling
|
|
72
|
+
// symlink, not a missing path callers may safely create through.
|
|
73
|
+
throw new FsSafeError("symlink", "local roots candidate is a dangling symlink");
|
|
74
|
+
}
|
|
68
75
|
let cursor = filePath;
|
|
69
76
|
const missingSegments = [];
|
|
70
77
|
while (true) {
|
|
@@ -84,6 +91,8 @@ function resolveCandidateCanonicalSync(filePath) {
|
|
|
84
91
|
}
|
|
85
92
|
catch (err) {
|
|
86
93
|
if (err.code !== "ENOENT") {
|
|
94
|
+
// Existing ancestors that cannot be canonicalized are symlink/error
|
|
95
|
+
// terrain; do not reconstruct a trusted missing path through them.
|
|
87
96
|
throw err;
|
|
88
97
|
}
|
|
89
98
|
}
|
|
@@ -134,12 +143,17 @@ export async function readLocalFileFromRoots(options) {
|
|
|
134
143
|
continue;
|
|
135
144
|
}
|
|
136
145
|
try {
|
|
137
|
-
const
|
|
146
|
+
const readOptions = {
|
|
138
147
|
hardlinks: options.hardlinks,
|
|
139
|
-
maxBytes: options.maxBytes,
|
|
140
148
|
nonBlockingRead: options.nonBlockingRead,
|
|
141
149
|
symlinks: options.symlinks,
|
|
142
|
-
}
|
|
150
|
+
};
|
|
151
|
+
// Leave maxBytes absent when the caller omits it so Root's own default
|
|
152
|
+
// cap remains in force instead of being overwritten by undefined.
|
|
153
|
+
if (options.maxBytes !== undefined) {
|
|
154
|
+
readOptions.maxBytes = options.maxBytes;
|
|
155
|
+
}
|
|
156
|
+
const result = await scopedRoot.read(relativePath, readOptions);
|
|
143
157
|
return { ...result, root: scopedRoot.rootReal };
|
|
144
158
|
}
|
|
145
159
|
catch {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SidecarLockRetryOptions, SidecarLockStaleRecovery } from "./sidecar-lock.js";
|
|
2
|
+
export type FsSafeLockConfig = {
|
|
3
|
+
staleRecovery: SidecarLockStaleRecovery;
|
|
4
|
+
staleMs?: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
retry?: SidecarLockRetryOptions;
|
|
7
|
+
};
|
|
8
|
+
export declare function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
9
|
+
export declare function getFsSafeLockConfig(): FsSafeLockConfig;
|
|
10
|
+
//# sourceMappingURL=lock-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-config.d.ts","sourceRoot":"","sources":["../src/lock-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAE3F,MAAM,MAAM,gBAAgB,GAAG;IAC7B,aAAa,EAAE,wBAAwB,CAAC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;CACjC,CAAC;AAQF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAI5E;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,CAEtD"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const DEFAULT_LOCK_CONFIG = {
|
|
2
|
+
staleRecovery: "fail-closed",
|
|
3
|
+
};
|
|
4
|
+
let lockConfig = { ...DEFAULT_LOCK_CONFIG };
|
|
5
|
+
export function configureFsSafeLocks(config) {
|
|
6
|
+
// Process defaults only fill lock options after a caller explicitly enables
|
|
7
|
+
// locking for a resource; this must never turn sidecar locks on globally.
|
|
8
|
+
lockConfig = { ...lockConfig, ...config };
|
|
9
|
+
}
|
|
10
|
+
export function getFsSafeLockConfig() {
|
|
11
|
+
return { ...lockConfig, retry: lockConfig.retry ? { ...lockConfig.retry } : undefined };
|
|
12
|
+
}
|
package/dist/move-path.d.ts
CHANGED
package/dist/move-path.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAqPF,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAuBf"}
|
package/dist/move-path.js
CHANGED
|
@@ -1,7 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { guardedRename } from "./guarded-mutation.js";
|
|
6
|
+
function entryIdentity(stat) {
|
|
7
|
+
return {
|
|
8
|
+
ctimeMs: stat.ctimeMs,
|
|
9
|
+
dev: stat.dev,
|
|
10
|
+
ino: stat.ino,
|
|
11
|
+
mode: stat.mode,
|
|
12
|
+
mtimeMs: stat.mtimeMs,
|
|
13
|
+
size: stat.size,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function sameIdentity(a, b) {
|
|
17
|
+
return (a.dev === b.dev &&
|
|
18
|
+
a.ino === b.ino &&
|
|
19
|
+
a.mode === b.mode &&
|
|
20
|
+
a.size === b.size &&
|
|
21
|
+
a.mtimeMs === b.mtimeMs &&
|
|
22
|
+
a.ctimeMs === b.ctimeMs);
|
|
23
|
+
}
|
|
24
|
+
function sameDirectoryNode(a, b) {
|
|
25
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
26
|
+
}
|
|
27
|
+
function modeBits(mode) {
|
|
28
|
+
return mode & 0o777;
|
|
29
|
+
}
|
|
30
|
+
function sourceChangedError(sourcePath) {
|
|
31
|
+
return Object.assign(new Error(`Source changed during move fallback: ${sourcePath}`), {
|
|
32
|
+
code: "ESTALE",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function assertSourceStillMatches(sourcePath, identity) {
|
|
36
|
+
if (!sameIdentity(identity, entryIdentity(await fs.lstat(sourcePath)))) {
|
|
37
|
+
throw sourceChangedError(sourcePath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function regularReadFlags() {
|
|
41
|
+
return (fsConstants.O_RDONLY |
|
|
42
|
+
(typeof fsConstants.O_NOFOLLOW === "number" && process.platform !== "win32"
|
|
43
|
+
? fsConstants.O_NOFOLLOW
|
|
44
|
+
: 0));
|
|
45
|
+
}
|
|
46
|
+
async function writeAll(handle, buffer, bytesRead) {
|
|
47
|
+
let offset = 0;
|
|
48
|
+
while (offset < bytesRead) {
|
|
49
|
+
const { bytesWritten } = await handle.write(buffer, offset, bytesRead - offset);
|
|
50
|
+
offset += bytesWritten;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function copyRegularFilePinned(params) {
|
|
54
|
+
let destinationCreated = false;
|
|
55
|
+
let sourceHandle;
|
|
56
|
+
try {
|
|
57
|
+
sourceHandle = await fs.open(params.from, regularReadFlags());
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const code = error?.code;
|
|
61
|
+
if (code === "ELOOP" || code === "ENOENT" || code === "ENOTDIR") {
|
|
62
|
+
throw sourceChangedError(params.from);
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const openedStat = await sourceHandle.stat();
|
|
68
|
+
if (!openedStat.isFile() || !sameIdentity(params.identity, entryIdentity(openedStat))) {
|
|
69
|
+
throw sourceChangedError(params.from);
|
|
70
|
+
}
|
|
71
|
+
const destinationHandle = await fs.open(params.to, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, modeBits(params.mode) || 0o666);
|
|
72
|
+
destinationCreated = true;
|
|
73
|
+
try {
|
|
74
|
+
const scratch = Buffer.allocUnsafe(64 * 1024);
|
|
75
|
+
while (true) {
|
|
76
|
+
const { bytesRead } = await sourceHandle.read(scratch, 0, scratch.length, null);
|
|
77
|
+
if (bytesRead === 0) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
await writeAll(destinationHandle, scratch, bytesRead);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await destinationHandle.close();
|
|
85
|
+
}
|
|
86
|
+
// Re-check the opened handle before the staged tree can be committed. If
|
|
87
|
+
// the source changed while we copied, the caller should retry the move.
|
|
88
|
+
if (!sameIdentity(params.identity, entryIdentity(await sourceHandle.stat()))) {
|
|
89
|
+
throw sourceChangedError(params.from);
|
|
90
|
+
}
|
|
91
|
+
await fs.chmod(params.to, modeBits(params.mode)).catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (destinationCreated) {
|
|
95
|
+
await fs.rm(params.to, { force: true }).catch(() => undefined);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await sourceHandle.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function copyEntryWithManifest(from, to, options) {
|
|
104
|
+
const sourceStat = await fs.lstat(from);
|
|
105
|
+
const identity = entryIdentity(sourceStat);
|
|
106
|
+
if (sourceStat.isSymbolicLink()) {
|
|
107
|
+
await fs.symlink(await fs.readlink(from), to);
|
|
108
|
+
// readlink() is path-based; verify the symlink we copied is still the one
|
|
109
|
+
// we inspected before letting the staged destination become visible.
|
|
110
|
+
await assertSourceStillMatches(from, identity);
|
|
111
|
+
return { ...identity, kind: "leaf" };
|
|
112
|
+
}
|
|
113
|
+
if (sourceStat.isDirectory()) {
|
|
114
|
+
await fs.mkdir(to, { mode: modeBits(sourceStat.mode) || 0o755 });
|
|
115
|
+
const children = [];
|
|
116
|
+
for (const child of await fs.readdir(from)) {
|
|
117
|
+
children.push({
|
|
118
|
+
name: child,
|
|
119
|
+
manifest: await copyEntryWithManifest(path.join(from, child), path.join(to, child), options),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Directory traversal is path-based in Node. Treat a changed parent as a
|
|
123
|
+
// stale move before committing so swapped-in outside trees are not imported.
|
|
124
|
+
await assertSourceStillMatches(from, identity);
|
|
125
|
+
// mkdir() honors process umask. Restore the source mode before commit so
|
|
126
|
+
// EXDEV fallback preserves directory permissions like fs.cp did.
|
|
127
|
+
await fs.chmod(to, modeBits(sourceStat.mode));
|
|
128
|
+
return { ...identity, children, kind: "directory" };
|
|
129
|
+
}
|
|
130
|
+
if (!sourceStat.isFile()) {
|
|
131
|
+
throw new Error(`Refusing to move non-file path with copy fallback: ${from}`);
|
|
132
|
+
}
|
|
133
|
+
if (options.sourceHardlinks === "reject" && sourceStat.nlink > 1) {
|
|
134
|
+
throw new Error(`Refusing to move hardlinked file with copy fallback: ${from}`);
|
|
135
|
+
}
|
|
136
|
+
await copyRegularFilePinned({ from, identity, mode: sourceStat.mode, to });
|
|
137
|
+
return { ...identity, kind: "leaf" };
|
|
138
|
+
}
|
|
139
|
+
function mergeCleanupResults(a, b) {
|
|
140
|
+
return a === "stale" || b === "stale" ? "stale" : "removed";
|
|
141
|
+
}
|
|
142
|
+
async function cleanupCopiedEntry(sourcePath, manifest) {
|
|
143
|
+
let currentStat;
|
|
144
|
+
try {
|
|
145
|
+
currentStat = await fs.lstat(sourcePath);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error?.code === "ENOENT") {
|
|
149
|
+
return "removed";
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (manifest.kind === "directory") {
|
|
154
|
+
if (!currentStat.isDirectory() || !sameDirectoryNode(manifest, entryIdentity(currentStat))) {
|
|
155
|
+
return "stale";
|
|
156
|
+
}
|
|
157
|
+
// A same-inode directory can gain unrelated children after commit. Still
|
|
158
|
+
// clean manifest children so the fallback does not duplicate copied files.
|
|
159
|
+
let result = "removed";
|
|
160
|
+
for (const child of manifest.children) {
|
|
161
|
+
result = mergeCleanupResults(result, await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest));
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await fs.rmdir(sourcePath);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const code = error?.code;
|
|
168
|
+
if (code === "ENOTEMPTY" || code === "EEXIST") {
|
|
169
|
+
return "stale";
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
if (!sameIdentity(manifest, entryIdentity(currentStat))) {
|
|
176
|
+
return "stale";
|
|
177
|
+
}
|
|
178
|
+
await fs.unlink(sourcePath);
|
|
179
|
+
return "removed";
|
|
180
|
+
}
|
|
2
181
|
export async function movePathWithCopyFallback(options) {
|
|
3
182
|
try {
|
|
4
|
-
await
|
|
183
|
+
await guardedRename({ from: options.from, to: options.to });
|
|
5
184
|
return;
|
|
6
185
|
}
|
|
7
186
|
catch (error) {
|
|
@@ -9,10 +188,19 @@ export async function movePathWithCopyFallback(options) {
|
|
|
9
188
|
throw error;
|
|
10
189
|
}
|
|
11
190
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
191
|
+
const targetDir = path.dirname(path.resolve(options.to));
|
|
192
|
+
const staged = path.join(targetDir, `.fs-safe-move-${process.pid}-${randomUUID()}.tmp`);
|
|
193
|
+
try {
|
|
194
|
+
const manifest = await copyEntryWithManifest(options.from, staged, {
|
|
195
|
+
sourceHardlinks: options.sourceHardlinks ?? "allow",
|
|
196
|
+
});
|
|
197
|
+
await guardedRename({ from: staged, to: options.to });
|
|
198
|
+
const cleanupResult = await cleanupCopiedEntry(options.from, manifest);
|
|
199
|
+
if (cleanupResult === "stale") {
|
|
200
|
+
throw sourceChangedError(options.from);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await fs.rm(staged, { recursive: true, force: true }).catch(() => undefined);
|
|
205
|
+
}
|
|
18
206
|
}
|