nx_fsops_write.nx source
↩ module page · 233 lines · 13890 B
1// nx_fsops_write.nx -- WRITE-CLASS CLI of the consolidated fs tool (tools-api name: nx_fs_write).
2// 2026-07-30: `write` is now COMPARE-AND-SWAP gated (seq1379) -- see the block above fsw_usage.
3// Deliberately a SEPARATE binary from the read-only `nx_fs`: the exposure policy (knowledge/mcp/
4// exposure_policy.txt) grades reads broad and writes cap-gated, so the two live under different
5// capability grants. One shared library (nx_fsops_lib) -- no logic duplicated.
6// write <path> <content> -> ATOMIC full-file write (tmp+fsync+rename)
7// edit <path> <old> <new> [all] -> exact-string replace, UNIQUENESS contract (Claude-Edit semantic)
8// exit: 0 ok | 2 usage | 3 absent | 4 io | 5 DENIED | 6 NOMATCH | 7 AMBIGUOUS (surfaced in MCP _meta)
9// DENY BY CONSTRUCTION (rule 26 + 12): secret material, OS device/firmware namespace (nx_os_fs seam),
10// the tool allowlist, + fs_write_deny.conf extras. license_tier: ORIGINAL
11import "nx_fsops_lib.nx"
12
13const FSW_USAGE_RC: i64 = 2
14const FSW_ARG_VERB: i64 = 1 // argv slot of the verb
15const FSW_ARG_PATH: i64 = 2 // argv slot of the path
16const FSW_ARG_A: i64 = 3 // write: content | edit: old-string
17const FSW_ARG_B: i64 = 4 // edit: new-string
18const FSW_ARG_ALL: i64 = 5 // edit: optional literal "all"
19const FSW_ARGC_WRITE: i64 = 4 // write path content
20const FSW_ARGC_EDIT: i64 = 5 // edit path old new
21const FSW_ARGC_EDITALL: i64 = 6 // edit path old new all
22
23// seq1379 -- THE WORK-DESTROYER FIX. A whole-file `write` over an EXISTING file could silently BACKDATE it,
24// destroying another session's landed work with no error anywhere: on 2026-07-30 three canonical daemon
25// sources were reverted this way and FIVE eaten debts across THREE lanes re-opened. The `edit` verb next
26// door was never able to do that, because its anchor makes a losing race LOUD -- this brings `write` up to
27// the same standard instead of leaving the sharp edge on the more destructive verb.
28// CREATION IS ALWAYS FREE (absent file -> write). Overwriting an EXISTING file now demands explicit intent:
29// expect=<size> the byte size the caller believes is there -> refuse if reality differs (compare-and-swap)
30// expect=any "I intend to replace whatever is there" -> the deliberate escape hatch
31// Omitted on an existing file -> REFUSE and print the real size, so the caller can retry correctly. A
32// refusal costs one round trip; a silent clobber costs somebody their whole session.
33const FSW_RC_CLOBBER: i64 = 8 // distinct from ABSENT/DENIED/IO/NOMATCH/AMBIG so callers can branch on it
34const FSW_ARG_EXPECT: i64 = 4 // write: optional expect token
35const FSW_ARGC_WRITE_EXPECT: i64 = 5
36
37// Value part of a `key=value` token, or the whole token when there is no '='.
38//
39// ★seq1422 -- THE GUARD COULD NOT BE SATISFIED. Both checks ran on the ENTIRE
40// token: fsx_seq("expect=any","any") is false, and fsw_atoi("expect=5986")
41// finds no leading digit and yields 0, so a CORRECT expectation was refused --
42// its own error message printed the two identical numbers back to the caller.
43// Every overwrite through this verb was therefore impossible.
44//
45// ★★THE REAL COST, and the reason this is a sev-8 not a typo: **a guard that
46// cannot be satisfied does not produce safety, it produces a BYPASS.** With
47// the sanctioned path refusing correct calls, sessions fell back to raw
48// scp/ssh -- which is exactly the unguarded third write path that silently
49// backdated source and destroyed shipped work (seq1439/seq1392). Parse the
50// VALUE, not the token.
51func fsw_argval(s: *u8) -> *u8 {
52 var i: i64 = 0
53 while s[i] != (0 as u8) {
54 if s[i] == (61 as u8) { return ((s as i64) + i + 1) as *u8 }
55 i = i + 1
56 }
57 return s
58}
59
60// current size of <path>, or -1 when absent/unreadable (absent = free to create)
61func fsw_cur_size(path: *u8) -> i64 {
62 let fd: i64 = sys_openat_rd(path)
63 if fd < 0 { return 0 - 1 }
64 let sz: i64 = sys_lseek(fd, 0, 2)
65 sys_close(fd)
66 return sz
67}
68
69// ---- seq1477: expect=<size> IS NOT A CONTENT HASH -------------------------------------------------------
70// The size compare catches the LOUD races (append, delete, whole-file backdate -- what seq1379 was about) and
71// misses the QUIET ones, which in this tree are the COMMON ones: flipping a constant, swapping an equal-length
72// identifier, toggling a flag, substituting one same-length path. Those all preserve byte count and sail
73// through a size guard -- and they are exactly the edits most likely to be raced, because they are the small
74// ones siblings make constantly. A guard that passes the common case is not a guard.
75// FNV-1a/64 over the file's CONTENT. Not crypto and not claimed to be: this defends against CONCURRENT EDITS,
76// not an adversary, so a 64-bit non-cryptographic hash is the right cost -- and it needs no new import, which
77// matters because this organ sits under `runtime/` and cannot reach `_hdl_build/`.
78const FSW_FNV_OFF: i64 = 0xcbf29ce484222325
79const FSW_FNV_PRIME: i64 = 0x100000001b3
80func fsw_content_hash(path: *u8) -> i64 {
81 let szp: *i64 = sys_mmap(16) as *i64
82 let buf: *u8 = sys_read_file(path, szp)
83 if (buf as i64) == 0 { return 0 }
84 let n: i64 = szp[0]
85 var h: i64 = FSW_FNV_OFF
86 var i: i64 = 0
87 while i < n { h = (h ^ ((buf[i] as i64) & 0xff)) * FSW_FNV_PRIME; i = i + 1 }
88 return h
89}
90func fsw_hex_of(v: i64, out: *u8) -> i64 {
91 let dig: *u8 = "0123456789abcdef" as *u8
92 var i: i64 = 0
93 while i < 16 { out[i] = dig[(v >> ((15 - i) * 4)) & 15]; i = i + 1 }
94 out[16] = 0 as u8
95 return 16
96}
97func fsw_hex_val(s: *u8) -> i64 {
98 var v: i64 = 0
99 var i: i64 = 0
100 while s[i] != (0 as u8) {
101 let c: i64 = s[i] as i64
102 var d: i64 = 0 - 1
103 if c >= 48 { if c <= 57 { d = c - 48 } }
104 if c >= 97 { if c <= 102 { d = c - 87 } }
105 if c >= 65 { if c <= 70 { d = c - 55 } }
106 if d < 0 { return 0 }
107 v = (v << 4) | d
108 i = i + 1
109 }
110 return v
111}
112// Returns something the caller can compare against `cur`, so the existing `want == cur` test is unchanged:
113// on a CONTENT-HASH match it returns cur; on mismatch -1. A plain numeric expect keeps its old meaning.
114// Print the file's CURRENT content-hash. A helper (not an inline let) so both refusal sites can share one
115// identical line -- a caller that cannot LEARN the current hash can never retry with expect=h<hash>, which
116// would make the whole content-CAS unusable. The remedy has to travel with the refusal.
117func fsw_print_hash(path: *u8) -> i64 {
118 let hb: *u8 = sys_mmap(32)
119 fsw_hex_of(fsw_content_hash(path), hb)
120 fsx_puts(hb)
121 return 0
122}
123func fsw_expect_val(ex: *u8, path: *u8, cur: i64) -> i64 {
124 if ex[0] == (104 as u8) {
125 if fsw_hex_val(((ex as i64) + 1) as *u8) == fsw_content_hash(path) { return cur }
126 return 0 - 1
127 }
128 return fsw_atoi(ex)
129}
130func fsw_atoi(s: *u8) -> i64 {
131 var v: i64 = 0
132 var i: i64 = 0
133 while s[i] != (0 as u8) { let c: i64 = s[i] as i64; if c < 48 { return 0 - 1 } if c > 57 { return 0 - 1 } v = v * 10 + (c - 48); i = i + 1 }
134 if i == 0 { return 0 - 1 }
135 return v
136}
137
138func fsw_usage() -> i64 {
139 fsx_puts("usage: nx_fs_write write <path> <content> [expect=<size>|any] | edit <path> <old> <new> [all] [expect=<size>|any]\n write REFUSES to overwrite an existing file without expect (seq1379: blind writes backdate other sessions' work); edit is anchored, and takes the same expect token for compare-and-swap (seq1456)\n TO REMOVE A FILE: use nx_retire_path retire <path> -- it RENAMES into knowledge/retired/ (never deletes, reversible, refuses traversal/shallow/dangerous names). There is NO delete verb here ON PURPOSE. Do NOT blank a file or comment it out to retire it: that leaves a landmine that still parses, still greps and can still shadow the real organ in a glob-driven build (measured 2026-07-31, ws=office).\n" as *u8)
140 return FSW_USAGE_RC
141}
142// map a lib result (negative sentinel) to the CLI exit code
143func fsw_rc(r: i64) -> i64 {
144 if r >= 0 { return 0 }
145 if r == 0 - 1 { return FSX_RC_ABSENT }
146 if r == 0 - (2 as i64) { return FSX_RC_DENIED }
147 if r == 0 - (3 as i64) { return FSX_RC_IO }
148 if r == 0 - FSX_RC_NOMATCH { return FSX_RC_NOMATCH }
149 if r == 0 - FSX_RC_AMBIG { return FSX_RC_AMBIG }
150 return FSX_RC_IO
151}
152
153func main(argc: i64, argv: *i64) -> i64 {
154 if argc < FSW_ARGC_WRITE { return fsw_usage() }
155 let verb: *u8 = argv[FSW_ARG_VERB] as *u8
156 if fsx_seq(verb, "write" as *u8) == 1 {
157 let body: *u8 = argv[FSW_ARG_A] as *u8
158 // seq1379 compare-and-swap gate: creation free, overwrite must be intended
159 let cur: i64 = fsw_cur_size(argv[FSW_ARG_PATH] as *u8)
160 if cur >= 0 {
161 var okw: i64 = 0
162 if argc >= FSW_ARGC_WRITE_EXPECT {
163 let ex: *u8 = fsw_argval(argv[FSW_ARG_EXPECT] as *u8)
164 if fsx_seq(ex, "any" as *u8) == 1 { okw = 1 } else {
165 let want: i64 = fsw_expect_val(ex, argv[FSW_ARG_PATH] as *u8, cur)
166 if want == cur { okw = 1 } else {
167 fsx_puts("NX-FS-WRITE REFUSED stale-expect: file is " as *u8); fsx_putn(cur)
168 fsx_puts(" bytes, you expected " as *u8); fsx_puts(ex)
169 fsx_puts(" -- it changed under you. CURRENT content-hash=h" as *u8); fsw_print_hash(argv[FSW_ARG_PATH] as *u8); fsx_puts(" (seq1477: pass expect=h<hash> for a CONTENT compare-and-swap; expect=<size> only catches SIZE changes, so a same-size rewrite sails through). RE-READ then retry (file unchanged)\n" as *u8)
170 return FSW_RC_CLOBBER
171 }
172 }
173 }
174 if okw == 0 {
175 fsx_puts("NX-FS-WRITE REFUSED would-clobber: " as *u8); fsx_puts(argv[FSW_ARG_PATH] as *u8)
176 fsx_puts(" already exists (" as *u8); fsx_putn(cur)
177 fsx_puts(" bytes) and no expect was given -- a blind whole-file write silently BACKDATES another session's work (seq1379). FIX: prefer `edit` (anchored, fails loud), or pass expect=" as *u8)
178 fsx_putn(cur)
179 fsx_puts(" for compare-and-swap, or expect=any to replace deliberately. If you are trying to REMOVE this file, do NOT blank it and do NOT comment it out -- use nx_retire_path retire <path> (renames into knowledge/retired/, never deletes, reversible). A blanked or commented-out file is a LANDMINE: it still parses, still greps, and can still shadow the real organ in a glob-driven build. (file unchanged)\n" as *u8)
180 return FSW_RC_CLOBBER
181 }
182 }
183 let r: i64 = fsx_write(argv[FSW_ARG_PATH] as *u8, body, vw_slen(body))
184 if r >= 0 {
185 fsx_puts("NX-FS-WRITE OK bytes=" as *u8); fsx_putn(r)
186 fsx_puts(" path=" as *u8); fsx_puts(argv[FSW_ARG_PATH] as *u8); fsx_puts("\n" as *u8)
187 }
188 return fsw_rc(r)
189 }
190 if fsx_seq(verb, "edit" as *u8) == 1 {
191 if argc < FSW_ARGC_EDIT { return fsw_usage() }
192 // seq1456: `edit` accepts the SAME compare-and-swap token as `write`.
193 // Anchored editing already fails loud when its anchor is gone, but that
194 // does not cover an anchor that still matches in a file a sibling has
195 // rewritten around you. Trailing args are order-free: `all` and
196 // `expect=<size>|any` may appear in either slot, so no existing call
197 // shape changes. Absent expect keeps today's behaviour exactly.
198 var allf: i64 = 0
199 var exs: *u8 = 0 as *u8
200 var ai: i64 = FSW_ARG_ALL
201 while ai < argc {
202 let tok: *u8 = argv[ai] as *u8
203 if fsx_seq(tok, "all" as *u8) == 1 { allf = 1 } else {
204 let tv: *u8 = fsw_argval(tok)
205 if (tv as i64) != (tok as i64) { exs = tv }
206 }
207 ai = ai + 1
208 }
209 if (exs as i64) != 0 {
210 let ecur: i64 = fsw_cur_size(argv[FSW_ARG_PATH] as *u8)
211 if ecur >= 0 { if fsx_seq(exs, "any" as *u8) == 0 {
212 let ewant: i64 = fsw_expect_val(exs, argv[FSW_ARG_PATH] as *u8, ecur)
213 if ewant != ecur {
214 fsx_puts("NX-FS-EDIT REFUSED stale-expect: file is " as *u8); fsx_putn(ecur)
215 fsx_puts(" bytes, you expected " as *u8); fsx_puts(exs)
216 fsx_puts(" -- it changed under you. CURRENT content-hash=h" as *u8); fsw_print_hash(argv[FSW_ARG_PATH] as *u8); fsx_puts(" (seq1477: pass expect=h<hash> for a CONTENT compare-and-swap; expect=<size> only catches SIZE changes, so a same-size rewrite sails through). RE-READ then retry (file unchanged)\n" as *u8)
217 return FSW_RC_CLOBBER
218 }
219 } }
220 }
221 let r2: i64 = fsx_edit(argv[FSW_ARG_PATH] as *u8, argv[FSW_ARG_A] as *u8, argv[FSW_ARG_B] as *u8, allf)
222 if r2 >= 0 {
223 fsx_puts("NX-FS-EDIT OK bytes=" as *u8); fsx_putn(r2)
224 fsx_puts(" path=" as *u8); fsx_puts(argv[FSW_ARG_PATH] as *u8); fsx_puts("\n" as *u8)
225 }
226 if r2 == 0 - FSX_RC_NOMATCH { fsx_puts("NX-FS-EDIT NOMATCH: old-string not found (file unchanged)\n" as *u8) }
227 if r2 == 0 - FSX_RC_AMBIG { fsx_puts("NX-FS-EDIT AMBIGUOUS: old-string occurs more than once; pass `all` or a longer unique context (file unchanged)\n" as *u8) }
228 if r2 == 0 - 1 { fsx_puts("NX-FS-EDIT ABSENT: cannot edit " as *u8); fsx_puts(argv[FSW_ARG_PATH] as *u8); fsx_puts(" -- file absent/unreadable (unchanged). FIX: confirm with `nx_fs ls <dir>`; create it via `nx_fs_write write <path> <content>`.\n" as *u8) }
229 if r2 == 0 - (3 as i64) { fsx_puts("NX-FS-EDIT IO: file at/over the edit size cap or a write error (unchanged). FIX: edit a bounded region of a very large file, or check disk/permissions.\n" as *u8) }
230 return fsw_rc(r2)
231 }
232 return fsw_usage()
233}