@openclaw/fs-safe 0.4.5 → 0.4.7
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 +31 -1
- package/SECURITY.md +40 -7
- package/dist/directory-durability.d.ts +44 -0
- package/dist/directory-durability.d.ts.map +1 -0
- package/dist/directory-durability.js +299 -0
- package/dist/durability.d.ts +2 -0
- package/dist/durability.d.ts.map +1 -0
- package/dist/durability.js +1 -0
- package/dist/fsync.d.ts +1 -1
- package/dist/fsync.d.ts.map +1 -1
- package/dist/fsync.js +1 -21
- package/dist/local-file-access.d.ts.map +1 -1
- package/dist/local-file-access.js +9 -1
- package/dist/permissions.d.ts +11 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +56 -55
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +3 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +1 -13
- package/dist/windows-command.d.ts +2 -0
- package/dist/windows-command.d.ts.map +1 -0
- package/dist/windows-command.js +34 -0
- package/dist/windows-owner.d.ts +29 -0
- package/dist/windows-owner.d.ts.map +1 -0
- package/dist/windows-owner.js +118 -0
- package/docs/advanced.md +1 -1
- package/docs/contributing.md +19 -4
- package/docs/durability.md +89 -0
- package/docs/index.md +1 -0
- package/docs/permissions.md +4 -1
- package/docs/security-model.md +13 -0
- package/package.json +26 -3
package/dist/permissions.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
|
|
7
|
+
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
8
|
+
import { inspectWindowsOwner, resolveWindowsCurrentUserSid, resolveWindowsPrincipalSids, } from "./windows-owner.js";
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
10
|
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
|
|
9
11
|
const WORLD_PRINCIPALS = new Set(["everyone", "users", "builtin\\users", "authenticated users", "nt authority\\authenticated users", "anonymous logon", "nt authority\\anonymous logon", "guests", "builtin\\guests", "interactive", "nt authority\\interactive", "network", "nt authority\\network", "local"]);
|
|
@@ -100,7 +102,23 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
100
102
|
const bits = modeBits(effectiveMode);
|
|
101
103
|
const platform = opts?.platform ?? process.platform;
|
|
102
104
|
if (platform === "win32") {
|
|
103
|
-
const
|
|
105
|
+
const owner = await inspectWindowsOwner({
|
|
106
|
+
targetPath,
|
|
107
|
+
env: opts?.env,
|
|
108
|
+
exec: opts?.exec ?? defaultPermissionExec,
|
|
109
|
+
});
|
|
110
|
+
const acl = await inspectWindowsAcl(targetPath, {
|
|
111
|
+
env: opts?.env,
|
|
112
|
+
exec: opts?.exec,
|
|
113
|
+
currentUserSid: owner.currentUserSid,
|
|
114
|
+
principalSids: owner.principalSids,
|
|
115
|
+
principalTranslationFailed: owner.principalTranslationFailed,
|
|
116
|
+
});
|
|
117
|
+
const ownerFields = {
|
|
118
|
+
...(owner.sid ? { ownerSid: owner.sid } : {}),
|
|
119
|
+
...(owner.trusted !== undefined ? { ownerTrusted: owner.trusted } : {}),
|
|
120
|
+
...(owner.error ? { ownerError: owner.error } : {}),
|
|
121
|
+
};
|
|
104
122
|
if (!acl.ok) {
|
|
105
123
|
return {
|
|
106
124
|
ok: true,
|
|
@@ -113,6 +131,7 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
113
131
|
groupWritable: false,
|
|
114
132
|
worldReadable: false,
|
|
115
133
|
groupReadable: false,
|
|
134
|
+
...ownerFields,
|
|
116
135
|
error: acl.error,
|
|
117
136
|
};
|
|
118
137
|
}
|
|
@@ -127,6 +146,7 @@ export async function inspectPathPermissions(targetPath, opts) {
|
|
|
127
146
|
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
|
|
128
147
|
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
|
|
129
148
|
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
|
|
149
|
+
...ownerFields,
|
|
130
150
|
aclSummary: formatWindowsAclSummary(acl),
|
|
131
151
|
};
|
|
132
152
|
}
|
|
@@ -204,36 +224,6 @@ function buildTrustedPrincipals(env) {
|
|
|
204
224
|
}
|
|
205
225
|
return trusted;
|
|
206
226
|
}
|
|
207
|
-
function getEnvValueCaseInsensitive(env, name) {
|
|
208
|
-
const direct = env[name];
|
|
209
|
-
if (direct !== undefined) {
|
|
210
|
-
return direct;
|
|
211
|
-
}
|
|
212
|
-
const lower = name.toLowerCase();
|
|
213
|
-
for (const [key, value] of Object.entries(env)) {
|
|
214
|
-
if (key.toLowerCase() === lower) {
|
|
215
|
-
return value;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return undefined;
|
|
219
|
-
}
|
|
220
|
-
function normalizeWindowsInstallRoot(value) {
|
|
221
|
-
const trimmed = value?.trim();
|
|
222
|
-
if (!trimmed || !path.win32.isAbsolute(trimmed)) {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
return trimmed.replace(/[\\/]+$/, "");
|
|
226
|
-
}
|
|
227
|
-
function resolveWindowsSystemRoot(env) {
|
|
228
|
-
const source = env ?? process.env;
|
|
229
|
-
return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
|
|
230
|
-
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
|
|
231
|
-
"C:\\Windows");
|
|
232
|
-
}
|
|
233
|
-
function resolveWindowsSystemCommand(command, env) {
|
|
234
|
-
const root = resolveWindowsSystemRoot(env);
|
|
235
|
-
return path.win32.join(root, "System32", command);
|
|
236
|
-
}
|
|
237
227
|
function classifyPrincipal(principal, trustedPrincipals) {
|
|
238
228
|
const normalized = normalize(principal);
|
|
239
229
|
if (SID_RE.test(normalized)) {
|
|
@@ -290,7 +280,14 @@ function parseAceEntry(entry) {
|
|
|
290
280
|
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
|
|
291
281
|
if (rights.length === 0)
|
|
292
282
|
return null;
|
|
293
|
-
|
|
283
|
+
const normalizedPrincipal = normalizeSid(principal);
|
|
284
|
+
return {
|
|
285
|
+
principal,
|
|
286
|
+
...(SID_RE.test(normalizedPrincipal) ? { sid: normalizedPrincipal } : {}),
|
|
287
|
+
rights,
|
|
288
|
+
rawRights,
|
|
289
|
+
...rightsFromTokens(rights),
|
|
290
|
+
};
|
|
294
291
|
}
|
|
295
292
|
export function parseIcaclsOutput(output, targetPath) {
|
|
296
293
|
const entries = [];
|
|
@@ -325,7 +322,7 @@ export function summarizeWindowsAcl(entries, env) {
|
|
|
325
322
|
const untrustedWorld = [];
|
|
326
323
|
const untrustedGroup = [];
|
|
327
324
|
for (const entry of entries) {
|
|
328
|
-
const classification = classifyPrincipal(entry.principal, trustedPrincipals);
|
|
325
|
+
const classification = classifyPrincipal(entry.sid ?? entry.principal, trustedPrincipals);
|
|
329
326
|
if (classification === "trusted")
|
|
330
327
|
trusted.push(entry);
|
|
331
328
|
else if (classification === "world")
|
|
@@ -335,37 +332,41 @@ export function summarizeWindowsAcl(entries, env) {
|
|
|
335
332
|
}
|
|
336
333
|
return { trusted, untrustedWorld, untrustedGroup };
|
|
337
334
|
}
|
|
338
|
-
async function resolveCurrentUserSid(exec, env) {
|
|
339
|
-
try {
|
|
340
|
-
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("whoami.exe", env), [
|
|
341
|
-
"/user",
|
|
342
|
-
"/fo",
|
|
343
|
-
"csv",
|
|
344
|
-
"/nh",
|
|
345
|
-
]);
|
|
346
|
-
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
|
|
347
|
-
return match ? normalizeSid(match[0]) : null;
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
335
|
export async function inspectWindowsAcl(targetPath, opts) {
|
|
354
336
|
const exec = opts?.exec ?? defaultPermissionExec;
|
|
355
337
|
try {
|
|
338
|
+
if (opts?.principalTranslationFailed) {
|
|
339
|
+
throw new Error("Windows ACL principal SID translation failed");
|
|
340
|
+
}
|
|
356
341
|
const { stdout, stderr } = await exec(resolveWindowsSystemCommand("icacls.exe", opts?.env), [
|
|
357
342
|
targetPath,
|
|
358
|
-
"/sid",
|
|
359
343
|
]);
|
|
360
|
-
|
|
361
|
-
|
|
344
|
+
let entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath);
|
|
345
|
+
const unresolvedPrincipals = entries
|
|
346
|
+
.filter((entry) => !entry.sid)
|
|
347
|
+
.map((entry) => entry.principal);
|
|
348
|
+
const principalSids = await resolveWindowsPrincipalSids({
|
|
349
|
+
principals: unresolvedPrincipals,
|
|
350
|
+
known: opts?.principalSids,
|
|
351
|
+
env: opts?.env,
|
|
352
|
+
exec,
|
|
353
|
+
});
|
|
354
|
+
entries = entries.map((entry) => {
|
|
355
|
+
const sid = entry.sid ?? principalSids[entry.principal.toLowerCase()];
|
|
356
|
+
if (!sid) {
|
|
357
|
+
throw new Error(`Windows ACL principal SID could not be verified: ${entry.principal}`);
|
|
358
|
+
}
|
|
359
|
+
return { ...entry, sid };
|
|
360
|
+
});
|
|
361
|
+
let currentUserSid = normalizeSid(opts?.currentUserSid ?? "");
|
|
362
|
+
let effectiveEnv = currentUserSid ? { USERSID: currentUserSid } : undefined;
|
|
362
363
|
let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv);
|
|
363
|
-
const needsUserSidResolution = !
|
|
364
|
-
untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal)));
|
|
364
|
+
const needsUserSidResolution = !currentUserSid && untrustedGroup.some((entry) => entry.sid && !TRUSTED_SIDS.has(entry.sid));
|
|
365
365
|
if (needsUserSidResolution) {
|
|
366
|
-
|
|
366
|
+
currentUserSid =
|
|
367
|
+
(await resolveWindowsCurrentUserSid({ exec, env: opts?.env })) ?? "";
|
|
367
368
|
if (currentUserSid) {
|
|
368
|
-
effectiveEnv = {
|
|
369
|
+
effectiveEnv = { USERSID: currentUserSid };
|
|
369
370
|
({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv));
|
|
370
371
|
}
|
|
371
372
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;
|
|
1
|
+
{"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAYrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAgLF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
|
package/dist/secure-file.js
CHANGED
|
@@ -117,6 +117,9 @@ async function assertSecurePermissions(options, stat, realPath) {
|
|
|
117
117
|
if (platform === "win32" && permissions.source === "unknown") {
|
|
118
118
|
throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
|
|
119
119
|
}
|
|
120
|
+
if (platform === "win32" && permissions.ownerTrusted !== true) {
|
|
121
|
+
throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
|
|
122
|
+
}
|
|
120
123
|
const writableByOthers = permissions.worldWritable || permissions.groupWritable;
|
|
121
124
|
const readableByOthers = permissions.worldReadable || permissions.groupReadable;
|
|
122
125
|
if (writableByOthers || (!options.permissions?.allowReadableByOthers && readableByOthers)) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA8BF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAyCxC;AAkBD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8ChB"}
|
package/dist/sibling-temp.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto, { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
|
|
5
|
+
import { syncDirectoryBestEffort } from "./directory-durability.js";
|
|
5
6
|
import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
|
|
6
7
|
import { sanitizeUntrustedFileName } from "./filename.js";
|
|
7
8
|
import { root } from "./root.js";
|
|
@@ -30,19 +31,6 @@ async function syncFileBestEffort(filePath) {
|
|
|
30
31
|
await handle.close();
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
async function syncDirectoryBestEffort(dirPath) {
|
|
34
|
-
let handle;
|
|
35
|
-
try {
|
|
36
|
-
handle = await fs.open(dirPath, "r");
|
|
37
|
-
await handle.sync();
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
// Best-effort on platforms/filesystems that do not support directory fsync.
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
await handle?.close().catch(() => undefined);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
34
|
function assertFinalPathIsSibling(dir, filePath) {
|
|
47
35
|
const resolvedDir = path.resolve(dir);
|
|
48
36
|
const resolvedFile = path.resolve(filePath);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"windows-command.d.ts","sourceRoot":"","sources":["../src/windows-command.ts"],"names":[],"mappings":"AAwCA,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,GACtB,MAAM,CAER"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
function getEnvValueCaseInsensitive(env, name) {
|
|
3
|
+
const direct = env[name];
|
|
4
|
+
if (direct !== undefined) {
|
|
5
|
+
return direct;
|
|
6
|
+
}
|
|
7
|
+
const lower = name.toLowerCase();
|
|
8
|
+
for (const [key, value] of Object.entries(env)) {
|
|
9
|
+
if (key.toLowerCase() === lower) {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function normalizeWindowsInstallRoot(value) {
|
|
16
|
+
const trimmed = value?.trim();
|
|
17
|
+
if (!trimmed || !path.win32.isAbsolute(trimmed)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let end = trimmed.length;
|
|
21
|
+
while (end > 0 && (trimmed[end - 1] === "\\" || trimmed[end - 1] === "/")) {
|
|
22
|
+
end -= 1;
|
|
23
|
+
}
|
|
24
|
+
return trimmed.slice(0, end);
|
|
25
|
+
}
|
|
26
|
+
function resolveWindowsSystemRoot(env) {
|
|
27
|
+
const source = env ?? process.env;
|
|
28
|
+
return (normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "SystemRoot")) ??
|
|
29
|
+
normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(source, "WINDIR")) ??
|
|
30
|
+
"C:\\Windows");
|
|
31
|
+
}
|
|
32
|
+
export function resolveWindowsSystemCommand(command, env) {
|
|
33
|
+
return path.win32.join(resolveWindowsSystemRoot(env), "System32", command);
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
}>;
|
|
5
|
+
export type WindowsOwnerSummary = {
|
|
6
|
+
sid?: string;
|
|
7
|
+
currentUserSid?: string;
|
|
8
|
+
principalSids?: Record<string, string>;
|
|
9
|
+
principalTranslationFailed?: boolean;
|
|
10
|
+
remote?: boolean;
|
|
11
|
+
trusted?: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function resolveWindowsPrincipalSids(params: {
|
|
15
|
+
principals: string[];
|
|
16
|
+
known?: Record<string, string>;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
exec: WindowsOwnerExec;
|
|
19
|
+
}): Promise<Record<string, string>>;
|
|
20
|
+
export declare function resolveWindowsCurrentUserSid(params: {
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
exec: WindowsOwnerExec;
|
|
23
|
+
}): Promise<string | null>;
|
|
24
|
+
export declare function inspectWindowsOwner(params: {
|
|
25
|
+
targetPath: string;
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
exec: WindowsOwnerExec;
|
|
28
|
+
}): Promise<WindowsOwnerSummary>;
|
|
29
|
+
//# sourceMappingURL=windows-owner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"windows-owner.d.ts","sourceRoot":"","sources":["../src/windows-owner.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,KACX,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA4DF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWzB;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,IAAI,EAAE,gBAAgB,CAAC;CACxB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA2C/B"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { resolveWindowsSystemCommand } from "./windows-command.js";
|
|
2
|
+
const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
|
|
3
|
+
const TRUSTED_OWNER_SIDS = new Set(["s-1-5-18", "s-1-5-32-544"]);
|
|
4
|
+
function normalizeSid(value) {
|
|
5
|
+
const normalized = value.trim().toLowerCase();
|
|
6
|
+
return normalized.startsWith("*") ? normalized.slice(1) : normalized;
|
|
7
|
+
}
|
|
8
|
+
function encodePowerShellCommand(source) {
|
|
9
|
+
return Buffer.from(source, "utf16le").toString("base64");
|
|
10
|
+
}
|
|
11
|
+
function windowsOwnerQueryCommand(targetPath) {
|
|
12
|
+
const encodedPath = Buffer.from(targetPath, "utf8").toString("base64");
|
|
13
|
+
return [
|
|
14
|
+
"$ErrorActionPreference='Stop'",
|
|
15
|
+
`$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
|
|
16
|
+
"$sections=[System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner",
|
|
17
|
+
"$acl=if([IO.Directory]::Exists($p)){[IO.Directory]::GetAccessControl($p,$sections)}else{[IO.File]::GetAccessControl($p,$sections)}",
|
|
18
|
+
"$ownerSid=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value",
|
|
19
|
+
"$currentSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
|
20
|
+
"$root=[IO.Path]::GetPathRoot($p)",
|
|
21
|
+
"$extendedDrive=$p.Length -ge 7 -and $p.StartsWith('\\\\?\\') -and [char]::IsLetter($p[4]) -and $p[5] -eq ':' -and $p[6] -eq '\\'",
|
|
22
|
+
"$driveRoot=if($extendedDrive){$p.Substring(4,3)}else{$root}",
|
|
23
|
+
"$namespacePath=$p.StartsWith('\\\\')",
|
|
24
|
+
"$remote=($namespacePath -and -not $extendedDrive) -or ([IO.DriveInfo]::new($driveRoot).DriveType -eq [IO.DriveType]::Network)",
|
|
25
|
+
"$rules=$acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])",
|
|
26
|
+
"$principalSids=@($rules|ForEach-Object {$identity=$_.IdentityReference;$sid=$identity.Value;@{name=$sid;sid=$sid};try{@{name=$identity.Translate([System.Security.Principal.NTAccount]).Value;sid=$sid}}catch{}})",
|
|
27
|
+
"@{ownerSid=$ownerSid;currentUserSid=$currentSid;principalSids=$principalSids;principalTranslationFailed=$false;remote=$remote}|ConvertTo-Json -Depth 4 -Compress",
|
|
28
|
+
].join(";");
|
|
29
|
+
}
|
|
30
|
+
function windowsPrincipalQueryCommand(principals) {
|
|
31
|
+
const encodedPrincipals = Buffer.from(JSON.stringify(principals), "utf8").toString("base64");
|
|
32
|
+
return [
|
|
33
|
+
"$ErrorActionPreference='Stop'",
|
|
34
|
+
`$names=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPrincipals}'))|ConvertFrom-Json`,
|
|
35
|
+
"$rows=@($names|ForEach-Object {@{name=$_;sid=(New-Object System.Security.Principal.NTAccount($_)).Translate([System.Security.Principal.SecurityIdentifier]).Value}})",
|
|
36
|
+
"ConvertTo-Json -InputObject $rows -Compress",
|
|
37
|
+
].join(";");
|
|
38
|
+
}
|
|
39
|
+
function parsePrincipalSidRows(value) {
|
|
40
|
+
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
41
|
+
const result = {};
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (!row || typeof row !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const name = "name" in row && typeof row.name === "string" ? row.name.trim() : "";
|
|
47
|
+
const sid = "sid" in row && typeof row.sid === "string" ? normalizeSid(row.sid) : "";
|
|
48
|
+
if (name && SID_RE.test(sid)) {
|
|
49
|
+
result[name.toLowerCase()] = sid;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export async function resolveWindowsPrincipalSids(params) {
|
|
55
|
+
const principals = [...new Set(params.principals.map((value) => value.trim()).filter(Boolean))];
|
|
56
|
+
const known = Object.fromEntries(Object.entries(params.known ?? {}).map(([name, sid]) => [name.toLowerCase(), normalizeSid(sid)]));
|
|
57
|
+
const unresolved = principals.filter((principal) => !known[principal.toLowerCase()]);
|
|
58
|
+
if (unresolved.length === 0) {
|
|
59
|
+
return known;
|
|
60
|
+
}
|
|
61
|
+
const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
|
|
62
|
+
const { stdout } = await params.exec(command, [
|
|
63
|
+
"-NoLogo",
|
|
64
|
+
"-NoProfile",
|
|
65
|
+
"-NonInteractive",
|
|
66
|
+
"-EncodedCommand",
|
|
67
|
+
encodePowerShellCommand(windowsPrincipalQueryCommand(unresolved)),
|
|
68
|
+
]);
|
|
69
|
+
const resolved = { ...known, ...parsePrincipalSidRows(JSON.parse(stdout.trim())) };
|
|
70
|
+
if (principals.some((principal) => !resolved[principal.toLowerCase()])) {
|
|
71
|
+
throw new Error("Windows ACL principal translation returned incomplete SID data");
|
|
72
|
+
}
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
export async function resolveWindowsCurrentUserSid(params) {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout, stderr } = await params.exec(resolveWindowsSystemCommand("whoami.exe", params.env), ["/user", "/fo", "csv", "/nh"]);
|
|
78
|
+
const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i);
|
|
79
|
+
return match ? normalizeSid(match[0]) : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function inspectWindowsOwner(params) {
|
|
86
|
+
try {
|
|
87
|
+
const command = resolveWindowsSystemCommand(String.raw `WindowsPowerShell\v1.0\powershell.exe`, params.env);
|
|
88
|
+
const { stdout } = await params.exec(command, [
|
|
89
|
+
"-NoLogo",
|
|
90
|
+
"-NoProfile",
|
|
91
|
+
"-NonInteractive",
|
|
92
|
+
"-EncodedCommand",
|
|
93
|
+
encodePowerShellCommand(windowsOwnerQueryCommand(params.targetPath)),
|
|
94
|
+
]);
|
|
95
|
+
const parsed = JSON.parse(stdout.trim());
|
|
96
|
+
const ownerSid = typeof parsed.ownerSid === "string" && SID_RE.test(parsed.ownerSid)
|
|
97
|
+
? normalizeSid(parsed.ownerSid)
|
|
98
|
+
: undefined;
|
|
99
|
+
const currentUserSid = typeof parsed.currentUserSid === "string" && SID_RE.test(parsed.currentUserSid)
|
|
100
|
+
? normalizeSid(parsed.currentUserSid)
|
|
101
|
+
: undefined;
|
|
102
|
+
if (!ownerSid || !currentUserSid) {
|
|
103
|
+
return { error: "Windows owner query returned invalid SID data" };
|
|
104
|
+
}
|
|
105
|
+
const remote = parsed.remote === true;
|
|
106
|
+
return {
|
|
107
|
+
sid: ownerSid,
|
|
108
|
+
currentUserSid,
|
|
109
|
+
principalSids: parsePrincipalSidRows(parsed.principalSids),
|
|
110
|
+
principalTranslationFailed: parsed.principalTranslationFailed === true,
|
|
111
|
+
remote,
|
|
112
|
+
trusted: !remote && (ownerSid === currentUserSid || TRUSTED_OWNER_SIDS.has(ownerSid)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { error: String(err) };
|
|
117
|
+
}
|
|
118
|
+
}
|
package/docs/advanced.md
CHANGED
|
@@ -5,7 +5,7 @@ description: "Lower-level composition helpers under @openclaw/fs-safe/advanced.
|
|
|
5
5
|
|
|
6
6
|
# `@openclaw/fs-safe/advanced`
|
|
7
7
|
|
|
8
|
-
Composition primitives that OpenClaw uses to build higher-level APIs. They are public — semver applies — but treated as a less stable surface than the focused subpaths (`root`, `json`, `store`, `temp`, `archive`, `errors`). Reach for them only when you are building a primitive of your own and the focused subpaths do not cover it.
|
|
8
|
+
Composition primitives that OpenClaw uses to build higher-level APIs. They are public — semver applies — but treated as a less stable surface than the focused subpaths (`root`, `json`, `store`, `temp`, `archive`, `durability`, `errors`). Reach for them only when you are building a primitive of your own and the focused subpaths do not cover it.
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
11
|
import {
|
package/docs/contributing.md
CHANGED
|
@@ -10,7 +10,8 @@ cd fs-safe
|
|
|
10
10
|
pnpm install
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Node 22 or newer. The dev toolchain
|
|
13
|
+
Node 22 or newer. The dev toolchain and lockfile use pnpm; use the package
|
|
14
|
+
manager version declared in `package.json`.
|
|
14
15
|
|
|
15
16
|
## Build
|
|
16
17
|
|
|
@@ -34,9 +35,16 @@ pnpm test test/archive.test.ts
|
|
|
34
35
|
|
|
35
36
|
Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
|
|
36
37
|
|
|
37
|
-
##
|
|
38
|
+
## Checks
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Run the complete repository gate before handoff:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pnpm check
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This runs the filesystem boundary checks, build, tests, and package
|
|
47
|
+
tarball/import validation.
|
|
40
48
|
|
|
41
49
|
## Docs
|
|
42
50
|
|
|
@@ -69,7 +77,14 @@ Small, focused PRs land faster. The general shape:
|
|
|
69
77
|
|
|
70
78
|
## Releases
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
Maintainers publish from a protected `vX.Y.Z` tag on `main` through
|
|
81
|
+
`.github/workflows/release.yml`. The workflow requires the package version and a
|
|
82
|
+
dated `CHANGELOG.md` section to match the tag, then publishes with npm trusted
|
|
83
|
+
publishing and provenance before creating the GitHub release.
|
|
84
|
+
|
|
85
|
+
External contributors do not need to do anything beyond getting the pull
|
|
86
|
+
request merged. Maintainers must not publish locally or add npm automation
|
|
87
|
+
tokens.
|
|
73
88
|
|
|
74
89
|
## Reporting security issues
|
|
75
90
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Directory durability
|
|
3
|
+
description: "Pin directory identities, fsync publication metadata, and durably create nested directory paths."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Directory durability
|
|
7
|
+
|
|
8
|
+
`@openclaw/fs-safe/durability` provides the directory side of crash-safe file
|
|
9
|
+
publication. Flushing a file does not guarantee that its containing directory
|
|
10
|
+
entry reached storage; callers that promise durable create, link, rename, or
|
|
11
|
+
unlink operations must also synchronize the affected directory.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
ensureDurableDirectory,
|
|
16
|
+
pinDirectory,
|
|
17
|
+
} from "@openclaw/fs-safe/durability";
|
|
18
|
+
|
|
19
|
+
const repository = await ensureDurableDirectory({
|
|
20
|
+
directoryPath: "/srv/backups/sqlite",
|
|
21
|
+
mode: 0o700,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const pinned = await pinDirectory(repository, { label: "backup repository" });
|
|
25
|
+
try {
|
|
26
|
+
await publishSnapshot();
|
|
27
|
+
const outcome = await pinned.sync();
|
|
28
|
+
if (outcome.status === "unsupported") {
|
|
29
|
+
// Decide at the product boundary whether this platform can weaken the promise.
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
await pinned.close();
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Outcomes and failure semantics
|
|
37
|
+
|
|
38
|
+
`syncDirectory()` and `PinnedDirectory.sync()` return:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
type DirectorySyncOutcome =
|
|
42
|
+
| { status: "synced" }
|
|
43
|
+
| { status: "unsupported"; code?: string };
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
POSIX synchronization failures propagate. Windows directory handles do not
|
|
47
|
+
portably support `FlushFileBuffers`; the known unsupported error family is
|
|
48
|
+
reported as `unsupported` after the pathname and pinned identity are checked
|
|
49
|
+
again. Directory-open access failures and other Windows I/O failures still
|
|
50
|
+
propagate.
|
|
51
|
+
|
|
52
|
+
`syncDirectoryBestEffort()` and `syncDirectoryBestEffortSync()` intentionally
|
|
53
|
+
discard both unsupported outcomes and failures. Use them only when the primary
|
|
54
|
+
write remains useful without a crash-durability promise.
|
|
55
|
+
|
|
56
|
+
## Pinned directories
|
|
57
|
+
|
|
58
|
+
`pinDirectory()` rejects final symlinks and non-directories. On POSIX it opens
|
|
59
|
+
with `O_DIRECTORY`, `O_NOFOLLOW`, and `O_NONBLOCK`, then compares the open
|
|
60
|
+
descriptor, pathname identity, and canonical path. `assertCurrent()` repeats
|
|
61
|
+
those checks. This prevents a pathname replacement from turning a later sync
|
|
62
|
+
into proof for a different directory.
|
|
63
|
+
|
|
64
|
+
Call `close()` in `finally`. Closing is idempotent; using a closed pin fails.
|
|
65
|
+
|
|
66
|
+
## Durable directory creation
|
|
67
|
+
|
|
68
|
+
`ensureDurableDirectory()` finds and pins the nearest existing ancestor,
|
|
69
|
+
creates the requested path, opens every new directory segment, and synchronizes
|
|
70
|
+
each new parent-to-child edge from the leaf upward. It returns the final
|
|
71
|
+
directory receipt plus the aggregate parent-sync outcome.
|
|
72
|
+
|
|
73
|
+
By default it uses fs-safe's guarded one-segment-at-a-time absolute-directory
|
|
74
|
+
creator. Advanced callers can pass `create` when directory creation needs
|
|
75
|
+
platform-specific ACLs or another product-owned policy. The callback owns the
|
|
76
|
+
safety of its mutations and must create exactly `directoryPath`; fs-safe
|
|
77
|
+
validates and pins every resulting segment before any synchronization is
|
|
78
|
+
accepted.
|
|
79
|
+
|
|
80
|
+
`expectedExistingIdentity` binds an existing target to an identity observed by
|
|
81
|
+
the caller before a separate permission or policy check. A missing or replaced
|
|
82
|
+
target fails with `FsSafeError("path-mismatch")`.
|
|
83
|
+
|
|
84
|
+
## Scope
|
|
85
|
+
|
|
86
|
+
These primitives establish path identity and filesystem synchronization. They
|
|
87
|
+
do not decide application commit protocols, marker formats, permission policy,
|
|
88
|
+
or whether an unsupported platform is acceptable. Keep those decisions at the
|
|
89
|
+
owning product boundary.
|
package/docs/index.md
CHANGED
|
@@ -52,6 +52,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
52
52
|
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
|
|
53
53
|
| [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
|
|
54
54
|
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
|
55
|
+
| [Directory durability](durability.md) | Pinned directory identities, explicit sync outcomes, and durable nested-directory creation. |
|
|
55
56
|
| [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
|
|
56
57
|
| [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
|
|
57
58
|
| [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
|
package/docs/permissions.md
CHANGED
|
@@ -38,7 +38,7 @@ isWorldReadable(bits);
|
|
|
38
38
|
isGroupReadable(bits);
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below.
|
|
41
|
+
`inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
|
|
42
42
|
|
|
43
43
|
## Advanced Windows ACL helpers
|
|
44
44
|
|
|
@@ -82,6 +82,9 @@ type PermissionCheck = {
|
|
|
82
82
|
groupWritable: boolean;
|
|
83
83
|
worldReadable: boolean;
|
|
84
84
|
groupReadable: boolean;
|
|
85
|
+
ownerSid?: string;
|
|
86
|
+
ownerTrusted?: boolean;
|
|
87
|
+
ownerError?: string;
|
|
85
88
|
aclSummary?: string;
|
|
86
89
|
error?: string;
|
|
87
90
|
};
|
package/docs/security-model.md
CHANGED
|
@@ -61,6 +61,19 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
|
|
|
61
61
|
|
|
62
62
|
Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
|
|
63
63
|
|
|
64
|
+
### Directory durability
|
|
65
|
+
|
|
66
|
+
`pinDirectory()` opens a directory without following its final component on
|
|
67
|
+
POSIX, verifies the descriptor against the pathname identity and canonical
|
|
68
|
+
path, and repeats those checks around synchronization. `ensureDurableDirectory()`
|
|
69
|
+
pins the nearest existing ancestor and each newly created segment before
|
|
70
|
+
synchronizing every new directory edge from the leaf upward.
|
|
71
|
+
|
|
72
|
+
Known Windows directory-flush limitations are returned as an explicit
|
|
73
|
+
`unsupported` outcome. POSIX and other I/O failures propagate from the strict
|
|
74
|
+
API. The separately named best-effort helpers intentionally provide no crash
|
|
75
|
+
durability guarantee.
|
|
76
|
+
|
|
64
77
|
### Archive extraction
|
|
65
78
|
|
|
66
79
|
`extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
|