@nanhara/hara 0.123.1 → 0.124.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.
@@ -2,11 +2,11 @@
2
2
  // runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
3
3
  // index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
4
4
  // .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
5
+ import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
6
6
  import { join, resolve, isAbsolute } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { execFileSync } from "node:child_process";
9
- import { readRawConfig } from "../config.js";
9
+ import { readRawConfig, updateRawConfig } from "../config.js";
10
10
  export function pluginsDir() {
11
11
  return join(homedir(), ".hara", "plugins");
12
12
  }
@@ -193,11 +193,9 @@ export function panelsForProject(cwd) {
193
193
  }
194
194
  /** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
195
195
  export function setPluginEnabled(name, on) {
196
- const p = join(homedir(), ".hara", "config.json");
197
- const cfg = readRawConfig();
198
- const plugins = (cfg.plugins && typeof cfg.plugins === "object" ? cfg.plugins : {});
199
- plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
200
- cfg.plugins = plugins;
201
- mkdirSync(join(homedir(), ".hara"), { recursive: true });
202
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
196
+ updateRawConfig((config) => {
197
+ const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
198
+ plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
199
+ config.plugins = plugins;
200
+ });
203
201
  }
@@ -32,29 +32,22 @@
32
32
  import { homedir } from "node:os";
33
33
  import { spawnSync } from "node:child_process";
34
34
  import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
35
- import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, chmodSync, realpathSync, renameSync } from "node:fs";
35
+ import { lstatSync, realpathSync } from "node:fs";
36
+ import { readRawConfig, updateRawConfig } from "../config.js";
36
37
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
37
38
  import { atomicWriteText, bindProfilePinWritePath, discardClaimedPath, verifyAtomicWriteBoundary, } from "../fs-write.js";
38
39
  import { projectRepositoryTrustedAtStartup } from "../security/project-trust.js";
40
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
39
41
  const PERSONAL_ID = "personal";
40
42
  const DEFAULT_ORG_ID = "default-org";
41
- function haraDir() {
42
- return join(homedir(), ".hara");
43
- }
44
- function profilesPath() {
45
- return join(haraDir(), "profiles.json");
46
- }
47
- function configPath() {
48
- return join(haraDir(), "config.json");
49
- }
50
- function orgPath() {
51
- return join(haraDir(), "org.json");
52
- }
53
- function readJSON(p) {
54
- if (!existsSync(p))
55
- return null;
43
+ const MAX_PROFILE_STATE_BYTES = 4 * 1024 * 1024;
44
+ function readPrivateJSON(filename) {
56
45
  try {
57
- return JSON.parse(readFileSync(p, "utf8"));
46
+ const binding = bindPrivateHaraStateFile(homedir(), [], filename);
47
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_PROFILE_STATE_BYTES);
48
+ if (!snapshot)
49
+ return null;
50
+ return { binding, snapshot, value: JSON.parse(snapshot.text) };
58
51
  }
59
52
  catch {
60
53
  return null;
@@ -62,20 +55,13 @@ function readJSON(p) {
62
55
  }
63
56
  /** Write the profiles file 0600 (it can hold device tokens / api keys). */
64
57
  function persistProfilesFile(f) {
65
- const p = profilesPath();
66
- mkdirSync(dirname(p), { recursive: true });
67
- writeFileSync(p, JSON.stringify(f, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
68
- try {
69
- chmodSync(p, 0o600);
70
- }
71
- catch {
72
- /* best-effort */
73
- }
58
+ const binding = bindPrivateHaraStateFile(homedir(), [], "profiles.json");
59
+ writePrivateStateFileSync(binding, JSON.stringify(f, null, 2) + "\n");
74
60
  }
75
61
  /** Synthesize the "personal" profile view from the legacy config.json. The config.json itself
76
62
  * stays the *storage* — this just presents it as a Profile object. */
77
63
  function readPersonalFromConfig() {
78
- const cfg = readJSON(configPath()) ?? {};
64
+ const cfg = readRawConfig();
79
65
  // A legacy user that ran `hara enroll` had their provider written as "hara-gateway" in config.json.
80
66
  // After migration that case is handled separately (default-org profile), so when synthesizing the
81
67
  // personal profile we coerce a stray "hara-gateway" provider to anthropic (the BYOK default) — the
@@ -97,8 +83,7 @@ function readPersonalFromConfig() {
97
83
  };
98
84
  }
99
85
  /** Synthesize a `default-org` profile from the legacy org.json (Enrollment). */
100
- function readDefaultOrgFromOrgJson() {
101
- const e = readJSON(orgPath());
86
+ function readDefaultOrgFromOrgJson(e) {
102
87
  if (!e || !e.gatewayUrl || !e.deviceToken)
103
88
  return null;
104
89
  const defaultModel = e.model || "";
@@ -117,11 +102,12 @@ function readDefaultOrgFromOrgJson() {
117
102
  }
118
103
  /** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
119
104
  function maybeMigrate() {
120
- const existing = readJSON(profilesPath());
105
+ const existing = readPrivateJSON("profiles.json")?.value;
121
106
  if (existing && Array.isArray(existing.profiles) && existing.profiles.length > 0)
122
107
  return existing;
123
108
  const personal = readPersonalFromConfig();
124
- const org = readDefaultOrgFromOrgJson();
109
+ const legacyOrg = readPrivateJSON("org.json");
110
+ const org = readDefaultOrgFromOrgJson(legacyOrg?.value ?? null);
125
111
  const profiles = [personal];
126
112
  let active = PERSONAL_ID;
127
113
  if (org) {
@@ -130,10 +116,13 @@ function maybeMigrate() {
130
116
  }
131
117
  const f = { active, profiles };
132
118
  persistProfilesFile(f);
133
- // Park org.json so we never re-migrate. We keep the file (don't delete data), just rename.
134
- if (org && existsSync(orgPath())) {
119
+ // Park the exact verified legacy bytes without ever following/replacing an alias. If archival races or
120
+ // fails, leave org.json untouched; profiles.json already makes the migration idempotent.
121
+ if (org && legacyOrg) {
135
122
  try {
136
- renameSync(orgPath(), orgPath() + ".legacy");
123
+ const archive = bindPrivateHaraStateFile(homedir(), [], "org.json.legacy");
124
+ writePrivateStateFileSync(archive, legacyOrg.snapshot.text);
125
+ removePrivateStateFile(legacyOrg.binding.path, legacyOrg.snapshot, legacyOrg.binding.directory);
137
126
  }
138
127
  catch {
139
128
  /* best-effort */
@@ -531,31 +520,15 @@ export function routeHost(p) {
531
520
  // config.ts without circular imports. Implemented inline to avoid pulling config.ts.
532
521
  // ────────────────────────────────────────────────────────────────────────────────
533
522
  function setModelOnPersonal(model) {
534
- const p = configPath();
535
- const cfg = readJSON(p) ?? {};
536
- cfg.model = model;
537
- mkdirSync(dirname(p), { recursive: true });
538
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
539
- try {
540
- chmodSync(p, 0o600);
541
- }
542
- catch {
543
- /* best-effort */
544
- }
523
+ updateRawConfig((config) => {
524
+ config.model = model;
525
+ });
545
526
  return { ok: true };
546
527
  }
547
528
  function clearModelOnPersonal() {
548
- const p = configPath();
549
- const cfg = readJSON(p) ?? {};
550
- delete cfg.model;
551
- mkdirSync(dirname(p), { recursive: true });
552
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
553
- try {
554
- chmodSync(p, 0o600);
555
- }
556
- catch {
557
- /* best-effort */
558
- }
529
+ updateRawConfig((config) => {
530
+ delete config.model;
531
+ });
559
532
  return { ok: true };
560
533
  }
561
534
  export { PERSONAL_ID, DEFAULT_ORG_ID };
@@ -2,8 +2,7 @@
2
2
  // Token (access/refresh/resource_url) is stored in ~/.hara/qwen-oauth.json and auto-refreshed.
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
5
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, writePrivateStateFileSync, } from "../security/private-state.js";
7
6
  const BASE = "https://chat.qwen.ai";
8
7
  const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
9
8
  const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
@@ -11,31 +10,19 @@ const CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
11
10
  const SCOPE = "openid profile email model.completion";
12
11
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
13
12
  const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1";
14
- function tokenPath() {
15
- return join(homedir(), ".hara", "qwen-oauth.json");
16
- }
17
13
  export function loadQwenToken() {
18
- const p = tokenPath();
19
- if (!existsSync(p))
20
- return null;
21
14
  try {
22
- return JSON.parse(readFileSync(p, "utf8"));
15
+ const binding = bindPrivateHaraStateFile(homedir(), [], "qwen-oauth.json");
16
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
17
+ return snapshot ? JSON.parse(snapshot.text) : null;
23
18
  }
24
19
  catch {
25
20
  return null;
26
21
  }
27
22
  }
28
23
  function saveQwenToken(t) {
29
- const p = tokenPath();
30
- mkdirSync(join(homedir(), ".hara"), { recursive: true });
31
- // 0600 — the file holds long-lived access + refresh tokens; don't leave it world-readable on shared boxes.
32
- writeFileSync(p, JSON.stringify(t, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
33
- try {
34
- chmodSync(p, 0o600); // tighten an existing file that predated the mode
35
- }
36
- catch {
37
- /* best-effort */
38
- }
24
+ const binding = bindPrivateHaraStateFile(homedir(), [], "qwen-oauth.json");
25
+ writePrivateStateFileSync(binding, JSON.stringify(t, null, 2) + "\n");
39
26
  }
40
27
  const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
41
28
  function pkce() {
@@ -16,6 +16,7 @@ import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
16
16
  import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
17
17
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
18
18
  import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
19
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
19
20
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
20
21
  const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
21
22
  const projectPermissionWarnings = new Set();
@@ -472,7 +473,7 @@ function scaffoldProjectPermissions(cwd) {
472
473
  fd = undefined;
473
474
  verifiedDirectory(parent, parentIdentity);
474
475
  const staged = lstatSync(temp);
475
- if (!staged.isFile() || staged.isSymbolicLink() || staged.dev !== tempIdentity.dev || staged.ino !== tempIdentity.ino) {
476
+ if (!staged.isFile() || staged.isSymbolicLink() || !sameOpenedFileIdentity(staged, tempIdentity)) {
476
477
  throw new Error("refusing project permissions write: staging file identity changed");
477
478
  }
478
479
  verifiedDirectory(parent, parentIdentity);
@@ -490,7 +491,7 @@ function scaffoldProjectPermissions(cwd) {
490
491
  }
491
492
  verifiedDirectory(parent, parentIdentity);
492
493
  const written = lstatSync(target);
493
- if (!written.isFile() || written.isSymbolicLink() || written.dev !== tempIdentity.dev || written.ino !== tempIdentity.ino) {
494
+ if (!written.isFile() || written.isSymbolicLink() || !sameOpenedFileIdentity(written, tempIdentity)) {
494
495
  throw new Error("refusing project permissions write: committed file identity changed");
495
496
  }
496
497
  unlinkSync(temp);
@@ -507,7 +508,7 @@ function scaffoldProjectPermissions(cwd) {
507
508
  try {
508
509
  verifiedDirectory(parent, parentIdentity);
509
510
  const current = lstatSync(temp);
510
- if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino)
511
+ if (current.isFile() && sameOpenedFileIdentity(current, tempIdentity))
511
512
  unlinkSync(temp);
512
513
  }
513
514
  catch {
@@ -1,10 +1,13 @@
1
1
  // Owner-only migration for Hara's local control plane. New writers should still create private files
2
2
  // directly, but this repairs installations created by older releases and makes ~/.hara non-traversable by
3
3
  // other local users before credentials/session state are read.
4
- import { closeSync, chmodSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, realpathSync, unlinkSync, } from "node:fs";
4
+ import { closeSync, chmodSync, constants, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { homedir } from "node:os";
6
- import { basename, join, resolve, sep } from "node:path";
7
- import { FileReadLimitError, MAX_EDIT_READ_BYTES, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
9
+ import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
8
11
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
9
12
  const tightenedHomes = new Set();
10
13
  const DEFAULT_MIGRATION_CAP = 50_000;
@@ -23,7 +26,12 @@ function chmodPrivate(path, mode) {
23
26
  }
24
27
  }
25
28
  function checkedPrivateComponent(component) {
26
- if (!component || component === "." || component === ".." || basename(component) !== component || component.includes(sep) || component.includes("\0")) {
29
+ if (!component
30
+ || component === "."
31
+ || component === ".."
32
+ || basename(component) !== component
33
+ || /[\\/]/.test(component)
34
+ || component.includes("\0")) {
27
35
  throw new Error(`invalid private Hara state path component '${component}'`);
28
36
  }
29
37
  return component;
@@ -58,9 +66,10 @@ function verifyAndTightenPrivateDirectory(path) {
58
66
  // no POSIX ownership mode to repair
59
67
  }
60
68
  else {
61
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
62
- const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
63
- const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
69
+ const fd = openSync(path, constants.O_RDONLY
70
+ | optionalPosixOpenFlag("O_NONBLOCK")
71
+ | optionalPosixOpenFlag("O_NOFOLLOW")
72
+ | optionalPosixOpenFlag("O_DIRECTORY"));
64
73
  try {
65
74
  const opened = fstatSync(fd);
66
75
  if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
@@ -128,6 +137,311 @@ export function ensurePrivateStateSubdirectory(base, components, tightenExisting
128
137
  }
129
138
  return parent;
130
139
  }
140
+ /** Bind one immediate file below a symlink-free, owner-only Hara state directory. */
141
+ export function bindPrivateHaraStateFile(home, subdirectories, filename) {
142
+ const directory = ensurePrivateStateSubdirectory(home, [".hara", ...subdirectories]);
143
+ const name = checkedPrivateComponent(filename);
144
+ const path = join(directory.path, name);
145
+ if (dirname(path) !== directory.path)
146
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
147
+ verifyPrivateDirectory(directory);
148
+ return { directory, path };
149
+ }
150
+ function privateReadLimit(maxBytes) {
151
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
152
+ return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
153
+ }
154
+ function readFdBytes(fd, count) {
155
+ const out = Buffer.allocUnsafe(count);
156
+ let offset = 0;
157
+ while (offset < count) {
158
+ const read = readSync(fd, out, offset, count - offset, offset);
159
+ if (!read)
160
+ break;
161
+ offset += read;
162
+ }
163
+ return out.subarray(0, offset);
164
+ }
165
+ /** Synchronous private-state reader for startup/auth APIs that cannot make their public contract async. */
166
+ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES) {
167
+ const limit = privateReadLimit(maxBytes);
168
+ let before;
169
+ try {
170
+ before = lstatSync(path);
171
+ }
172
+ catch (error) {
173
+ if (error?.code === "ENOENT")
174
+ return null;
175
+ throw error;
176
+ }
177
+ if (before.isSymbolicLink())
178
+ throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
179
+ const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
180
+ try {
181
+ let info = fstatSync(fd);
182
+ verifyOpenedRegularFileSync(path, info, {
183
+ action: "read private Hara state",
184
+ rejectHardLinks: true,
185
+ protectSensitive: false,
186
+ });
187
+ try {
188
+ fchmodSync(fd, 0o600);
189
+ }
190
+ catch (error) {
191
+ if (process.platform !== "win32")
192
+ throw error;
193
+ }
194
+ // chmod may update ctime; capture the authoritative baseline afterwards.
195
+ info = fstatSync(fd);
196
+ if (info.size > limit)
197
+ throw new FileReadLimitError(path, limit);
198
+ const bytes = readFdBytes(fd, Math.min(limit + 1, info.size + 1));
199
+ if (bytes.length > limit)
200
+ throw new FileReadLimitError(path, limit);
201
+ const latest = fstatSync(fd);
202
+ verifyOpenedRegularFileSync(path, latest, {
203
+ action: "read private Hara state",
204
+ rejectHardLinks: true,
205
+ protectSensitive: false,
206
+ });
207
+ if (latest.dev !== info.dev
208
+ || latest.ino !== info.ino
209
+ || latest.size !== info.size
210
+ || latest.mtimeMs !== info.mtimeMs
211
+ || latest.ctimeMs !== info.ctimeMs)
212
+ throw new Error(`private Hara state file changed while reading: '${path}'`);
213
+ return {
214
+ text: decodeUtf8Strict(bytes, path),
215
+ dev: latest.dev,
216
+ ino: latest.ino,
217
+ mode: latest.mode & 0o777,
218
+ nlink: latest.nlink,
219
+ size: latest.size,
220
+ mtimeMs: latest.mtimeMs,
221
+ ctimeMs: latest.ctimeMs,
222
+ };
223
+ }
224
+ finally {
225
+ closeSync(fd);
226
+ }
227
+ }
228
+ function samePrivateFile(path, expected) {
229
+ const info = lstatSync(path);
230
+ return (info.isFile()
231
+ && !info.isSymbolicLink()
232
+ && privateStateFileIdentityMatches({
233
+ dev: info.dev,
234
+ ino: info.ino,
235
+ mode: info.mode & 0o777,
236
+ nlink: info.nlink,
237
+ }, expected));
238
+ }
239
+ /**
240
+ * Compare the fields Node can use as a stable file identity on the current platform. Windows only exposes
241
+ * owner read/write permission semantics, so its synthetic POSIX mode can differ between descriptor- and
242
+ * path-based stats without identifying a different file. File type, dev/ino and hard-link count remain
243
+ * mandatory there; POSIX additionally requires the exact owner-only mode.
244
+ */
245
+ export function privateStateFileIdentityMatches(actual, expected, platform = process.platform) {
246
+ return (sameOpenedFileIdentity(actual, expected, platform)
247
+ && actual.nlink === expected.nlink
248
+ && (platform === "win32" || actual.mode === expected.mode));
249
+ }
250
+ function privateFileIdentitySummary(path) {
251
+ try {
252
+ const info = lstatSync(path);
253
+ return JSON.stringify({
254
+ file: info.isFile(),
255
+ symlink: info.isSymbolicLink(),
256
+ dev: info.dev,
257
+ ino: info.ino,
258
+ mode: info.mode & 0o777,
259
+ nlink: info.nlink,
260
+ });
261
+ }
262
+ catch (error) {
263
+ return JSON.stringify({ error: error?.code ?? error?.message ?? String(error) });
264
+ }
265
+ }
266
+ function restorePrivateClaim(claimed, target, expected) {
267
+ if (!samePrivateFile(claimed, expected)) {
268
+ throw new Error(`private Hara state claim changed; original entry is preserved at '${claimed}'`);
269
+ }
270
+ try {
271
+ linkSync(claimed, target);
272
+ }
273
+ catch (error) {
274
+ if (error?.code === "EEXIST") {
275
+ throw new Error(`another entry appeared at '${target}'; original entry is preserved at '${claimed}'`);
276
+ }
277
+ throw error;
278
+ }
279
+ const linked = { ...expected, nlink: expected.nlink + 1 };
280
+ if (!samePrivateFile(claimed, linked) || !samePrivateFile(target, linked)) {
281
+ throw new Error(`private Hara state restore changed; original entry is preserved at '${claimed}'`);
282
+ }
283
+ unlinkSync(claimed);
284
+ }
285
+ function syncPrivateDirectory(path) {
286
+ try {
287
+ const fd = openSync(path, constants.O_RDONLY
288
+ | optionalPosixOpenFlag("O_NONBLOCK")
289
+ | optionalPosixOpenFlag("O_NOFOLLOW")
290
+ | optionalPosixOpenFlag("O_DIRECTORY"));
291
+ try {
292
+ if (fstatSync(fd).isDirectory())
293
+ fsyncSync(fd);
294
+ }
295
+ finally {
296
+ closeSync(fd);
297
+ }
298
+ }
299
+ catch {
300
+ /* Directory fsync is not portable; the staged file itself is always fsynced. */
301
+ }
302
+ }
303
+ /**
304
+ * Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
305
+ * are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
306
+ */
307
+ export function writePrivateStateFileSync(binding, text) {
308
+ const { directory, path } = binding;
309
+ verifyPrivateDirectory(directory);
310
+ if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
311
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
312
+ }
313
+ const existing = readPrivateStateFileSnapshotSync(path);
314
+ verifyPrivateDirectory(directory);
315
+ const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
316
+ let fd;
317
+ let staged;
318
+ try {
319
+ // `wx` maps to O_WRONLY|O_CREAT|O_EXCL/CREATE_NEW and refuses an already-present symlink or file.
320
+ // Bun's Windows numeric-open compatibility can otherwise turn this valid create into a false ENOENT.
321
+ fd = openSync(temp, "wx", 0o600);
322
+ writeFileSync(fd, text, "utf8");
323
+ try {
324
+ fchmodSync(fd, 0o600);
325
+ }
326
+ catch (error) {
327
+ if (process.platform !== "win32")
328
+ throw error;
329
+ }
330
+ fsyncSync(fd);
331
+ const stagedInfo = fstatSync(fd);
332
+ if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
333
+ throw new Error("unsafe private Hara state staging inode");
334
+ staged = {
335
+ dev: stagedInfo.dev,
336
+ ino: stagedInfo.ino,
337
+ mode: stagedInfo.mode & 0o777,
338
+ nlink: stagedInfo.nlink,
339
+ };
340
+ closeSync(fd);
341
+ fd = undefined;
342
+ if (!existing) {
343
+ try {
344
+ verifyPrivateDirectory(directory);
345
+ linkSync(temp, path);
346
+ }
347
+ catch (error) {
348
+ if (error?.code === "EEXIST")
349
+ throw new Error(`private Hara state file changed before create: '${path}'`);
350
+ throw error;
351
+ }
352
+ }
353
+ else {
354
+ const claimed = join(directory.path, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
355
+ try {
356
+ verifyPrivateDirectory(directory);
357
+ renameSync(path, claimed);
358
+ }
359
+ catch (error) {
360
+ if (error?.code === "ENOENT")
361
+ throw new Error(`private Hara state file changed before replace: '${path}'`);
362
+ throw error;
363
+ }
364
+ let verifiedClaim = false;
365
+ try {
366
+ const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
367
+ verifiedClaim = Boolean(claimedSnapshot
368
+ && sameOpenedFileIdentity(claimedSnapshot, existing)
369
+ && claimedSnapshot.mode === existing.mode
370
+ && claimedSnapshot.nlink === existing.nlink
371
+ && claimedSnapshot.text === existing.text);
372
+ if (!verifiedClaim)
373
+ throw new Error(`private Hara state file changed before replace: '${path}'`);
374
+ }
375
+ catch (error) {
376
+ try {
377
+ restorePrivateClaim(claimed, path, existing);
378
+ }
379
+ catch (restoreError) {
380
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
381
+ }
382
+ throw error;
383
+ }
384
+ try {
385
+ verifyPrivateDirectory(directory);
386
+ linkSync(temp, path);
387
+ }
388
+ catch (error) {
389
+ let recovery = "";
390
+ try {
391
+ restorePrivateClaim(claimed, path, existing);
392
+ }
393
+ catch (restoreError) {
394
+ recovery = `; original entry is retained: ${restoreError?.message ?? String(restoreError)}`;
395
+ }
396
+ throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
397
+ }
398
+ if (verifiedClaim) {
399
+ try {
400
+ if (samePrivateFile(claimed, existing))
401
+ unlinkSync(claimed);
402
+ }
403
+ catch {
404
+ // The new entry is already committed. Retain a changed/unremovable unpredictable claim instead of
405
+ // risking deletion of a concurrently supplied path; it contains only the previous private state.
406
+ }
407
+ }
408
+ }
409
+ const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
410
+ if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
411
+ throw new Error(`private Hara state staging identity changed during commit: '${path}'`
412
+ + `; expected=${JSON.stringify(linkedStaged)}`
413
+ + `; staging=${privateFileIdentitySummary(temp)}`
414
+ + `; target=${privateFileIdentitySummary(path)}`);
415
+ }
416
+ unlinkSync(temp);
417
+ const committed = lstatSync(path);
418
+ if (!staged
419
+ || !committed.isFile()
420
+ || committed.isSymbolicLink()
421
+ || !sameOpenedFileIdentity(committed, staged)
422
+ || committed.nlink !== 1
423
+ || (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
424
+ throw new Error(`private Hara state file changed during commit: '${path}'`);
425
+ verifyPrivateDirectory(directory);
426
+ syncPrivateDirectory(directory.path);
427
+ }
428
+ finally {
429
+ if (fd !== undefined)
430
+ try {
431
+ closeSync(fd);
432
+ }
433
+ catch { /* preserve original error */ }
434
+ if (staged) {
435
+ try {
436
+ if (samePrivateFile(temp, staged))
437
+ unlinkSync(temp);
438
+ }
439
+ catch {
440
+ /* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
441
+ }
442
+ }
443
+ }
444
+ }
131
445
  /** Open/read one internal state file without following aliases, reject hard links, and repair mode by fd. */
132
446
  export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
133
447
  const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
@@ -185,7 +499,7 @@ export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_REA
185
499
  || after.ctimeMs !== before.ctimeMs)
186
500
  throw new Error(`private Hara state file changed while reading: '${path}'`);
187
501
  return {
188
- text: Buffer.concat(chunks, total).toString("utf8"),
502
+ text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
189
503
  dev: after.dev,
190
504
  ino: after.ino,
191
505
  mode: after.mode & 0o777,
@@ -209,9 +523,8 @@ export function removePrivateStateFile(path, expected, directory) {
209
523
  if (!current.isFile()
210
524
  || current.isSymbolicLink()
211
525
  || current.nlink !== 1
212
- || current.dev !== expected.dev
213
- || current.ino !== expected.ino
214
- || (current.mode & 0o777) !== expected.mode
526
+ || !sameOpenedFileIdentity(current, expected)
527
+ || (process.platform !== "win32" && (current.mode & 0o777) !== expected.mode)
215
528
  || current.size !== expected.size
216
529
  || current.mtimeMs !== expected.mtimeMs
217
530
  || current.ctimeMs !== expected.ctimeMs)