code wiki / (root) / nx_fsops_write.nx

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}