@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.
@@ -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 acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
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
- return { principal, rights, rawRights, ...rightsFromTokens(rights) };
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
- const entries = parseIcaclsOutput(`${stdout}\n${stderr}`.trim(), targetPath);
361
- let effectiveEnv = opts?.env;
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 = !effectiveEnv?.USERSID &&
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
- const currentUserSid = await resolveCurrentUserSid(exec, effectiveEnv);
366
+ currentUserSid =
367
+ (await resolveWindowsCurrentUserSid({ exec, env: opts?.env })) ?? "";
367
368
  if (currentUserSid) {
368
- effectiveEnv = { ...effectiveEnv, USERSID: currentUserSid };
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;AA0KF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAc/B"}
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"}
@@ -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":"AAaA,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;AA0CF,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"}
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"}
@@ -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,2 @@
1
+ export declare function resolveWindowsSystemCommand(command: string, env?: NodeJS.ProcessEnv): string;
2
+ //# sourceMappingURL=windows-command.d.ts.map
@@ -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 {
@@ -10,7 +10,8 @@ cd fs-safe
10
10
  pnpm install
11
11
  ```
12
12
 
13
- Node 22 or newer. The dev toolchain uses pnpm; `npm install` works too but pnpm is what the lockfile is keyed against.
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
- ## Format and types
38
+ ## Checks
38
39
 
39
- The repo doesn't ship a separate lint config; `tsc --noEmit` (run by `pnpm build`) is the type gate. Format with your editor's TypeScript Language Server defaults — keep diffs tight.
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
- Release process lives in the maintainer's runbook. External contributors don't need to do anything beyond getting the PR merged.
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`. |
@@ -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
  };
@@ -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.