@nanhara/hara 0.123.0 → 0.124.0

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.
@@ -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() {
@@ -1,10 +1,11 @@
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";
8
9
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
9
10
  const tightenedHomes = new Set();
10
11
  const DEFAULT_MIGRATION_CAP = 50_000;
@@ -23,7 +24,12 @@ function chmodPrivate(path, mode) {
23
24
  }
24
25
  }
25
26
  function checkedPrivateComponent(component) {
26
- if (!component || component === "." || component === ".." || basename(component) !== component || component.includes(sep) || component.includes("\0")) {
27
+ if (!component
28
+ || component === "."
29
+ || component === ".."
30
+ || basename(component) !== component
31
+ || /[\\/]/.test(component)
32
+ || component.includes("\0")) {
27
33
  throw new Error(`invalid private Hara state path component '${component}'`);
28
34
  }
29
35
  return component;
@@ -128,6 +134,280 @@ export function ensurePrivateStateSubdirectory(base, components, tightenExisting
128
134
  }
129
135
  return parent;
130
136
  }
137
+ /** Bind one immediate file below a symlink-free, owner-only Hara state directory. */
138
+ export function bindPrivateHaraStateFile(home, subdirectories, filename) {
139
+ const directory = ensurePrivateStateSubdirectory(home, [".hara", ...subdirectories]);
140
+ const name = checkedPrivateComponent(filename);
141
+ const path = join(directory.path, name);
142
+ if (dirname(path) !== directory.path)
143
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
144
+ verifyPrivateDirectory(directory);
145
+ return { directory, path };
146
+ }
147
+ function privateReadLimit(maxBytes) {
148
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
149
+ return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
150
+ }
151
+ function readFdBytes(fd, count) {
152
+ const out = Buffer.allocUnsafe(count);
153
+ let offset = 0;
154
+ while (offset < count) {
155
+ const read = readSync(fd, out, offset, count - offset, offset);
156
+ if (!read)
157
+ break;
158
+ offset += read;
159
+ }
160
+ return out.subarray(0, offset);
161
+ }
162
+ /** Synchronous private-state reader for startup/auth APIs that cannot make their public contract async. */
163
+ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES) {
164
+ const limit = privateReadLimit(maxBytes);
165
+ let before;
166
+ try {
167
+ before = lstatSync(path);
168
+ }
169
+ catch (error) {
170
+ if (error?.code === "ENOENT")
171
+ return null;
172
+ throw error;
173
+ }
174
+ if (before.isSymbolicLink())
175
+ throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
176
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
177
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
178
+ try {
179
+ let info = fstatSync(fd);
180
+ verifyOpenedRegularFileSync(path, info, {
181
+ action: "read private Hara state",
182
+ rejectHardLinks: true,
183
+ protectSensitive: false,
184
+ });
185
+ try {
186
+ fchmodSync(fd, 0o600);
187
+ }
188
+ catch (error) {
189
+ if (process.platform !== "win32")
190
+ throw error;
191
+ }
192
+ // chmod may update ctime; capture the authoritative baseline afterwards.
193
+ info = fstatSync(fd);
194
+ if (info.size > limit)
195
+ throw new FileReadLimitError(path, limit);
196
+ const bytes = readFdBytes(fd, Math.min(limit + 1, info.size + 1));
197
+ if (bytes.length > limit)
198
+ throw new FileReadLimitError(path, limit);
199
+ const latest = fstatSync(fd);
200
+ verifyOpenedRegularFileSync(path, latest, {
201
+ action: "read private Hara state",
202
+ rejectHardLinks: true,
203
+ protectSensitive: false,
204
+ });
205
+ if (latest.dev !== info.dev
206
+ || latest.ino !== info.ino
207
+ || latest.size !== info.size
208
+ || latest.mtimeMs !== info.mtimeMs
209
+ || latest.ctimeMs !== info.ctimeMs)
210
+ throw new Error(`private Hara state file changed while reading: '${path}'`);
211
+ return {
212
+ text: decodeUtf8Strict(bytes, path),
213
+ dev: latest.dev,
214
+ ino: latest.ino,
215
+ mode: latest.mode & 0o777,
216
+ nlink: latest.nlink,
217
+ size: latest.size,
218
+ mtimeMs: latest.mtimeMs,
219
+ ctimeMs: latest.ctimeMs,
220
+ };
221
+ }
222
+ finally {
223
+ closeSync(fd);
224
+ }
225
+ }
226
+ function samePrivateFile(path, expected) {
227
+ const info = lstatSync(path);
228
+ return (info.isFile()
229
+ && !info.isSymbolicLink()
230
+ && info.dev === expected.dev
231
+ && info.ino === expected.ino
232
+ && (info.mode & 0o777) === expected.mode
233
+ && info.nlink === expected.nlink);
234
+ }
235
+ function restorePrivateClaim(claimed, target, expected) {
236
+ if (!samePrivateFile(claimed, expected)) {
237
+ throw new Error(`private Hara state claim changed; original entry is preserved at '${claimed}'`);
238
+ }
239
+ try {
240
+ linkSync(claimed, target);
241
+ }
242
+ catch (error) {
243
+ if (error?.code === "EEXIST") {
244
+ throw new Error(`another entry appeared at '${target}'; original entry is preserved at '${claimed}'`);
245
+ }
246
+ throw error;
247
+ }
248
+ const linked = { ...expected, nlink: expected.nlink + 1 };
249
+ if (!samePrivateFile(claimed, linked) || !samePrivateFile(target, linked)) {
250
+ throw new Error(`private Hara state restore changed; original entry is preserved at '${claimed}'`);
251
+ }
252
+ unlinkSync(claimed);
253
+ }
254
+ function syncPrivateDirectory(path) {
255
+ try {
256
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
257
+ const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
258
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
259
+ try {
260
+ if (fstatSync(fd).isDirectory())
261
+ fsyncSync(fd);
262
+ }
263
+ finally {
264
+ closeSync(fd);
265
+ }
266
+ }
267
+ catch {
268
+ /* Directory fsync is not portable; the staged file itself is always fsynced. */
269
+ }
270
+ }
271
+ /**
272
+ * Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
273
+ * are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
274
+ */
275
+ export function writePrivateStateFileSync(binding, text) {
276
+ const { directory, path } = binding;
277
+ verifyPrivateDirectory(directory);
278
+ if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
279
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
280
+ }
281
+ const existing = readPrivateStateFileSnapshotSync(path);
282
+ verifyPrivateDirectory(directory);
283
+ const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
284
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
285
+ let fd;
286
+ let staged;
287
+ try {
288
+ fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
289
+ writeFileSync(fd, text, "utf8");
290
+ try {
291
+ fchmodSync(fd, 0o600);
292
+ }
293
+ catch (error) {
294
+ if (process.platform !== "win32")
295
+ throw error;
296
+ }
297
+ fsyncSync(fd);
298
+ const stagedInfo = fstatSync(fd);
299
+ if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
300
+ throw new Error("unsafe private Hara state staging inode");
301
+ staged = {
302
+ dev: stagedInfo.dev,
303
+ ino: stagedInfo.ino,
304
+ mode: stagedInfo.mode & 0o777,
305
+ nlink: stagedInfo.nlink,
306
+ };
307
+ closeSync(fd);
308
+ fd = undefined;
309
+ if (!existing) {
310
+ try {
311
+ verifyPrivateDirectory(directory);
312
+ linkSync(temp, path);
313
+ }
314
+ catch (error) {
315
+ if (error?.code === "EEXIST")
316
+ throw new Error(`private Hara state file changed before create: '${path}'`);
317
+ throw error;
318
+ }
319
+ }
320
+ else {
321
+ const claimed = join(directory.path, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
322
+ try {
323
+ verifyPrivateDirectory(directory);
324
+ renameSync(path, claimed);
325
+ }
326
+ catch (error) {
327
+ if (error?.code === "ENOENT")
328
+ throw new Error(`private Hara state file changed before replace: '${path}'`);
329
+ throw error;
330
+ }
331
+ let verifiedClaim = false;
332
+ try {
333
+ const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
334
+ verifiedClaim = Boolean(claimedSnapshot
335
+ && claimedSnapshot.dev === existing.dev
336
+ && claimedSnapshot.ino === existing.ino
337
+ && claimedSnapshot.mode === existing.mode
338
+ && claimedSnapshot.nlink === existing.nlink
339
+ && claimedSnapshot.text === existing.text);
340
+ if (!verifiedClaim)
341
+ throw new Error(`private Hara state file changed before replace: '${path}'`);
342
+ }
343
+ catch (error) {
344
+ try {
345
+ restorePrivateClaim(claimed, path, existing);
346
+ }
347
+ catch (restoreError) {
348
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; safe restore was incomplete: ${restoreError?.message ?? String(restoreError)}`, { cause: error });
349
+ }
350
+ throw error;
351
+ }
352
+ try {
353
+ verifyPrivateDirectory(directory);
354
+ linkSync(temp, path);
355
+ }
356
+ catch (error) {
357
+ let recovery = "";
358
+ try {
359
+ restorePrivateClaim(claimed, path, existing);
360
+ }
361
+ catch (restoreError) {
362
+ recovery = `; original entry is retained: ${restoreError?.message ?? String(restoreError)}`;
363
+ }
364
+ throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
365
+ }
366
+ if (verifiedClaim) {
367
+ try {
368
+ if (samePrivateFile(claimed, existing))
369
+ unlinkSync(claimed);
370
+ }
371
+ catch {
372
+ // The new entry is already committed. Retain a changed/unremovable unpredictable claim instead of
373
+ // risking deletion of a concurrently supplied path; it contains only the previous private state.
374
+ }
375
+ }
376
+ }
377
+ const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
378
+ if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
379
+ throw new Error(`private Hara state staging identity changed during commit: '${path}'`);
380
+ }
381
+ unlinkSync(temp);
382
+ const committed = lstatSync(path);
383
+ if (!staged
384
+ || !committed.isFile()
385
+ || committed.isSymbolicLink()
386
+ || committed.dev !== staged.dev
387
+ || committed.ino !== staged.ino
388
+ || committed.nlink !== 1
389
+ || (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
390
+ throw new Error(`private Hara state file changed during commit: '${path}'`);
391
+ verifyPrivateDirectory(directory);
392
+ syncPrivateDirectory(directory.path);
393
+ }
394
+ finally {
395
+ if (fd !== undefined)
396
+ try {
397
+ closeSync(fd);
398
+ }
399
+ catch { /* preserve original error */ }
400
+ if (staged) {
401
+ try {
402
+ if (samePrivateFile(temp, staged))
403
+ unlinkSync(temp);
404
+ }
405
+ catch {
406
+ /* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
407
+ }
408
+ }
409
+ }
410
+ }
131
411
  /** Open/read one internal state file without following aliases, reject hard links, and repair mode by fd. */
132
412
  export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
133
413
  const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
@@ -185,7 +465,7 @@ export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_REA
185
465
  || after.ctimeMs !== before.ctimeMs)
186
466
  throw new Error(`private Hara state file changed while reading: '${path}'`);
187
467
  return {
188
- text: Buffer.concat(chunks, total).toString("utf8"),
468
+ text: decodeUtf8Strict(Buffer.concat(chunks, total), path),
189
469
  dev: after.dev,
190
470
  ino: after.ino,
191
471
  mode: after.mode & 0o777,