release-skill 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +14 -0
  7. package/INSTALL.md +24 -4
  8. package/INSTALL.zh-CN.md +22 -4
  9. package/README.md +15 -13
  10. package/README.zh-CN.md +10 -13
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +1 -1
  14. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  15. package/adapters/codex/bin/release-skill.bundle.mjs +1 -1
  16. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  17. package/adapters/kimi/bin/release-skill.bundle.mjs +1 -1
  18. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +10 -0
  19. package/adapters/workbuddy/bin/release-skill.bundle.mjs +85363 -0
  20. package/adapters/workbuddy/bin/release-skill.mjs +54 -0
  21. package/adapters/workbuddy/native/safe-write/binding.gyp +41 -0
  22. package/adapters/workbuddy/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  23. package/adapters/workbuddy/native/safe-write/prebuilds.json +24 -0
  24. package/adapters/workbuddy/native/safe-write/src/safe_write.cc +2032 -0
  25. package/adapters/workbuddy/schemas/.render-manifest.json +37 -0
  26. package/adapters/workbuddy/schemas/approval-record.schema.json +115 -0
  27. package/adapters/workbuddy/schemas/artifact-lock.schema.json +111 -0
  28. package/adapters/workbuddy/schemas/artifact-plan.schema.json +52 -0
  29. package/adapters/workbuddy/schemas/artifact-policy.schema.json +76 -0
  30. package/adapters/workbuddy/schemas/evidence-event.schema.json +89 -0
  31. package/adapters/workbuddy/schemas/release-plan.schema.json +882 -0
  32. package/adapters/workbuddy/schemas/release-project.schema.json +909 -0
  33. package/adapters/workbuddy/schemas/release-run.schema.json +343 -0
  34. package/adapters/workbuddy/skills/release-assess/SKILL.md +51 -0
  35. package/adapters/workbuddy/skills/release-help/SKILL.md +77 -0
  36. package/adapters/workbuddy/skills/release-prepare/SKILL.md +92 -0
  37. package/adapters/workbuddy/skills/release-publish/SKILL.md +57 -0
  38. package/adapters/workbuddy/skills/release-reconcile/SKILL.md +73 -0
  39. package/adapters/workbuddy/skills/release-setup/SKILL.md +95 -0
  40. package/adapters/workbuddy/skills/release-verify/SKILL.md +70 -0
  41. package/bin/release-skill.bundle.mjs +1 -1
  42. package/package.json +2 -1
  43. package/references/06-adapter-contract.md +1 -1
  44. package/scripts/sync-public-files.mjs +21 -2
  45. package/src/producers/build-adapters.mjs +38 -4
@@ -0,0 +1,2032 @@
1
+ /**
2
+ * Node-API safe write addon — identity-bound / fd-relative primitives.
3
+ *
4
+ * All directory traversal uses openat() with O_NOFOLLOW | O_CLOEXEC.
5
+ * openRoot walks from "/" one segment at a time to prevent ancestor-symlink
6
+ * attacks. readFile opens via openat(O_RDONLY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC)
7
+ * to prevent FIFO blocking, validates via fstat on the fd (regular, nlink=1),
8
+ * reads all bytes, then performs post-read identity verification.
9
+ *
10
+ * createTemp validates Buffer data and mode (0..0777), writes data with
11
+ * EINTR/partial-write handling, applies fchmod for exact mode, and returns
12
+ * a TempToken bound to the open temp fd, dup'd directory fd, creation-time
13
+ * dev/ino, temp name, and active/committed state. The TempToken object is
14
+ * tagged with napi_type_tag for identity verification.
15
+ *
16
+ * rename verifies temp-file identity via the token's fd, then executes an
17
+ * atomic no-replace rename, verifies target identity (dev/ino/nlink/type),
18
+ * fsyncs the directory, and consumes the token.
19
+ *
20
+ * abortTemp only unlinks when identity matches (dev/ino/type + nlink==1).
21
+ * chmod uses fd-relative openat + fchmod (not path-based fchmodat).
22
+ *
23
+ * All error messages expose only syscall, errno, and safe segment —
24
+ * never absolute paths.
25
+ *
26
+ * @file native/safe-write/src/safe_write.cc
27
+ */
28
+
29
+ // Platform feature macros must come before all other includes.
30
+ #if defined(__APPLE__)
31
+ # ifndef _DARWIN_C_SOURCE
32
+ # define _DARWIN_C_SOURCE
33
+ # endif
34
+ #endif
35
+
36
+ #include <node_api.h>
37
+
38
+ #include <cerrno>
39
+ #include <climits>
40
+ #include <cstdio>
41
+ #include <cstdlib>
42
+ #include <cstring>
43
+ #include <new>
44
+ #include <string>
45
+ #include <vector>
46
+
47
+ #include <fcntl.h>
48
+ #include <sys/stat.h>
49
+ #include <unistd.h>
50
+
51
+ #if defined(__linux__)
52
+ # include <sys/syscall.h>
53
+ #endif
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Platform-specific timespec accessors for st_mtim/st_ctim.
57
+ // macOS uses st_mtimespec/st_ctimespec; Linux uses st_mtim/st_ctim.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ #if defined(__APPLE__)
61
+ # define ST_MTIM_NS(st) \
62
+ ((int64_t)(st).st_mtimespec.tv_sec * 1000000000LL + (st).st_mtimespec.tv_nsec)
63
+ # define ST_CTIM_NS(st) \
64
+ ((int64_t)(st).st_ctimespec.tv_sec * 1000000000LL + (st).st_ctimespec.tv_nsec)
65
+ #elif defined(__linux__)
66
+ # define ST_MTIM_NS(st) \
67
+ ((int64_t)(st).st_mtim.tv_sec * 1000000000LL + (st).st_mtim.tv_nsec)
68
+ # define ST_CTIM_NS(st) \
69
+ ((int64_t)(st).st_ctim.tv_sec * 1000000000LL + (st).st_ctim.tv_nsec)
70
+ #else
71
+ # define ST_MTIM_NS(st) ((int64_t)(st).st_mtime * 1000000000LL)
72
+ # define ST_CTIM_NS(st) ((int64_t)(st).st_ctime * 1000000000LL)
73
+ #endif
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Type tags for N-API object identity verification.
77
+ // Random 64-bit values baked into the binary to prevent cross-addon forgery.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ static constexpr uint64_t kDirHandleTag = 0x8a3f1b2c4d5e6f70ULL;
81
+ static constexpr uint64_t kTempTokenTag = 0xd4e5f6a7b8c90123ULL;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Test seam for deterministic N-API failure injection.
85
+ // Only available in test builds (SAFE_WRITE_TEST_SEAM=1).
86
+ // Production builds never define this, so the test hook does not exist.
87
+ // ---------------------------------------------------------------------------
88
+
89
+ #ifdef SAFE_WRITE_TEST_SEAM
90
+ #include <unordered_map>
91
+ #include <mutex>
92
+
93
+ // Test seam state: maps failure point names to failure counts.
94
+ // A count of -1 means "fail on every call".
95
+ static std::mutex g_test_seam_mutex;
96
+ static std::unordered_map<std::string, int> g_test_seam_failures;
97
+
98
+ // Test seam statistics: tracks calls and failures for each failure point.
99
+ static std::unordered_map<std::string, int> g_test_seam_calls;
100
+ static std::unordered_map<std::string, int> g_test_seam_failed;
101
+
102
+ // Check if a test seam failure should be triggered.
103
+ // Returns true if the failure should be injected.
104
+ static bool TestSeamShouldFail(const char* point) {
105
+ std::lock_guard<std::mutex> lock(g_test_seam_mutex);
106
+ g_test_seam_calls[point]++;
107
+ auto it = g_test_seam_failures.find(point);
108
+ if (it != g_test_seam_failures.end()) {
109
+ if (it->second == -1) {
110
+ // Fail every time
111
+ g_test_seam_failed[point]++;
112
+ return true;
113
+ }
114
+ if (it->second > 0) {
115
+ // Fail N times
116
+ it->second--;
117
+ g_test_seam_failed[point]++;
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ // Test seam failure point names for CreateTemp N-API calls.
125
+ static const char* kSeamCreateObject = "createTemp:napi_create_object";
126
+ static const char* kSeamCreateString = "createTemp:napi_create_string_utf8";
127
+ static const char* kSeamCreateExternal = "createTemp:napi_create_external";
128
+ static const char* kSeamSetProperty = "createTemp:napi_set_named_property";
129
+ static const char* kSeamTypeTag = "createTemp:napi_type_tag_object";
130
+
131
+ // Test seam failure point names for other functions.
132
+ static const char* kSeamOpenRootCreateObject = "openRoot:napi_create_object";
133
+ static const char* kSeamOpenDirCreateObject = "openDir:napi_create_object";
134
+ static const char* kSeamReadFileCreateObject = "readFile:napi_create_object";
135
+ static const char* kSeamAbortTempCreateObject = "abortTemp:napi_create_object";
136
+ static const char* kSeamAbortUnlink = "abortTemp:unlinkat";
137
+
138
+ // Reset all test seam state.
139
+ static void TestSeamReset() {
140
+ std::lock_guard<std::mutex> lock(g_test_seam_mutex);
141
+ g_test_seam_failures.clear();
142
+ g_test_seam_calls.clear();
143
+ g_test_seam_failed.clear();
144
+ }
145
+
146
+ // Configure a test seam failure point.
147
+ // count: -1 = fail every time, 0 = don't fail, N = fail N times.
148
+ static void TestSeamConfigure(const char* point, int count) {
149
+ std::lock_guard<std::mutex> lock(g_test_seam_mutex);
150
+ g_test_seam_failures[point] = count;
151
+ }
152
+
153
+ // Get the number of times a failure point was triggered.
154
+ static int TestSeamGetFailedCount(const char* point) {
155
+ std::lock_guard<std::mutex> lock(g_test_seam_mutex);
156
+ return g_test_seam_failed[point];
157
+ }
158
+
159
+ // Get the number of times a failure point was called.
160
+ static int TestSeamGetCallCount(const char* point) {
161
+ std::lock_guard<std::mutex> lock(g_test_seam_mutex);
162
+ return g_test_seam_calls[point];
163
+ }
164
+ #endif // SAFE_WRITE_TEST_SEAM
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // N-API status check macro
168
+ //
169
+ // NAPI_CHECK — for napi_value-returning functions; returns nullptr on failure.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ #define NAPI_CHECK(call) \
173
+ do { \
174
+ napi_status _s = (call); \
175
+ if (_s != napi_ok) { \
176
+ napi_throw_error(env, nullptr, "N-API call failed: " #call); \
177
+ return nullptr; \
178
+ } \
179
+ } while (0)
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Platform-specific atomic no-replace rename
183
+ // ---------------------------------------------------------------------------
184
+
185
+ #if defined(__APPLE__)
186
+ # include <sys/errno.h>
187
+ # ifndef RENAME_EXCL
188
+ # define RENAME_EXCL 0x00000001
189
+ # endif
190
+ static int safe_rename_noreplace(int src_dirfd, const char* src_name,
191
+ int dst_dirfd, const char* dst_name) {
192
+ return renameatx_np(src_dirfd, src_name, dst_dirfd, dst_name, RENAME_EXCL);
193
+ }
194
+ static int safe_rename_exchange(int src_dirfd, const char* src_name,
195
+ int dst_dirfd, const char* dst_name) {
196
+ return renameatx_np(src_dirfd, src_name, dst_dirfd, dst_name, RENAME_SWAP);
197
+ }
198
+
199
+ #elif defined(__linux__)
200
+ # ifndef RENAME_NOREPLACE
201
+ # define RENAME_NOREPLACE (1 << 0)
202
+ # endif
203
+ # ifndef RENAME_EXCHANGE
204
+ # define RENAME_EXCHANGE (1 << 1)
205
+ # endif
206
+ static int safe_rename_noreplace(int src_dirfd, const char* src_name,
207
+ int dst_dirfd, const char* dst_name) {
208
+ return static_cast<int>(
209
+ syscall(SYS_renameat2, src_dirfd, src_name, dst_dirfd, dst_name,
210
+ static_cast<unsigned>(RENAME_NOREPLACE)));
211
+ }
212
+ static int safe_rename_exchange(int src_dirfd, const char* src_name,
213
+ int dst_dirfd, const char* dst_name) {
214
+ return static_cast<int>(
215
+ syscall(SYS_renameat2, src_dirfd, src_name, dst_dirfd, dst_name,
216
+ static_cast<unsigned>(RENAME_EXCHANGE)));
217
+ }
218
+
219
+ #else
220
+ static int safe_rename_noreplace(int /*src_dirfd*/, const char* /*src_name*/,
221
+ int /*dst_dirfd*/, const char* /*dst_name*/) {
222
+ errno = ENOSYS;
223
+ return -1;
224
+ }
225
+ static int safe_rename_exchange(int /*src_dirfd*/, const char* /*src_name*/,
226
+ int /*dst_dirfd*/, const char* /*dst_name*/) {
227
+ errno = ENOSYS;
228
+ return -1;
229
+ }
230
+ #endif
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Helpers
234
+ // ---------------------------------------------------------------------------
235
+
236
+ static napi_value ThrowCode(napi_env env, const char* code, const char* msg) {
237
+ napi_value code_val, msg_val;
238
+ napi_create_string_utf8(env, code, NAPI_AUTO_LENGTH, &code_val);
239
+ napi_create_string_utf8(env, msg, NAPI_AUTO_LENGTH, &msg_val);
240
+ napi_value error;
241
+ napi_create_error(env, code_val, msg_val, &error);
242
+ napi_throw(env, error);
243
+ return nullptr;
244
+ }
245
+
246
+ static napi_value ThrowErrno(napi_env env, const char* syscall, int err,
247
+ const char* segment) {
248
+ char buf[512];
249
+ snprintf(buf, sizeof(buf), "%s: errno=%d segment=%s", syscall, err, segment);
250
+ return ThrowCode(env, "PATH_UNSAFE", buf);
251
+ }
252
+
253
+ static bool IsSafeSegment(const char* s, size_t len) {
254
+ if (len == 0) return false;
255
+ if (s[0] == '.' && (len == 1 || (len == 2 && s[1] == '.'))) return false;
256
+ for (size_t i = 0; i < len; ++i) {
257
+ if (s[i] == '/' || s[i] == '\0') return false;
258
+ }
259
+ return true;
260
+ }
261
+
262
+ /** Segment validation that allows "." for directory self-query (e.g. readEntry(".")),
263
+ * but still rejects "..", slashes, and NUL. */
264
+ static bool IsSafeSegmentAllowDot(const char* s, size_t len) {
265
+ if (len == 0) return false;
266
+ if (len == 1 && s[0] == '.') return true; // Allow "."
267
+ if (s[0] == '.' && len == 2 && s[1] == '.') return false; // Reject ".."
268
+ for (size_t i = 0; i < len; ++i) {
269
+ if (s[i] == '/' || s[i] == '\0') return false;
270
+ }
271
+ return true;
272
+ }
273
+
274
+ /** Close fd without EINTR retry. Per POSIX, close() behavior on EINTR is
275
+ * undefined — the fd may or may not be closed. Retrying risks closing a
276
+ * reused fd. CLOEXEC handles cleanup at process exit. */
277
+ static int SafeClose(int fd) {
278
+ return ::close(fd);
279
+ }
280
+
281
+ /** UTF-8 extraction with safe NUL-terminated buffer.
282
+ * Returns false and throws on N-API failure; caller must return immediately. */
283
+ static bool GetUtf8(napi_env env, napi_value val, std::string& out) {
284
+ size_t len = 0;
285
+ if (napi_get_value_string_utf8(env, val, nullptr, 0, &len) != napi_ok) {
286
+ napi_throw_error(env, nullptr, "N-API call failed: napi_get_value_string_utf8");
287
+ return false;
288
+ }
289
+ std::vector<char> buf(len + 1, '\0');
290
+ size_t written = 0;
291
+ if (napi_get_value_string_utf8(env, val, buf.data(), len + 1, &written) !=
292
+ napi_ok) {
293
+ napi_throw_error(env, nullptr, "N-API call failed: napi_get_value_string_utf8");
294
+ return false;
295
+ }
296
+ out = std::string(buf.data(), written);
297
+ return true;
298
+ }
299
+
300
+ /** Extract int32 from N-API value.
301
+ * Returns false and throws on N-API failure; caller must return immediately. */
302
+ static bool GetInt32(napi_env env, napi_value val, int32_t& out) {
303
+ if (napi_get_value_int32(env, val, &out) != napi_ok) {
304
+ napi_throw_error(env, nullptr, "N-API call failed: napi_get_value_int32");
305
+ return false;
306
+ }
307
+ return true;
308
+ }
309
+
310
+ static bool GetNamedInt64(napi_env env, napi_value obj, const char* name,
311
+ int64_t& out) {
312
+ napi_value value;
313
+ if (napi_get_named_property(env, obj, name, &value) != napi_ok) return false;
314
+ return napi_get_value_int64(env, value, &out) == napi_ok;
315
+ }
316
+
317
+ static bool GetNamedDouble(napi_env env, napi_value obj, const char* name,
318
+ double& out) {
319
+ napi_value value;
320
+ if (napi_get_named_property(env, obj, name, &value) != napi_ok) return false;
321
+ return napi_get_value_double(env, value, &out) == napi_ok;
322
+ }
323
+
324
+ struct ExpectedIdentity {
325
+ int64_t dev;
326
+ int64_t ino;
327
+ int64_t nlink;
328
+ int64_t size;
329
+ int64_t mode;
330
+ double mtime_ns;
331
+ double ctime_ns;
332
+ };
333
+
334
+ static bool GetExpectedIdentity(napi_env env, napi_value value,
335
+ ExpectedIdentity& expected) {
336
+ napi_valuetype type;
337
+ if (napi_typeof(env, value, &type) != napi_ok || type != napi_object)
338
+ return false;
339
+ return GetNamedInt64(env, value, "dev", expected.dev) &&
340
+ GetNamedInt64(env, value, "ino", expected.ino) &&
341
+ GetNamedInt64(env, value, "nlink", expected.nlink) &&
342
+ GetNamedInt64(env, value, "size", expected.size) &&
343
+ GetNamedInt64(env, value, "mode", expected.mode) &&
344
+ GetNamedDouble(env, value, "mtimeNs", expected.mtime_ns) &&
345
+ GetNamedDouble(env, value, "ctimeNs", expected.ctime_ns);
346
+ }
347
+
348
+ static bool StatMatchesExpected(const struct stat& st,
349
+ const ExpectedIdentity& expected) {
350
+ return S_ISREG(st.st_mode) &&
351
+ static_cast<int64_t>(st.st_dev) == expected.dev &&
352
+ static_cast<int64_t>(st.st_ino) == expected.ino &&
353
+ static_cast<int64_t>(st.st_nlink) == expected.nlink &&
354
+ static_cast<int64_t>(st.st_size) == expected.size &&
355
+ static_cast<int64_t>(st.st_mode & 07777) == expected.mode &&
356
+ static_cast<double>(ST_MTIM_NS(st)) == expected.mtime_ns &&
357
+ static_cast<double>(ST_CTIM_NS(st)) == expected.ctime_ns;
358
+ }
359
+
360
+ // A successful directory-entry rename may update ctime even though the file
361
+ // content and inode are unchanged. Post-exchange verification therefore keeps
362
+ // every stable identity/content field except ctime; mtime still detects an
363
+ // in-place content mutation.
364
+ static bool StatMatchesExpectedAfterRename(
365
+ const struct stat& st, const ExpectedIdentity& expected) {
366
+ return S_ISREG(st.st_mode) &&
367
+ static_cast<int64_t>(st.st_dev) == expected.dev &&
368
+ static_cast<int64_t>(st.st_ino) == expected.ino &&
369
+ static_cast<int64_t>(st.st_nlink) == expected.nlink &&
370
+ static_cast<int64_t>(st.st_size) == expected.size &&
371
+ static_cast<int64_t>(st.st_mode & 07777) == expected.mode &&
372
+ static_cast<double>(ST_MTIM_NS(st)) == expected.mtime_ns;
373
+ }
374
+
375
+ /** Get the fd from a JS handle object, verifying napi_type_tag.
376
+ * Returns -1 on failure (invalid handle), without throwing. */
377
+ static int GetDirFd(napi_env env, napi_value js_handle_obj) {
378
+ if (!js_handle_obj) return -1;
379
+ napi_valuetype t;
380
+ if (napi_typeof(env, js_handle_obj, &t) != napi_ok) return -1;
381
+ if (t != napi_object) return -1;
382
+
383
+ // Verify type tag — rejects objects from other addons or forged objects
384
+ bool tag_ok = false;
385
+ napi_type_tag check_tag = {kDirHandleTag, 0};
386
+ if (napi_check_object_type_tag(env, js_handle_obj, &check_tag, &tag_ok) !=
387
+ napi_ok)
388
+ return -1;
389
+ if (!tag_ok) return -1;
390
+
391
+ napi_value ext;
392
+ if (napi_get_named_property(env, js_handle_obj, "_handle", &ext) != napi_ok)
393
+ return -1;
394
+ void* data = nullptr;
395
+ if (napi_get_value_external(env, ext, &data) != napi_ok) return -1;
396
+ if (!data) return -1;
397
+ return *static_cast<int*>(data);
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Segment extraction — argv[idx] must be a safe path segment string
402
+ // ---------------------------------------------------------------------------
403
+
404
+ struct SegmentArg {
405
+ std::string value;
406
+ bool ok;
407
+ };
408
+
409
+ static SegmentArg GetSegment(napi_env env, napi_value* argv, size_t argc,
410
+ size_t idx, bool allow_dot = false) {
411
+ if (argc <= idx || !argv[idx]) return {"", false};
412
+ napi_valuetype t;
413
+ if (napi_typeof(env, argv[idx], &t) != napi_ok) return {"", false};
414
+ if (t != napi_string) return {"", false};
415
+ std::string s;
416
+ if (!GetUtf8(env, argv[idx], s)) return {"", false};
417
+ if (allow_dot ? !IsSafeSegmentAllowDot(s.c_str(), s.size())
418
+ : !IsSafeSegment(s.c_str(), s.size()))
419
+ return {"", false};
420
+ return {s, true};
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Directory handle — just a bare fd stored as an napi_external.
425
+ // The external's destructor closes the fd.
426
+ // ---------------------------------------------------------------------------
427
+
428
+ static void DirFdFinalize(napi_env /*env*/, void* data, void* /*hint*/) {
429
+ if (data) {
430
+ int fd = *static_cast<int*>(data);
431
+ if (fd >= 0) SafeClose(fd);
432
+ delete static_cast<int*>(data);
433
+ }
434
+ }
435
+
436
+ /** Create a JS object wrapping a bare int fd via napi_external.
437
+ * Tags the object with kDirHandleTag for type verification.
438
+ * On N-API failure after fd allocation, closes the fd to prevent leaks. */
439
+ static napi_value WrapDirFd(napi_env env, int fd) {
440
+ napi_value result, ext;
441
+ if (napi_create_object(env, &result) != napi_ok) {
442
+ SafeClose(fd);
443
+ napi_throw_error(env, nullptr, "N-API call failed: napi_create_object");
444
+ return nullptr;
445
+ }
446
+ int* heap_fd = new (std::nothrow) int(fd);
447
+ if (!heap_fd) {
448
+ SafeClose(fd);
449
+ napi_throw_error(env, nullptr, "allocation failed");
450
+ return nullptr;
451
+ }
452
+ if (napi_create_external(env, heap_fd, DirFdFinalize, nullptr, &ext) !=
453
+ napi_ok) {
454
+ // napi_create_external failed — heap_fd destructor doesn't close fd
455
+ SafeClose(fd);
456
+ delete heap_fd;
457
+ napi_throw_error(env, nullptr, "N-API call failed: napi_create_external");
458
+ return nullptr;
459
+ }
460
+ if (napi_set_named_property(env, result, "_handle", ext) != napi_ok) {
461
+ // ext destructor will call DirFdFinalize which closes fd
462
+ napi_throw_error(env, nullptr, "N-API call failed: napi_set_named_property");
463
+ return nullptr;
464
+ }
465
+ // Tag the object so only our addon's handles pass type check
466
+ napi_type_tag tag = {kDirHandleTag, 0};
467
+ if (napi_type_tag_object(env, result, &tag) != napi_ok) {
468
+ napi_throw_error(env, nullptr, "N-API call failed: napi_type_tag_object");
469
+ return nullptr;
470
+ }
471
+ return result;
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // TempToken — identity-bound safe temp file token
476
+ // ---------------------------------------------------------------------------
477
+
478
+ struct TempToken {
479
+ int tmp_fd;
480
+ int dir_fd;
481
+ dev_t tmp_dev;
482
+ ino_t tmp_ino;
483
+ char name[PATH_MAX];
484
+ bool active;
485
+ bool committed;
486
+
487
+ TempToken()
488
+ : tmp_fd(-1), dir_fd(-1), tmp_dev(0), tmp_ino(0), active(true),
489
+ committed(false) {
490
+ name[0] = '\0';
491
+ }
492
+
493
+ ~TempToken() { CloseFds(); }
494
+
495
+ void CloseFds() {
496
+ if (tmp_fd >= 0) {
497
+ SafeClose(tmp_fd);
498
+ tmp_fd = -1;
499
+ }
500
+ if (dir_fd >= 0) {
501
+ SafeClose(dir_fd);
502
+ dir_fd = -1;
503
+ }
504
+ }
505
+
506
+ TempToken(const TempToken&) = delete;
507
+ TempToken& operator=(const TempToken&) = delete;
508
+ };
509
+
510
+ // Forward declarations — defined in the identity-bound cleanup section below.
511
+ static bool identity_unlink(TempToken* tok);
512
+ static void cleanup_temp(TempToken* tok);
513
+
514
+ static void TempTokenFinalize(napi_env /*env*/, void* data, void* /*hint*/) {
515
+ TempToken* tok = static_cast<TempToken*>(data);
516
+ if (tok) {
517
+ // Identity-bound cleanup: only unlink if the token is still active
518
+ // (not consumed by rename/abortTemp or already cleaned up by a
519
+ // CreateTemp N-API failure path).
520
+ if (tok->active && !tok->committed) {
521
+ identity_unlink(tok);
522
+ }
523
+ delete tok;
524
+ }
525
+ }
526
+
527
+ /** Unwrap a TempToken from a JS object, verifying napi_type_tag.
528
+ * Returns nullptr if the object is not a genuine token from this addon.
529
+ * On N-API failure, returns nullptr (caller must check). */
530
+ static TempToken* GetTempToken(napi_env env, napi_value js_obj) {
531
+ if (!js_obj) return nullptr;
532
+ napi_valuetype t;
533
+ if (napi_typeof(env, js_obj, &t) != napi_ok) return nullptr;
534
+ if (t != napi_object) return nullptr;
535
+
536
+ // Verify type tag
537
+ bool tag_ok = false;
538
+ napi_type_tag check_tag = {kTempTokenTag, 0};
539
+ if (napi_check_object_type_tag(env, js_obj, &check_tag, &tag_ok) != napi_ok)
540
+ return nullptr;
541
+ if (!tag_ok) return nullptr;
542
+
543
+ napi_value tok_ext;
544
+ if (napi_get_named_property(env, js_obj, "_token", &tok_ext) != napi_ok)
545
+ return nullptr;
546
+ void* tok_data = nullptr;
547
+ if (napi_get_value_external(env, tok_ext, &tok_data) != napi_ok)
548
+ return nullptr;
549
+ return static_cast<TempToken*>(tok_data);
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // openRoot(path) -> { _handle }
554
+ //
555
+ // Walks from "/" one segment at a time with openat(O_DIRECTORY|O_NOFOLLOW|
556
+ // O_CLOEXEC). Rejects non-absolute paths, ancestor symlinks, ".", "..",
557
+ // and NUL bytes.
558
+ // ---------------------------------------------------------------------------
559
+
560
+ static napi_value OpenRoot(napi_env env, napi_callback_info info) {
561
+ size_t argc = 1;
562
+ napi_value argv[1];
563
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
564
+ if (argc < 1)
565
+ return ThrowCode(env, "PATH_UNSAFE", "openRoot requires a path");
566
+
567
+ napi_valuetype t;
568
+ NAPI_CHECK(napi_typeof(env, argv[0], &t));
569
+ if (t != napi_string)
570
+ return ThrowCode(env, "PATH_UNSAFE", "openRoot path must be a string");
571
+
572
+ std::string path;
573
+ if (!GetUtf8(env, argv[0], path))
574
+ return nullptr;
575
+
576
+ if (path.empty() || path[0] != '/') {
577
+ return ThrowCode(env, "PATH_UNSAFE", "openRoot requires absolute path");
578
+ }
579
+
580
+ int cur_fd = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
581
+ if (cur_fd < 0) {
582
+ return ThrowErrno(env, "open", errno, "/");
583
+ }
584
+
585
+ size_t pos = 1;
586
+ while (pos < path.size()) {
587
+ size_t next = path.find('/', pos);
588
+ if (next == std::string::npos) next = path.size();
589
+
590
+ std::string seg = path.substr(pos, next - pos);
591
+ pos = next + 1;
592
+
593
+ if (seg.empty()) continue;
594
+
595
+ if (!IsSafeSegment(seg.c_str(), seg.size())) {
596
+ SafeClose(cur_fd);
597
+ return ThrowErrno(env, "openat", EINVAL, seg.c_str());
598
+ }
599
+
600
+ int child_fd = openat(cur_fd, seg.c_str(),
601
+ O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
602
+ int saved_errno = errno;
603
+ SafeClose(cur_fd);
604
+
605
+ if (child_fd < 0) {
606
+ return ThrowErrno(env, "openat", saved_errno, seg.c_str());
607
+ }
608
+ cur_fd = child_fd;
609
+ }
610
+
611
+ return WrapDirFd(env, cur_fd);
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // openDir(handle, name) -> { _handle }
616
+ // ---------------------------------------------------------------------------
617
+
618
+ static napi_value OpenDir(napi_env env, napi_callback_info info) {
619
+ size_t argc = 4;
620
+ napi_value argv[4];
621
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
622
+
623
+ int parent_fd = GetDirFd(env, argv[0]);
624
+ if (parent_fd < 0)
625
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
626
+
627
+ auto seg = GetSegment(env, argv, argc, 1);
628
+ if (!seg.ok)
629
+ return ThrowCode(env, "PATH_UNSAFE", "openDir: unsafe segment");
630
+
631
+ int child_fd = openat(parent_fd, seg.value.c_str(),
632
+ O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
633
+ if (child_fd < 0)
634
+ return ThrowErrno(env, "openat", errno, seg.value.c_str());
635
+
636
+ return WrapDirFd(env, child_fd);
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // readEntry(handle, name) -> { type, size, mode, dev, ino, nlink } | null
641
+ // ---------------------------------------------------------------------------
642
+
643
+ static napi_value ReadEntry(napi_env env, napi_callback_info info) {
644
+ size_t argc = 4;
645
+ napi_value argv[4];
646
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
647
+
648
+ int dir_fd = GetDirFd(env, argv[0]);
649
+ if (dir_fd < 0)
650
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
651
+
652
+ // Allow "." for directory self-query (e.g. readEntry(".") to inspect own metadata)
653
+ auto seg = GetSegment(env, argv, argc, 1, /*allow_dot=*/true);
654
+ if (!seg.ok)
655
+ return ThrowCode(env, "PATH_UNSAFE", "readEntry: unsafe segment");
656
+
657
+ struct stat st;
658
+ // For "." use fstat on the directory fd itself (not fstatat on ".")
659
+ if (seg.value == ".") {
660
+ if (fstat(dir_fd, &st) < 0) {
661
+ return ThrowErrno(env, "fstat", errno, ".");
662
+ }
663
+ } else {
664
+ if (fstatat(dir_fd, seg.value.c_str(), &st, AT_SYMLINK_NOFOLLOW) < 0) {
665
+ if (errno == ENOENT) {
666
+ napi_value null_val;
667
+ napi_get_null(env, &null_val);
668
+ return null_val;
669
+ }
670
+ return ThrowErrno(env, "fstatat", errno, seg.value.c_str());
671
+ }
672
+ }
673
+
674
+ napi_value result;
675
+ NAPI_CHECK(napi_create_object(env, &result));
676
+
677
+ const char* type = "unknown";
678
+ if (S_ISREG(st.st_mode)) type = "file";
679
+ else if (S_ISDIR(st.st_mode)) type = "directory";
680
+ else if (S_ISLNK(st.st_mode)) type = "symlink";
681
+ else if (S_ISFIFO(st.st_mode)) type = "fifo";
682
+ else if (S_ISBLK(st.st_mode)) type = "block-device";
683
+ else if (S_ISCHR(st.st_mode)) type = "char-device";
684
+ else if (S_ISSOCK(st.st_mode)) type = "socket";
685
+
686
+ napi_value type_val, size_val, mode_val, dev_val, ino_val, nlink_val;
687
+ NAPI_CHECK(napi_create_string_utf8(env, type, NAPI_AUTO_LENGTH, &type_val));
688
+ NAPI_CHECK(
689
+ napi_create_int64(env, static_cast<int64_t>(st.st_size), &size_val));
690
+ NAPI_CHECK(
691
+ napi_create_int32(env, static_cast<int32_t>(st.st_mode & 07777),
692
+ &mode_val));
693
+ NAPI_CHECK(
694
+ napi_create_int64(env, static_cast<int64_t>(st.st_dev), &dev_val));
695
+ NAPI_CHECK(
696
+ napi_create_int64(env, static_cast<int64_t>(st.st_ino), &ino_val));
697
+ NAPI_CHECK(
698
+ napi_create_int64(env, static_cast<int64_t>(st.st_nlink), &nlink_val));
699
+
700
+ NAPI_CHECK(napi_set_named_property(env, result, "type", type_val));
701
+ NAPI_CHECK(napi_set_named_property(env, result, "size", size_val));
702
+ NAPI_CHECK(napi_set_named_property(env, result, "mode", mode_val));
703
+ NAPI_CHECK(napi_set_named_property(env, result, "dev", dev_val));
704
+ NAPI_CHECK(napi_set_named_property(env, result, "ino", ino_val));
705
+ NAPI_CHECK(napi_set_named_property(env, result, "nlink", nlink_val));
706
+ return result;
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // readFile(handle, name) -> { bytes: Buffer, size, mode, dev, ino, nlink }
711
+ //
712
+ // Opens with O_RDONLY|O_NONBLOCK|O_NOFOLLOW|O_CLOEXEC to prevent FIFO
713
+ // blocking. Validates via fstat on the opened fd (regular, nlink=1),
714
+ // reads all bytes, then performs post-read identity verification.
715
+ // ---------------------------------------------------------------------------
716
+
717
+ static napi_value ReadFile(napi_env env, napi_callback_info info) {
718
+ size_t argc = 4;
719
+ napi_value argv[4];
720
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
721
+
722
+ int dir_fd = GetDirFd(env, argv[0]);
723
+ if (dir_fd < 0)
724
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
725
+
726
+ auto seg = GetSegment(env, argv, argc, 1);
727
+ if (!seg.ok)
728
+ return ThrowCode(env, "PATH_UNSAFE", "readFile: unsafe segment");
729
+
730
+ // Open with O_NONBLOCK to prevent blocking on FIFOs (security: B1).
731
+ // O_NOFOLLOW rejects symlinks at open time.
732
+ int file_fd = openat(dir_fd, seg.value.c_str(),
733
+ O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC);
734
+ if (file_fd < 0) {
735
+ if (errno == ENOENT) {
736
+ napi_value null_val;
737
+ napi_get_null(env, &null_val);
738
+ return null_val;
739
+ }
740
+ return ThrowErrno(env, "openat", errno, seg.value.c_str());
741
+ }
742
+
743
+ // Validate via fstat on the opened fd
744
+ struct stat st;
745
+ if (fstat(file_fd, &st) < 0) {
746
+ int saved = errno;
747
+ SafeClose(file_fd);
748
+ return ThrowErrno(env, "fstat", saved, seg.value.c_str());
749
+ }
750
+
751
+ if (!S_ISREG(st.st_mode)) {
752
+ SafeClose(file_fd);
753
+ return ThrowCode(env, "PATH_UNSAFE", "readFile: not a regular file");
754
+ }
755
+
756
+ if (st.st_nlink != 1) {
757
+ SafeClose(file_fd);
758
+ return ThrowCode(env, "PATH_UNSAFE",
759
+ "readFile: unexpected hard link count");
760
+ }
761
+
762
+ // Read all bytes — always loop (even from size=0) to handle concurrent
763
+ // growth, partial reads, and EINTR. Documented capacity limit: 256 MiB.
764
+ constexpr size_t kMaxReadCapacity = 256ULL * 1024 * 1024; // 256 MiB
765
+ std::vector<uint8_t> buf;
766
+ {
767
+ size_t init_size = (st.st_size > 0)
768
+ ? static_cast<size_t>(st.st_size)
769
+ : 65536;
770
+ if (init_size > kMaxReadCapacity) init_size = kMaxReadCapacity;
771
+ buf.resize(init_size);
772
+ size_t total = 0;
773
+ while (true) {
774
+ size_t want = buf.size() - total;
775
+ if (want == 0) {
776
+ // Buffer full — check capacity before growing
777
+ if (buf.size() >= kMaxReadCapacity) {
778
+ SafeClose(file_fd);
779
+ return ThrowCode(env, "PATH_UNSAFE",
780
+ "readFile: capacity limit exceeded (256 MiB)");
781
+ }
782
+ size_t new_cap = buf.size() + 65536;
783
+ if (new_cap > kMaxReadCapacity) new_cap = kMaxReadCapacity;
784
+ buf.resize(new_cap);
785
+ want = buf.size() - total;
786
+ }
787
+ ssize_t n = ::read(file_fd, buf.data() + total, want);
788
+ if (n < 0) {
789
+ if (errno == EINTR) continue;
790
+ int saved = errno;
791
+ SafeClose(file_fd);
792
+ return ThrowErrno(env, "read", saved, seg.value.c_str());
793
+ }
794
+ if (n == 0) break;
795
+ total += static_cast<size_t>(n);
796
+ }
797
+ buf.resize(total);
798
+ }
799
+
800
+ // Post-read identity verification (B3): check that the file hasn't been
801
+ // concurrently replaced or modified. Compare dev/ino/type/nlink/size/mtime/ctime.
802
+ struct stat post_st;
803
+ if (fstat(file_fd, &post_st) < 0) {
804
+ int saved = errno;
805
+ SafeClose(file_fd);
806
+ return ThrowErrno(env, "fstat", saved, seg.value.c_str());
807
+ }
808
+ if (post_st.st_dev != st.st_dev || post_st.st_ino != st.st_ino ||
809
+ !S_ISREG(post_st.st_mode) || post_st.st_nlink != st.st_nlink) {
810
+ SafeClose(file_fd);
811
+ return ThrowCode(env, "PATH_UNSAFE",
812
+ "readFile: file identity changed during read");
813
+ }
814
+ if (static_cast<size_t>(post_st.st_size) != buf.size()) {
815
+ SafeClose(file_fd);
816
+ return ThrowCode(env, "PATH_UNSAFE",
817
+ "readFile: file size changed during read");
818
+ }
819
+ // Compare mtime and ctime (with nanosecond precision on supported platforms)
820
+ if (ST_MTIM_NS(post_st) != ST_MTIM_NS(st) ||
821
+ ST_CTIM_NS(post_st) != ST_CTIM_NS(st)) {
822
+ SafeClose(file_fd);
823
+ return ThrowCode(env, "PATH_UNSAFE",
824
+ "readFile: file timestamp changed during read");
825
+ }
826
+
827
+ SafeClose(file_fd);
828
+
829
+ // Build result
830
+ napi_value result;
831
+ NAPI_CHECK(napi_create_object(env, &result));
832
+
833
+ napi_value bytes_val;
834
+ void* copy_data = nullptr;
835
+ NAPI_CHECK(napi_create_buffer_copy(env, buf.size(), buf.data(), &copy_data,
836
+ &bytes_val));
837
+ (void)copy_data;
838
+
839
+ napi_value size_val, mode_val, dev_val, ino_val, nlink_val, mtime_val, ctime_val;
840
+ NAPI_CHECK(
841
+ napi_create_int64(env, static_cast<int64_t>(post_st.st_size), &size_val));
842
+ NAPI_CHECK(
843
+ napi_create_int32(env, static_cast<int32_t>(post_st.st_mode & 07777),
844
+ &mode_val));
845
+ NAPI_CHECK(
846
+ napi_create_int64(env, static_cast<int64_t>(post_st.st_dev), &dev_val));
847
+ NAPI_CHECK(
848
+ napi_create_int64(env, static_cast<int64_t>(post_st.st_ino), &ino_val));
849
+ NAPI_CHECK(
850
+ napi_create_int64(env, static_cast<int64_t>(post_st.st_nlink),
851
+ &nlink_val));
852
+ NAPI_CHECK(
853
+ napi_create_double(env, static_cast<double>(ST_MTIM_NS(post_st)),
854
+ &mtime_val));
855
+ NAPI_CHECK(
856
+ napi_create_double(env, static_cast<double>(ST_CTIM_NS(post_st)),
857
+ &ctime_val));
858
+
859
+ NAPI_CHECK(napi_set_named_property(env, result, "bytes", bytes_val));
860
+ NAPI_CHECK(napi_set_named_property(env, result, "size", size_val));
861
+ NAPI_CHECK(napi_set_named_property(env, result, "mode", mode_val));
862
+ NAPI_CHECK(napi_set_named_property(env, result, "dev", dev_val));
863
+ NAPI_CHECK(napi_set_named_property(env, result, "ino", ino_val));
864
+ NAPI_CHECK(napi_set_named_property(env, result, "nlink", nlink_val));
865
+ NAPI_CHECK(napi_set_named_property(env, result, "mtimeNs", mtime_val));
866
+ NAPI_CHECK(napi_set_named_property(env, result, "ctimeNs", ctime_val));
867
+
868
+ return result;
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // Identity-bound temp cleanup.
873
+ //
874
+ // Single cleanup path shared by all CreateTemp N-API failure branches and
875
+ // the TempTokenFinalize.
876
+ //
877
+ // identity_unlink(tok) — two-phase identity check:
878
+ // 1) fstat(tok->tmp_fd) on the open fd
879
+ // 2) fstatat(tok->dir_fd, tok->name) on the directory entry
880
+ // 3) Both must match each other AND creation-time dev/ino, be S_ISREG,
881
+ // and have nlink==1 before unlinking.
882
+ // On success, marks tok->active = false.
883
+ //
884
+ // Before napi_create_external: the caller owns tok and must delete it.
885
+ // Use cleanup_temp(tok) — identity-bound unlink, close fds, delete.
886
+ // After napi_create_external: tok is owned by the finalizer; use
887
+ // identity_unlink(tok) — identity-bound unlink only, do NOT close fds
888
+ // or delete (the finalizer handles that).
889
+ // ---------------------------------------------------------------------------
890
+
891
+ /** Identity-bound unlink: two-phase check (fd + directory entry) against
892
+ * creation-time identity before unlinking. Returns true if the file was
893
+ * unlinked, false if identity didn't match or unlink failed. */
894
+ static bool identity_unlink(TempToken* tok) {
895
+ if (!tok || tok->dir_fd < 0 || tok->name[0] == '\0') return false;
896
+ // Phase 1: fstat the open fd
897
+ struct stat fd_st;
898
+ if (fstat(tok->tmp_fd, &fd_st) < 0) return false;
899
+ if (!S_ISREG(fd_st.st_mode) || fd_st.st_nlink != 1) return false;
900
+ if (fd_st.st_dev != tok->tmp_dev || fd_st.st_ino != tok->tmp_ino)
901
+ return false;
902
+ // Phase 2: fstatat the directory entry
903
+ struct stat dir_st;
904
+ if (fstatat(tok->dir_fd, tok->name, &dir_st, AT_SYMLINK_NOFOLLOW) < 0)
905
+ return false;
906
+ if (!S_ISREG(dir_st.st_mode) || dir_st.st_nlink != 1) return false;
907
+ if (dir_st.st_dev != tok->tmp_dev || dir_st.st_ino != tok->tmp_ino)
908
+ return false;
909
+ // Both phases passed — unlink
910
+ if (unlinkat(tok->dir_fd, tok->name, 0) == 0) {
911
+ tok->active = false;
912
+ return true;
913
+ }
914
+ return false;
915
+ }
916
+
917
+ /** Identity-bound unlink, close+delete the token (caller owns tok). */
918
+ static void cleanup_temp(TempToken* tok) {
919
+ if (!tok) return;
920
+ identity_unlink(tok);
921
+ tok->CloseFds();
922
+ delete tok;
923
+ }
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // createTemp(handle, name, mode, Buffer) -> { tempName, _token }
927
+ //
928
+ // Validates: name is safe segment, mode is 0..0777, data is Buffer.
929
+ // Creates temp file, writes data, fsyncs, applies fchmod for exact mode,
930
+ // captures identity, and returns a type-tagged TempToken.
931
+ // ---------------------------------------------------------------------------
932
+
933
+ static napi_value CreateTemp(napi_env env, napi_callback_info info) {
934
+ size_t argc = 4;
935
+ napi_value argv[4];
936
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
937
+
938
+ int dir_fd = GetDirFd(env, argv[0]);
939
+ if (dir_fd < 0)
940
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
941
+
942
+ if (argc < 4)
943
+ return ThrowCode(env, "PATH_UNSAFE",
944
+ "createTemp requires name, mode, and data");
945
+
946
+ auto seg = GetSegment(env, argv, argc, 1);
947
+ if (!seg.ok)
948
+ return ThrowCode(env, "PATH_UNSAFE", "createTemp: unsafe name segment");
949
+
950
+ // Validate mode type (must be int32) before extracting value (A2)
951
+ {
952
+ napi_valuetype mt;
953
+ NAPI_CHECK(napi_typeof(env, argv[2], &mt));
954
+ if (mt != napi_number) {
955
+ return ThrowCode(env, "PATH_UNSAFE",
956
+ "createTemp: mode must be a number");
957
+ }
958
+ // Reject NaN and non-integer doubles (e.g. 644.5, Infinity)
959
+ double dval = 0;
960
+ NAPI_CHECK(napi_get_value_double(env, argv[2], &dval));
961
+ if (dval != dval || dval == INFINITY) { // NaN check via self-inequality
962
+ return ThrowCode(env, "PATH_UNSAFE",
963
+ "createTemp: mode must be an integer");
964
+ }
965
+ if (static_cast<double>(static_cast<int32_t>(dval)) != dval) {
966
+ return ThrowCode(env, "PATH_UNSAFE",
967
+ "createTemp: mode must be an integer");
968
+ }
969
+ }
970
+
971
+ // Validate mode range (A2)
972
+ int32_t mode = 0;
973
+ if (!GetInt32(env, argv[2], mode))
974
+ return nullptr;
975
+ if (mode < 0 || mode > 0777) {
976
+ return ThrowCode(env, "PATH_UNSAFE",
977
+ "createTemp: mode must be 0..0777");
978
+ }
979
+
980
+ // Require Buffer data — empty Buffer is legal, but must be a Buffer (A2)
981
+ if (!argv[3]) {
982
+ return ThrowCode(env, "PATH_UNSAFE", "createTemp: data must be a Buffer");
983
+ }
984
+ {
985
+ napi_valuetype dt;
986
+ NAPI_CHECK(napi_typeof(env, argv[3], &dt));
987
+ if (dt != napi_object) {
988
+ return ThrowCode(env, "PATH_UNSAFE",
989
+ "createTemp: data must be a Buffer");
990
+ }
991
+ bool is_buf = false;
992
+ NAPI_CHECK(napi_is_buffer(env, argv[3], &is_buf));
993
+ if (!is_buf) {
994
+ return ThrowCode(env, "PATH_UNSAFE",
995
+ "createTemp: data must be a Buffer");
996
+ }
997
+ }
998
+
999
+ const uint8_t* data = nullptr;
1000
+ size_t data_len = 0;
1001
+ NAPI_CHECK(napi_get_buffer_info(env, argv[3], (void**)&data, &data_len));
1002
+
1003
+ // Generate temp name
1004
+ uint64_t r = 0;
1005
+ #if defined(__APPLE__) || defined(__FreeBSD__)
1006
+ r = (static_cast<uint64_t>(arc4random()) << 32) | arc4random();
1007
+ #else
1008
+ r = (static_cast<uint64_t>(random()) << 32) | random();
1009
+ #endif
1010
+ char tmp_name[PATH_MAX];
1011
+ int name_len = snprintf(tmp_name, sizeof(tmp_name), ".tmp-%s-%d-%llx",
1012
+ seg.value.c_str(), static_cast<int>(getpid()),
1013
+ static_cast<unsigned long long>(r));
1014
+ if (name_len < 0 ||
1015
+ static_cast<size_t>(name_len) >= sizeof(tmp_name)) {
1016
+ return ThrowCode(env, "PATH_UNSAFE",
1017
+ "createTemp: temp name truncated");
1018
+ }
1019
+
1020
+ int tmp_fd = openat(dir_fd, tmp_name,
1021
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC,
1022
+ mode);
1023
+ if (tmp_fd < 0) return ThrowErrno(env, "openat", errno, tmp_name);
1024
+
1025
+ // Write data in a loop (handles partial writes and EINTR)
1026
+ if (data_len > 0) {
1027
+ size_t written = 0;
1028
+ while (written < data_len) {
1029
+ ssize_t n = ::write(tmp_fd, data + written, data_len - written);
1030
+ if (n < 0) {
1031
+ if (errno == EINTR) continue;
1032
+ int saved = errno;
1033
+ SafeClose(tmp_fd);
1034
+ unlinkat(dir_fd, tmp_name, 0);
1035
+ return ThrowErrno(env, "write", saved, tmp_name);
1036
+ }
1037
+ if (n == 0) {
1038
+ SafeClose(tmp_fd);
1039
+ unlinkat(dir_fd, tmp_name, 0);
1040
+ return ThrowErrno(env, "write", EIO, tmp_name);
1041
+ }
1042
+ written += static_cast<size_t>(n);
1043
+ }
1044
+ }
1045
+
1046
+ // Apply exact mode via fchmod on the fd BEFORE fsync (A2).
1047
+ // This ensures the mode is persisted with the data.
1048
+ if (fchmod(tmp_fd, mode) < 0) {
1049
+ int saved = errno;
1050
+ SafeClose(tmp_fd);
1051
+ unlinkat(dir_fd, tmp_name, 0);
1052
+ return ThrowErrno(env, "fchmod", saved, tmp_name);
1053
+ }
1054
+
1055
+ // fsync data AND mode to disk before identity capture
1056
+ if (fsync(tmp_fd) < 0) {
1057
+ int saved = errno;
1058
+ SafeClose(tmp_fd);
1059
+ unlinkat(dir_fd, tmp_name, 0);
1060
+ return ThrowErrno(env, "fsync", saved, tmp_name);
1061
+ }
1062
+
1063
+ // Capture identity of the temp file via fstat on the open fd
1064
+ struct stat tmp_st;
1065
+ if (fstat(tmp_fd, &tmp_st) < 0) {
1066
+ int saved = errno;
1067
+ SafeClose(tmp_fd);
1068
+ unlinkat(dir_fd, tmp_name, 0);
1069
+ return ThrowErrno(env, "fstat", saved, tmp_name);
1070
+ }
1071
+ if (!S_ISREG(tmp_st.st_mode) || tmp_st.st_nlink != 1) {
1072
+ SafeClose(tmp_fd);
1073
+ unlinkat(dir_fd, tmp_name, 0);
1074
+ return ThrowCode(env, "PATH_UNSAFE", "createTemp: identity check failed");
1075
+ }
1076
+
1077
+ // dup the directory fd with CLOEXEC (A5)
1078
+ int token_dir_fd = fcntl(dir_fd, F_DUPFD_CLOEXEC, 0);
1079
+ if (token_dir_fd < 0) {
1080
+ int saved = errno;
1081
+ SafeClose(tmp_fd);
1082
+ unlinkat(dir_fd, tmp_name, 0);
1083
+ return ThrowErrno(env, "fcntl(F_DUPFD_CLOEXEC)", saved, "<dir>");
1084
+ }
1085
+
1086
+ // Build the TempToken
1087
+ TempToken* tok = new (std::nothrow) TempToken();
1088
+ if (!tok) {
1089
+ SafeClose(tmp_fd);
1090
+ SafeClose(token_dir_fd);
1091
+ unlinkat(dir_fd, tmp_name, 0);
1092
+ return ThrowCode(env, "PATH_UNSAFE", "createTemp: allocation failed");
1093
+ }
1094
+ tok->tmp_fd = tmp_fd;
1095
+ tok->dir_fd = token_dir_fd;
1096
+ tok->tmp_dev = tmp_st.st_dev;
1097
+ tok->tmp_ino = tmp_st.st_ino;
1098
+ tok->active = true;
1099
+ tok->committed = false;
1100
+ strncpy(tok->name, tmp_name, PATH_MAX - 1);
1101
+ tok->name[PATH_MAX - 1] = '\0';
1102
+
1103
+ // Build return object: { tempName, _token }
1104
+ // On any N-API failure, unlink the temp file before closing fds to prevent
1105
+ // orphan temp files. cleanup_temp(tok) performs identity-bound unlink
1106
+ // then closes fds and deletes the token.
1107
+ //
1108
+ // Test seam points fire BEFORE the real N-API call. When a seam fires,
1109
+ // it throws PATH_UNSAFE (stable error code) and returns immediately,
1110
+ // skipping the real N-API call. This ensures deterministic testing:
1111
+ // the file stays on disk (no real unlinkat) and the error has a stable code.
1112
+ napi_value result;
1113
+ #ifdef SAFE_WRITE_TEST_SEAM
1114
+ if (TestSeamShouldFail(kSeamCreateObject)) {
1115
+ cleanup_temp(tok);
1116
+ return ThrowCode(env, "PATH_UNSAFE", "test seam: createTemp create_object");
1117
+ }
1118
+ #endif
1119
+ if (napi_create_object(env, &result) != napi_ok) {
1120
+ cleanup_temp(tok);
1121
+ napi_throw_error(env, nullptr, "N-API call failed: napi_create_object");
1122
+ return nullptr;
1123
+ }
1124
+
1125
+ napi_value name_val;
1126
+ #ifdef SAFE_WRITE_TEST_SEAM
1127
+ if (TestSeamShouldFail(kSeamCreateString)) {
1128
+ cleanup_temp(tok);
1129
+ return ThrowCode(env, "PATH_UNSAFE", "test seam: createTemp create_string");
1130
+ }
1131
+ #endif
1132
+ if (napi_create_string_utf8(env, tmp_name, NAPI_AUTO_LENGTH, &name_val) !=
1133
+ napi_ok) {
1134
+ cleanup_temp(tok);
1135
+ napi_throw_error(env, nullptr,
1136
+ "N-API call failed: napi_create_string_utf8");
1137
+ return nullptr;
1138
+ }
1139
+
1140
+ napi_value ext;
1141
+ #ifdef SAFE_WRITE_TEST_SEAM
1142
+ if (TestSeamShouldFail(kSeamCreateExternal)) {
1143
+ cleanup_temp(tok);
1144
+ return ThrowCode(env, "PATH_UNSAFE", "test seam: createTemp create_external");
1145
+ }
1146
+ #endif
1147
+ if (napi_create_external(env, tok, TempTokenFinalize, nullptr, &ext) !=
1148
+ napi_ok) {
1149
+ cleanup_temp(tok);
1150
+ napi_throw_error(env, nullptr, "N-API call failed: napi_create_external");
1151
+ return nullptr;
1152
+ }
1153
+
1154
+ // After napi_create_external succeeded, tok is owned by the finalizer.
1155
+ // We must NOT delete tok or close fds — use identity_unlink(tok) for
1156
+ // identity-bound unlink + mark inactive.
1157
+ #ifdef SAFE_WRITE_TEST_SEAM
1158
+ if (TestSeamShouldFail(kSeamSetProperty)) {
1159
+ identity_unlink(tok);
1160
+ return ThrowCode(env, "PATH_UNSAFE", "test seam: createTemp set_property");
1161
+ }
1162
+ #endif
1163
+ if (napi_set_named_property(env, result, "tempName", name_val) != napi_ok ||
1164
+ napi_set_named_property(env, result, "_token", ext) != napi_ok) {
1165
+ identity_unlink(tok);
1166
+ napi_throw_error(env, nullptr,
1167
+ "N-API call failed: napi_set_named_property");
1168
+ return nullptr;
1169
+ }
1170
+
1171
+ // Tag the token object (A3)
1172
+ napi_type_tag tok_tag = {kTempTokenTag, 0};
1173
+ #ifdef SAFE_WRITE_TEST_SEAM
1174
+ if (TestSeamShouldFail(kSeamTypeTag)) {
1175
+ identity_unlink(tok);
1176
+ return ThrowCode(env, "PATH_UNSAFE", "test seam: createTemp type_tag");
1177
+ }
1178
+ #endif
1179
+ if (napi_type_tag_object(env, result, &tok_tag) != napi_ok) {
1180
+ identity_unlink(tok);
1181
+ napi_throw_error(env, nullptr, "N-API call failed: napi_type_tag_object");
1182
+ return nullptr;
1183
+ }
1184
+
1185
+ return result;
1186
+ }
1187
+
1188
+ // ---------------------------------------------------------------------------
1189
+ // rename(handle, token, targetSegment)
1190
+ // ---------------------------------------------------------------------------
1191
+
1192
+ static napi_value Rename(napi_env env, napi_callback_info info) {
1193
+ size_t argc = 4;
1194
+ napi_value argv[4];
1195
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1196
+
1197
+ int dir_fd = GetDirFd(env, argv[0]);
1198
+ if (dir_fd < 0)
1199
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1200
+
1201
+ if (argc < 3)
1202
+ return ThrowCode(env, "PATH_UNSAFE", "rename requires token and target");
1203
+
1204
+ // Unwrap token with type tag verification (A3)
1205
+ TempToken* tok = GetTempToken(env, argv[1]);
1206
+ if (!tok)
1207
+ return ThrowCode(env, "PATH_UNSAFE", "rename: invalid token");
1208
+
1209
+ if (!tok->active)
1210
+ return ThrowCode(env, "PATH_UNSAFE", "rename: token already consumed");
1211
+ if (tok->committed)
1212
+ return ThrowCode(env, "PATH_UNSAFE", "rename: token already committed");
1213
+
1214
+ auto to_seg = GetSegment(env, argv, argc, 2);
1215
+ if (!to_seg.ok)
1216
+ return ThrowCode(env, "PATH_UNSAFE", "rename: unsafe target segment");
1217
+
1218
+ // 1. Verify directory identity
1219
+ if (tok->dir_fd < 0)
1220
+ return ThrowCode(env, "PATH_UNSAFE", "rename: token directory fd invalid");
1221
+
1222
+ struct stat caller_dir_st, token_dir_st;
1223
+ if (fstat(dir_fd, &caller_dir_st) < 0)
1224
+ return ThrowErrno(env, "fstat", errno, "<dir>");
1225
+ if (fstat(tok->dir_fd, &token_dir_st) < 0)
1226
+ return ThrowErrno(env, "fstat", errno, "<token-dir>");
1227
+
1228
+ if (caller_dir_st.st_dev != token_dir_st.st_dev ||
1229
+ caller_dir_st.st_ino != token_dir_st.st_ino) {
1230
+ return ThrowCode(env, "PATH_UNSAFE",
1231
+ "rename: token directory identity mismatch");
1232
+ }
1233
+
1234
+ // 2. Verify temp file identity — TWO-PHASE THREE-PARTY CHECK (RED 8):
1235
+ // a) fstat(tok->tmp_fd) — the open fd (attack-resistant)
1236
+ // b) fstatat(tok->dir_fd, tok->name) — the directory entry by name
1237
+ // c) Both must match each other AND the creation-time identity
1238
+ struct stat tmp_fd_st;
1239
+ if (fstat(tok->tmp_fd, &tmp_fd_st) < 0) {
1240
+ return ThrowErrno(env, "fstat", errno, "<tmp_fd>");
1241
+ }
1242
+ struct stat tmp_st;
1243
+ if (fstatat(tok->dir_fd, tok->name, &tmp_st, AT_SYMLINK_NOFOLLOW) < 0) {
1244
+ return ThrowErrno(env, "fstatat", errno, tok->name);
1245
+ }
1246
+ // fd and name must point to the same file
1247
+ if (tmp_fd_st.st_dev != tmp_st.st_dev ||
1248
+ tmp_fd_st.st_ino != tmp_st.st_ino) {
1249
+ return ThrowCode(env, "PATH_UNSAFE",
1250
+ "rename: temp fd/name identity mismatch");
1251
+ }
1252
+ if (!S_ISREG(tmp_fd_st.st_mode))
1253
+ return ThrowCode(env, "PATH_UNSAFE",
1254
+ "rename: temp is no longer a regular file");
1255
+ if (tmp_fd_st.st_nlink != 1)
1256
+ return ThrowCode(env, "PATH_UNSAFE",
1257
+ "rename: temp has unexpected hard link count");
1258
+ if (tmp_fd_st.st_dev != tok->tmp_dev ||
1259
+ tmp_fd_st.st_ino != tok->tmp_ino) {
1260
+ return ThrowCode(env, "PATH_UNSAFE",
1261
+ "rename: temp identity mismatch (dev/ino changed)");
1262
+ }
1263
+
1264
+ // 3. Commit. With an expected identity object, atomically exchange the
1265
+ // temp and existing target, prove that the displaced entry is the exact
1266
+ // entry read by the caller, then unlink it. Without expected identity the
1267
+ // operation remains strict no-replace creation.
1268
+ bool replacing = argc >= 4;
1269
+ int old_fd = -1;
1270
+ if (replacing) {
1271
+ ExpectedIdentity expected;
1272
+ if (!GetExpectedIdentity(env, argv[3], expected)) {
1273
+ return ThrowCode(env, "PATH_UNSAFE",
1274
+ "rename: invalid expected identity token");
1275
+ }
1276
+ old_fd = openat(dir_fd, to_seg.value.c_str(),
1277
+ O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC);
1278
+ if (old_fd < 0)
1279
+ return ThrowErrno(env, "openat", errno, to_seg.value.c_str());
1280
+
1281
+ struct stat old_fd_st, old_name_st;
1282
+ if (fstat(old_fd, &old_fd_st) < 0 ||
1283
+ fstatat(dir_fd, to_seg.value.c_str(), &old_name_st,
1284
+ AT_SYMLINK_NOFOLLOW) < 0) {
1285
+ int saved = errno;
1286
+ SafeClose(old_fd);
1287
+ return ThrowErrno(env, "fstat", saved, to_seg.value.c_str());
1288
+ }
1289
+ if (!StatMatchesExpected(old_fd_st, expected) ||
1290
+ old_fd_st.st_dev != old_name_st.st_dev ||
1291
+ old_fd_st.st_ino != old_name_st.st_ino ||
1292
+ !StatMatchesExpected(old_name_st, expected)) {
1293
+ SafeClose(old_fd);
1294
+ return ThrowCode(env, "PATH_UNSAFE",
1295
+ "rename: expected target identity changed");
1296
+ }
1297
+
1298
+ if (safe_rename_exchange(tok->dir_fd, tok->name, dir_fd,
1299
+ to_seg.value.c_str()) < 0) {
1300
+ int saved = errno;
1301
+ SafeClose(old_fd);
1302
+ if (saved == ENOSYS) {
1303
+ return ThrowCode(env, "SAFE_WRITE_UNAVAILABLE",
1304
+ "atomic exchange rename not supported on platform");
1305
+ }
1306
+ return ThrowErrno(env, "renameat_exchange", saved,
1307
+ to_seg.value.c_str());
1308
+ }
1309
+
1310
+ struct stat displaced_st, old_post_st, new_target_st;
1311
+ bool exchange_valid =
1312
+ fstatat(tok->dir_fd, tok->name, &displaced_st,
1313
+ AT_SYMLINK_NOFOLLOW) == 0 &&
1314
+ fstat(old_fd, &old_post_st) == 0 &&
1315
+ fstatat(dir_fd, to_seg.value.c_str(), &new_target_st,
1316
+ AT_SYMLINK_NOFOLLOW) == 0 &&
1317
+ displaced_st.st_dev == old_post_st.st_dev &&
1318
+ displaced_st.st_ino == old_post_st.st_ino &&
1319
+ StatMatchesExpectedAfterRename(old_post_st, expected) &&
1320
+ StatMatchesExpectedAfterRename(displaced_st, expected) &&
1321
+ new_target_st.st_dev == tok->tmp_dev &&
1322
+ new_target_st.st_ino == tok->tmp_ino &&
1323
+ S_ISREG(new_target_st.st_mode) && new_target_st.st_nlink == 1;
1324
+
1325
+ if (!exchange_valid) {
1326
+ int rollback_rc = safe_rename_exchange(tok->dir_fd, tok->name, dir_fd,
1327
+ to_seg.value.c_str());
1328
+ SafeClose(old_fd);
1329
+ if (rollback_rc < 0) {
1330
+ tok->active = false;
1331
+ tok->committed = true;
1332
+ SafeClose(tok->tmp_fd);
1333
+ tok->tmp_fd = -1;
1334
+ SafeClose(tok->dir_fd);
1335
+ tok->dir_fd = -1;
1336
+ return ThrowCode(env, "PATH_UNSAFE",
1337
+ "rename: exchange verification and rollback failed");
1338
+ }
1339
+ return ThrowCode(env, "PATH_UNSAFE",
1340
+ "rename: expected target changed during exchange");
1341
+ }
1342
+
1343
+ if (unlinkat(tok->dir_fd, tok->name, 0) < 0) {
1344
+ int saved = errno;
1345
+ SafeClose(old_fd);
1346
+ tok->active = false;
1347
+ tok->committed = true;
1348
+ SafeClose(tok->tmp_fd);
1349
+ tok->tmp_fd = -1;
1350
+ SafeClose(tok->dir_fd);
1351
+ tok->dir_fd = -1;
1352
+ return ThrowErrno(env, "unlinkat", saved, tok->name);
1353
+ }
1354
+ SafeClose(old_fd);
1355
+ } else {
1356
+ int rc = safe_rename_noreplace(tok->dir_fd, tok->name, dir_fd,
1357
+ to_seg.value.c_str());
1358
+ if (rc < 0) {
1359
+ int saved = errno;
1360
+ if (saved == EEXIST || saved == ENOTEMPTY) {
1361
+ return ThrowCode(env, "PATH_UNSAFE", "rename target already exists");
1362
+ }
1363
+ if (saved == ENOSYS) {
1364
+ return ThrowCode(env, "SAFE_WRITE_UNAVAILABLE",
1365
+ "atomic no-replace rename not supported on platform");
1366
+ }
1367
+ return ThrowErrno(env, "renameat_noreplace", saved,
1368
+ to_seg.value.c_str());
1369
+ }
1370
+ }
1371
+
1372
+ // 4. Verify target identity matches token (B3).
1373
+ // After rename, target must match the still-open token fd (attack-resistant).
1374
+ // Mark committed immediately after rename succeeds (RED 8: rename once
1375
+ // successful is immediately marked committed/consumed).
1376
+ tok->active = false;
1377
+ tok->committed = true;
1378
+
1379
+ struct stat target_st;
1380
+ if (fstatat(dir_fd, to_seg.value.c_str(), &target_st,
1381
+ AT_SYMLINK_NOFOLLOW) < 0) {
1382
+ // Rename succeeded but can't verify target by name — uncertain durability.
1383
+ // Token already marked committed; close fds.
1384
+ int saved = errno;
1385
+ SafeClose(tok->tmp_fd);
1386
+ tok->tmp_fd = -1;
1387
+ SafeClose(tok->dir_fd);
1388
+ tok->dir_fd = -1;
1389
+ return ThrowErrno(env, "fstatat", saved, to_seg.value.c_str());
1390
+ }
1391
+
1392
+ // Cross-check with the still-open token fd
1393
+ struct stat post_fd_st;
1394
+ if (fstat(tok->tmp_fd, &post_fd_st) < 0) {
1395
+ int saved = errno;
1396
+ SafeClose(tok->tmp_fd);
1397
+ tok->tmp_fd = -1;
1398
+ SafeClose(tok->dir_fd);
1399
+ tok->dir_fd = -1;
1400
+ return ThrowErrno(env, "fstat", saved, "<tmp_fd>");
1401
+ }
1402
+
1403
+ // Verify target, token fd, and creation identity all match
1404
+ if (target_st.st_dev != tok->tmp_dev ||
1405
+ target_st.st_ino != tok->tmp_ino) {
1406
+ SafeClose(tok->tmp_fd);
1407
+ tok->tmp_fd = -1;
1408
+ SafeClose(tok->dir_fd);
1409
+ tok->dir_fd = -1;
1410
+ return ThrowCode(env, "PATH_UNSAFE",
1411
+ "rename: target identity mismatch");
1412
+ }
1413
+ if (post_fd_st.st_dev != tok->tmp_dev ||
1414
+ post_fd_st.st_ino != tok->tmp_ino) {
1415
+ SafeClose(tok->tmp_fd);
1416
+ tok->tmp_fd = -1;
1417
+ SafeClose(tok->dir_fd);
1418
+ tok->dir_fd = -1;
1419
+ return ThrowCode(env, "PATH_UNSAFE",
1420
+ "rename: token fd identity changed after rename");
1421
+ }
1422
+ if (!S_ISREG(target_st.st_mode) || target_st.st_nlink != 1) {
1423
+ SafeClose(tok->tmp_fd);
1424
+ tok->tmp_fd = -1;
1425
+ SafeClose(tok->dir_fd);
1426
+ tok->dir_fd = -1;
1427
+ return ThrowCode(env, "PATH_UNSAFE",
1428
+ "rename: target is not regular or nlink!=1");
1429
+ }
1430
+
1431
+ // 5. Fsync directory
1432
+ int fsync_rc;
1433
+ do {
1434
+ fsync_rc = fsync(dir_fd);
1435
+ } while (fsync_rc < 0 && errno == EINTR);
1436
+ if (fsync_rc < 0) {
1437
+ int saved = errno;
1438
+ // Target exists and identity verified, but directory not synced.
1439
+ // Mark committed since rename is durable on most filesystems.
1440
+ tok->active = false;
1441
+ tok->committed = true;
1442
+ SafeClose(tok->tmp_fd);
1443
+ tok->tmp_fd = -1;
1444
+ SafeClose(tok->dir_fd);
1445
+ tok->dir_fd = -1;
1446
+ return ThrowErrno(env, "fsync", saved, "<dir>");
1447
+ }
1448
+
1449
+ // 6. Consume token
1450
+ tok->active = false;
1451
+ tok->committed = true;
1452
+ SafeClose(tok->tmp_fd);
1453
+ tok->tmp_fd = -1;
1454
+ SafeClose(tok->dir_fd);
1455
+ tok->dir_fd = -1;
1456
+
1457
+ napi_value undefined;
1458
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1459
+ return undefined;
1460
+ }
1461
+
1462
+ // ---------------------------------------------------------------------------
1463
+ // abortTemp(handle, token)
1464
+ // ---------------------------------------------------------------------------
1465
+
1466
+ static napi_value AbortTemp(napi_env env, napi_callback_info info) {
1467
+ size_t argc = 4;
1468
+ napi_value argv[4];
1469
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1470
+
1471
+ int dir_fd = GetDirFd(env, argv[0]);
1472
+ if (dir_fd < 0)
1473
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1474
+
1475
+ if (argc < 2)
1476
+ return ThrowCode(env, "PATH_UNSAFE", "abortTemp requires token");
1477
+
1478
+ // Unwrap token with type tag verification (A3)
1479
+ TempToken* tok = GetTempToken(env, argv[1]);
1480
+ if (!tok)
1481
+ return ThrowCode(env, "PATH_UNSAFE", "abortTemp: invalid token");
1482
+
1483
+ if (!tok->active) {
1484
+ return ThrowCode(env, "PATH_UNSAFE", "abortTemp: token already consumed");
1485
+ }
1486
+
1487
+ // Verify caller directory matches token directory (cross-directory abort)
1488
+ if (tok->dir_fd >= 0) {
1489
+ struct stat caller_dir_st, token_dir_st;
1490
+ if (fstat(dir_fd, &caller_dir_st) < 0)
1491
+ return ThrowErrno(env, "fstat", errno, "<dir>");
1492
+ if (fstat(tok->dir_fd, &token_dir_st) < 0)
1493
+ return ThrowErrno(env, "fstat", errno, "<token-dir>");
1494
+ if (caller_dir_st.st_dev != token_dir_st.st_dev ||
1495
+ caller_dir_st.st_ino != token_dir_st.st_ino) {
1496
+ return ThrowCode(env, "PATH_UNSAFE",
1497
+ "abortTemp: directory identity mismatch");
1498
+ }
1499
+ }
1500
+
1501
+ // Determine result before consuming token.
1502
+ // Returns {removed: boolean, reason: string} per frozen contract.
1503
+ bool removed = false;
1504
+ const char* reason = "unknown";
1505
+
1506
+ if (tok->committed) {
1507
+ removed = false;
1508
+ reason = "already_committed";
1509
+ } else if (tok->dir_fd < 0 || tok->name[0] == '\0') {
1510
+ removed = false;
1511
+ reason = "identity_missing";
1512
+ } else {
1513
+ struct stat st;
1514
+ if (fstatat(tok->dir_fd, tok->name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
1515
+ removed = false;
1516
+ reason = "missing";
1517
+ } else if (!S_ISREG(st.st_mode) || st.st_dev != tok->tmp_dev ||
1518
+ st.st_ino != tok->tmp_ino) {
1519
+ removed = false;
1520
+ reason = "identity_mismatch";
1521
+ } else if (st.st_nlink != 1) {
1522
+ removed = false;
1523
+ reason = "nlink_gt_1";
1524
+ } else {
1525
+ // Identity matches and nlink==1 — attempt unlink.
1526
+ // If unlink fails, this is a fatal error: the temp file exists with
1527
+ // matching identity but cannot be removed. Throw an error rather than
1528
+ // silently returning removed=false.
1529
+ bool unlink_ok = false;
1530
+ #ifdef SAFE_WRITE_TEST_SEAM
1531
+ if (TestSeamShouldFail(kSeamAbortUnlink)) {
1532
+ // Simulate EACCES from unlinkat
1533
+ errno = EACCES;
1534
+ } else if (unlinkat(tok->dir_fd, tok->name, 0) == 0) {
1535
+ unlink_ok = true;
1536
+ }
1537
+ #else
1538
+ if (unlinkat(tok->dir_fd, tok->name, 0) == 0) {
1539
+ unlink_ok = true;
1540
+ }
1541
+ #endif
1542
+ if (unlink_ok) {
1543
+ removed = true;
1544
+ reason = "removed";
1545
+ } else {
1546
+ // Do NOT consume the token — throw without closing fds or marking
1547
+ // inactive. The caller may reset the seam and retry with the same
1548
+ // token. The temp file remains on disk (identity verified).
1549
+ return ThrowErrno(env, "unlinkat", errno, tok->name);
1550
+ }
1551
+ }
1552
+ }
1553
+
1554
+ // Consume token (close fds)
1555
+ tok->active = false;
1556
+ SafeClose(tok->tmp_fd);
1557
+ tok->tmp_fd = -1;
1558
+ SafeClose(tok->dir_fd);
1559
+ tok->dir_fd = -1;
1560
+
1561
+ // Build return object: { removed, reason }
1562
+ napi_value result;
1563
+ NAPI_CHECK(napi_create_object(env, &result));
1564
+
1565
+ napi_value removed_val, reason_val;
1566
+ NAPI_CHECK(napi_get_boolean(env, removed, &removed_val));
1567
+ NAPI_CHECK(napi_create_string_utf8(env, reason, NAPI_AUTO_LENGTH, &reason_val));
1568
+ NAPI_CHECK(napi_set_named_property(env, result, "removed", removed_val));
1569
+ NAPI_CHECK(napi_set_named_property(env, result, "reason", reason_val));
1570
+ return result;
1571
+ }
1572
+
1573
+ // ---------------------------------------------------------------------------
1574
+ // mkdir(handle, name, mode) — fd-relative via mkdirat (exclusive)
1575
+ //
1576
+ // Creates a directory with the given name and mode inside the parent handle.
1577
+ // Exclusive: EEXIST if the entry already exists (no overwrite).
1578
+ // mkdirat does not follow symlinks by design (POSIX).
1579
+ // After success, fsync the parent directory for durability.
1580
+ // ---------------------------------------------------------------------------
1581
+
1582
+ static napi_value Mkdir(napi_env env, napi_callback_info info) {
1583
+ size_t argc = 4;
1584
+ napi_value argv[4];
1585
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1586
+
1587
+ int dir_fd = GetDirFd(env, argv[0]);
1588
+ if (dir_fd < 0)
1589
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1590
+
1591
+ if (argc < 3)
1592
+ return ThrowCode(env, "PATH_UNSAFE", "mkdir requires name and mode");
1593
+
1594
+ auto seg = GetSegment(env, argv, argc, 1);
1595
+ if (!seg.ok)
1596
+ return ThrowCode(env, "PATH_UNSAFE", "mkdir: unsafe segment");
1597
+
1598
+ // Validate mode type (must be int32)
1599
+ {
1600
+ napi_valuetype mt;
1601
+ NAPI_CHECK(napi_typeof(env, argv[2], &mt));
1602
+ if (mt != napi_number) {
1603
+ return ThrowCode(env, "PATH_UNSAFE",
1604
+ "mkdir: mode must be a number");
1605
+ }
1606
+ double dval = 0;
1607
+ NAPI_CHECK(napi_get_value_double(env, argv[2], &dval));
1608
+ if (dval != dval || dval == INFINITY) {
1609
+ return ThrowCode(env, "PATH_UNSAFE",
1610
+ "mkdir: mode must be an integer");
1611
+ }
1612
+ if (static_cast<double>(static_cast<int32_t>(dval)) != dval) {
1613
+ return ThrowCode(env, "PATH_UNSAFE",
1614
+ "mkdir: mode must be an integer");
1615
+ }
1616
+ }
1617
+
1618
+ int32_t mode = 0;
1619
+ if (!GetInt32(env, argv[2], mode))
1620
+ return nullptr;
1621
+ if (mode < 0 || mode > 0777) {
1622
+ return ThrowCode(env, "PATH_UNSAFE",
1623
+ "mkdir: mode must be 0..0777");
1624
+ }
1625
+
1626
+ // mkdirat: exclusive (EEXIST if entry already exists), does not follow symlinks
1627
+ if (mkdirat(dir_fd, seg.value.c_str(), static_cast<mode_t>(mode)) < 0) {
1628
+ return ThrowErrno(env, "mkdirat", errno, seg.value.c_str());
1629
+ }
1630
+
1631
+ // Fsync parent directory for durability
1632
+ int fsync_rc;
1633
+ do {
1634
+ fsync_rc = fsync(dir_fd);
1635
+ } while (fsync_rc < 0 && errno == EINTR);
1636
+ if (fsync_rc < 0) {
1637
+ int saved = errno;
1638
+ // mkdir succeeded but directory not synced — return error but do not
1639
+ // attempt rollback (rmdir) since the directory is now visible.
1640
+ return ThrowErrno(env, "fsync", saved, "<dir>");
1641
+ }
1642
+
1643
+ napi_value undefined;
1644
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1645
+ return undefined;
1646
+ }
1647
+
1648
+ // ---------------------------------------------------------------------------
1649
+ // rmdir(handle, name) — fd-relative via unlinkat AT_REMOVEDIR
1650
+ //
1651
+ // Removes an empty directory. The entry must be a directory.
1652
+ // After success, fsync the parent directory for durability.
1653
+ // ---------------------------------------------------------------------------
1654
+
1655
+ static napi_value Rmdir(napi_env env, napi_callback_info info) {
1656
+ size_t argc = 4;
1657
+ napi_value argv[4];
1658
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1659
+
1660
+ int dir_fd = GetDirFd(env, argv[0]);
1661
+ if (dir_fd < 0)
1662
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1663
+
1664
+ auto seg = GetSegment(env, argv, argc, 1);
1665
+ if (!seg.ok)
1666
+ return ThrowCode(env, "PATH_UNSAFE", "rmdir: unsafe segment");
1667
+
1668
+ if (unlinkat(dir_fd, seg.value.c_str(), AT_REMOVEDIR) < 0) {
1669
+ return ThrowErrno(env, "unlinkat(AT_REMOVEDIR)", errno, seg.value.c_str());
1670
+ }
1671
+
1672
+ // Fsync parent directory for durability
1673
+ int fsync_rc;
1674
+ do {
1675
+ fsync_rc = fsync(dir_fd);
1676
+ } while (fsync_rc < 0 && errno == EINTR);
1677
+ if (fsync_rc < 0) {
1678
+ int saved = errno;
1679
+ return ThrowErrno(env, "fsync", saved, "<dir>");
1680
+ }
1681
+
1682
+ napi_value undefined;
1683
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1684
+ return undefined;
1685
+ }
1686
+
1687
+ // ---------------------------------------------------------------------------
1688
+ // unlink(handle, name)
1689
+ // ---------------------------------------------------------------------------
1690
+
1691
+ static napi_value Unlink(napi_env env, napi_callback_info info) {
1692
+ size_t argc = 4;
1693
+ napi_value argv[4];
1694
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1695
+
1696
+ int dir_fd = GetDirFd(env, argv[0]);
1697
+ if (dir_fd < 0)
1698
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1699
+
1700
+ auto seg = GetSegment(env, argv, argc, 1);
1701
+ if (!seg.ok)
1702
+ return ThrowCode(env, "PATH_UNSAFE", "unlink: unsafe segment");
1703
+
1704
+ if (unlinkat(dir_fd, seg.value.c_str(), 0) < 0) {
1705
+ return ThrowErrno(env, "unlinkat", errno, seg.value.c_str());
1706
+ }
1707
+
1708
+ napi_value undefined;
1709
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1710
+ return undefined;
1711
+ }
1712
+
1713
+ // ---------------------------------------------------------------------------
1714
+ // chmod(handle, name, mode) — fd-based via openat + fchmod (B5)
1715
+ // ---------------------------------------------------------------------------
1716
+
1717
+ static napi_value Chmod(napi_env env, napi_callback_info info) {
1718
+ size_t argc = 4;
1719
+ napi_value argv[4];
1720
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1721
+
1722
+ int dir_fd = GetDirFd(env, argv[0]);
1723
+ if (dir_fd < 0)
1724
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1725
+
1726
+ if (argc < 3)
1727
+ return ThrowCode(env, "PATH_UNSAFE", "chmod requires name and mode");
1728
+
1729
+ auto seg = GetSegment(env, argv, argc, 1);
1730
+ if (!seg.ok)
1731
+ return ThrowCode(env, "PATH_UNSAFE", "chmod: unsafe segment");
1732
+
1733
+ // Validate mode type (must be int32)
1734
+ {
1735
+ napi_valuetype mt;
1736
+ NAPI_CHECK(napi_typeof(env, argv[2], &mt));
1737
+ if (mt != napi_number) {
1738
+ return ThrowCode(env, "PATH_UNSAFE", "chmod: mode must be a number");
1739
+ }
1740
+ // Reject NaN and non-integer doubles
1741
+ double dval = 0;
1742
+ NAPI_CHECK(napi_get_value_double(env, argv[2], &dval));
1743
+ if (dval != dval || dval == INFINITY) {
1744
+ return ThrowCode(env, "PATH_UNSAFE", "chmod: mode must be an integer");
1745
+ }
1746
+ if (static_cast<double>(static_cast<int32_t>(dval)) != dval) {
1747
+ return ThrowCode(env, "PATH_UNSAFE", "chmod: mode must be an integer");
1748
+ }
1749
+ }
1750
+
1751
+ int32_t mode = 0;
1752
+ if (!GetInt32(env, argv[2], mode))
1753
+ return nullptr;
1754
+
1755
+ // Validate mode range 0..0777 (same as createTemp)
1756
+ if (mode < 0 || mode > 0777) {
1757
+ return ThrowCode(env, "PATH_UNSAFE",
1758
+ "chmod: mode must be 0..0777");
1759
+ }
1760
+
1761
+ // Open file to get an fd — fd-based fchmod prevents symlink races (B5)
1762
+ int file_fd = openat(dir_fd, seg.value.c_str(),
1763
+ O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
1764
+ if (file_fd < 0)
1765
+ return ThrowErrno(env, "openat", errno, seg.value.c_str());
1766
+
1767
+ // Verify it's a regular file
1768
+ struct stat st;
1769
+ if (fstat(file_fd, &st) < 0) {
1770
+ int saved = errno;
1771
+ SafeClose(file_fd);
1772
+ return ThrowErrno(env, "fstat", saved, seg.value.c_str());
1773
+ }
1774
+ if (!S_ISREG(st.st_mode) || st.st_nlink != 1) {
1775
+ SafeClose(file_fd);
1776
+ return ThrowCode(env, "PATH_UNSAFE",
1777
+ "chmod: not a regular file or nlink!=1");
1778
+ }
1779
+
1780
+ // Use fd-based fchmod
1781
+ if (fchmod(file_fd, mode) < 0) {
1782
+ int saved = errno;
1783
+ SafeClose(file_fd);
1784
+ return ThrowErrno(env, "fchmod", saved, seg.value.c_str());
1785
+ }
1786
+
1787
+ SafeClose(file_fd);
1788
+
1789
+ napi_value undefined;
1790
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1791
+ return undefined;
1792
+ }
1793
+
1794
+ // ---------------------------------------------------------------------------
1795
+ // fsync(handle)
1796
+ // ---------------------------------------------------------------------------
1797
+
1798
+ static napi_value Fsync(napi_env env, napi_callback_info info) {
1799
+ size_t argc = 1;
1800
+ napi_value argv[1];
1801
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1802
+
1803
+ int dir_fd = GetDirFd(env, argv[0]);
1804
+ if (dir_fd < 0)
1805
+ return ThrowCode(env, "PATH_UNSAFE", "handle is closed");
1806
+
1807
+ int rc;
1808
+ do {
1809
+ rc = fsync(dir_fd);
1810
+ } while (rc < 0 && errno == EINTR);
1811
+ if (rc < 0) {
1812
+ return ThrowErrno(env, "fsync", errno, "<dir>");
1813
+ }
1814
+
1815
+ napi_value undefined;
1816
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1817
+ return undefined;
1818
+ }
1819
+
1820
+ // ---------------------------------------------------------------------------
1821
+ // close(handle) — idempotent, no EINTR retry
1822
+ // ---------------------------------------------------------------------------
1823
+
1824
+ static napi_value Close(napi_env env, napi_callback_info info) {
1825
+ size_t argc = 1;
1826
+ napi_value argv[1];
1827
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1828
+
1829
+ int dir_fd = GetDirFd(env, argv[0]);
1830
+ if (dir_fd >= 0) {
1831
+ napi_value ext;
1832
+ NAPI_CHECK(napi_get_named_property(env, argv[0], "_handle", &ext));
1833
+ void* data = nullptr;
1834
+ NAPI_CHECK(napi_get_value_external(env, ext, &data));
1835
+ if (data) {
1836
+ int* fd_ptr = static_cast<int*>(data);
1837
+ SafeClose(*fd_ptr);
1838
+ *fd_ptr = -1;
1839
+ }
1840
+ }
1841
+
1842
+ napi_value undefined;
1843
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1844
+ return undefined;
1845
+ }
1846
+
1847
+ // ---------------------------------------------------------------------------
1848
+ // Module initialization
1849
+ // ---------------------------------------------------------------------------
1850
+
1851
+ #define DEF_FN(name, fn) \
1852
+ do { \
1853
+ napi_value _fn; \
1854
+ NAPI_CHECK(napi_create_function(env, name, NAPI_AUTO_LENGTH, fn, nullptr, \
1855
+ &_fn)); \
1856
+ NAPI_CHECK(napi_set_named_property(env, exports, name, _fn)); \
1857
+ } while (0)
1858
+
1859
+ // ---------------------------------------------------------------------------
1860
+ // Test seam JavaScript interface
1861
+ // Only available in test builds (SAFE_WRITE_TEST_SEAM=1).
1862
+ // ---------------------------------------------------------------------------
1863
+
1864
+ #ifdef SAFE_WRITE_TEST_SEAM
1865
+
1866
+ // testSeamReset() — reset all test seam state.
1867
+ static napi_value TestSeamResetJs(napi_env env, napi_callback_info /*info*/) {
1868
+ TestSeamReset();
1869
+ napi_value undefined;
1870
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1871
+ return undefined;
1872
+ }
1873
+
1874
+ // testSeamConfigure(point, count) — configure a failure point.
1875
+ // point: string name of the failure point.
1876
+ // count: -1 = fail every time, 0 = don't fail, N = fail N times.
1877
+ static napi_value TestSeamConfigureJs(napi_env env, napi_callback_info info) {
1878
+ size_t argc = 2;
1879
+ napi_value argv[2];
1880
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1881
+
1882
+ if (argc < 2) {
1883
+ return ThrowCode(env, "PATH_UNSAFE",
1884
+ "testSeamConfigure requires point and count");
1885
+ }
1886
+
1887
+ // Get point string
1888
+ napi_valuetype t;
1889
+ NAPI_CHECK(napi_typeof(env, argv[0], &t));
1890
+ if (t != napi_string) {
1891
+ return ThrowCode(env, "PATH_UNSAFE",
1892
+ "testSeamConfigure: point must be a string");
1893
+ }
1894
+ char point[256];
1895
+ size_t point_len = 0;
1896
+ NAPI_CHECK(napi_get_value_string_utf8(env, argv[0], point, sizeof(point),
1897
+ &point_len));
1898
+
1899
+ // Get count
1900
+ NAPI_CHECK(napi_typeof(env, argv[1], &t));
1901
+ if (t != napi_number) {
1902
+ return ThrowCode(env, "PATH_UNSAFE",
1903
+ "testSeamConfigure: count must be a number");
1904
+ }
1905
+ int32_t count = 0;
1906
+ NAPI_CHECK(napi_get_value_int32(env, argv[1], &count));
1907
+
1908
+ TestSeamConfigure(point, count);
1909
+
1910
+ napi_value undefined;
1911
+ NAPI_CHECK(napi_get_undefined(env, &undefined));
1912
+ return undefined;
1913
+ }
1914
+
1915
+ // testSeamGetFailedCount(point) — get the number of times a failure point was triggered.
1916
+ static napi_value TestSeamGetFailedCountJs(napi_env env,
1917
+ napi_callback_info info) {
1918
+ size_t argc = 1;
1919
+ napi_value argv[1];
1920
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1921
+
1922
+ if (argc < 1) {
1923
+ return ThrowCode(env, "PATH_UNSAFE",
1924
+ "testSeamGetFailedCount requires point");
1925
+ }
1926
+
1927
+ // Get point string
1928
+ napi_valuetype t;
1929
+ NAPI_CHECK(napi_typeof(env, argv[0], &t));
1930
+ if (t != napi_string) {
1931
+ return ThrowCode(env, "PATH_UNSAFE",
1932
+ "testSeamGetFailedCount: point must be a string");
1933
+ }
1934
+ char point[256];
1935
+ size_t point_len = 0;
1936
+ NAPI_CHECK(napi_get_value_string_utf8(env, argv[0], point, sizeof(point),
1937
+ &point_len));
1938
+
1939
+ int count = TestSeamGetFailedCount(point);
1940
+
1941
+ napi_value result;
1942
+ NAPI_CHECK(napi_create_int32(env, count, &result));
1943
+ return result;
1944
+ }
1945
+
1946
+ // testSeamGetCallCount(point) — get the number of times a failure point was called.
1947
+ static napi_value TestSeamGetCallCountJs(napi_env env,
1948
+ napi_callback_info info) {
1949
+ size_t argc = 1;
1950
+ napi_value argv[1];
1951
+ NAPI_CHECK(napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));
1952
+
1953
+ if (argc < 1) {
1954
+ return ThrowCode(env, "PATH_UNSAFE",
1955
+ "testSeamGetCallCount requires point");
1956
+ }
1957
+
1958
+ // Get point string
1959
+ napi_valuetype t;
1960
+ NAPI_CHECK(napi_typeof(env, argv[0], &t));
1961
+ if (t != napi_string) {
1962
+ return ThrowCode(env, "PATH_UNSAFE",
1963
+ "testSeamGetCallCount: point must be a string");
1964
+ }
1965
+ char point[256];
1966
+ size_t point_len = 0;
1967
+ NAPI_CHECK(napi_get_value_string_utf8(env, argv[0], point, sizeof(point),
1968
+ &point_len));
1969
+
1970
+ int count = TestSeamGetCallCount(point);
1971
+
1972
+ napi_value result;
1973
+ NAPI_CHECK(napi_create_int32(env, count, &result));
1974
+ return result;
1975
+ }
1976
+
1977
+ // testSeamGetPoints() — get all available failure point names.
1978
+ static napi_value TestSeamGetPointsJs(napi_env env,
1979
+ napi_callback_info /*info*/) {
1980
+ // List of all failure point names.
1981
+ static const char* points[] = {
1982
+ kSeamCreateObject, kSeamCreateString,
1983
+ kSeamCreateExternal, kSeamSetProperty,
1984
+ kSeamTypeTag, kSeamOpenRootCreateObject,
1985
+ kSeamOpenDirCreateObject, kSeamReadFileCreateObject,
1986
+ kSeamAbortTempCreateObject, kSeamAbortUnlink,
1987
+ };
1988
+ static const size_t num_points = sizeof(points) / sizeof(points[0]);
1989
+
1990
+ napi_value result;
1991
+ NAPI_CHECK(napi_create_array_with_length(env, num_points, &result));
1992
+
1993
+ for (size_t i = 0; i < num_points; i++) {
1994
+ napi_value str;
1995
+ NAPI_CHECK(
1996
+ napi_create_string_utf8(env, points[i], NAPI_AUTO_LENGTH, &str));
1997
+ NAPI_CHECK(napi_set_element(env, result, i, str));
1998
+ }
1999
+
2000
+ return result;
2001
+ }
2002
+
2003
+ #endif // SAFE_WRITE_TEST_SEAM
2004
+
2005
+ static napi_value Init(napi_env env, napi_value exports) {
2006
+ DEF_FN("openRoot", OpenRoot);
2007
+ DEF_FN("openDir", OpenDir);
2008
+ DEF_FN("readEntry", ReadEntry);
2009
+ DEF_FN("readFile", ReadFile);
2010
+ DEF_FN("createTemp", CreateTemp);
2011
+ DEF_FN("rename", Rename);
2012
+ DEF_FN("abortTemp", AbortTemp);
2013
+ DEF_FN("mkdir", Mkdir);
2014
+ DEF_FN("rmdir", Rmdir);
2015
+ DEF_FN("unlink", Unlink);
2016
+ DEF_FN("chmod", Chmod);
2017
+ DEF_FN("fsync", Fsync);
2018
+ DEF_FN("close", Close);
2019
+
2020
+ #ifdef SAFE_WRITE_TEST_SEAM
2021
+ // Test seam functions — only available in test builds.
2022
+ DEF_FN("testSeamReset", TestSeamResetJs);
2023
+ DEF_FN("testSeamConfigure", TestSeamConfigureJs);
2024
+ DEF_FN("testSeamGetFailedCount", TestSeamGetFailedCountJs);
2025
+ DEF_FN("testSeamGetCallCount", TestSeamGetCallCountJs);
2026
+ DEF_FN("testSeamGetPoints", TestSeamGetPointsJs);
2027
+ #endif
2028
+
2029
+ return exports;
2030
+ }
2031
+
2032
+ NAPI_MODULE(safe_write, Init)