nx_jrnl_archive_lib.nx source
↩ module page · 175 lines · 8796 B
1// nx_jrnl_archive_lib.nx -- THE SANCTIONED "ARCHIVE IT DELIBERATELY" VERB for an append-only
2// status journal that has reached its budget (2026-08-23, lane L).
3//
4// WHY THIS EXISTS. nx_compare_regen refuses receipts once knowledge/status/comparepub.jrnl passes its
5// budget and tells the reader to "archive it deliberately" -- and no organ in the estate could do that
6// (nx_spendgate: name free, no incumbent). Rotation was rejected by design: a silent rotator is a
7// duplicate ruler beside nx_sizeguard and a SHRINKING journal is exactly what nx_jrnlguard calls a
8// clobber. So the archive is a VERB a seat runs on purpose, and it is preserve-before-remove BY
9// CONSTRUCTION: the live journal is reset ONLY after the archived copy has been re-read from disk and
10// hashed equal to the original. A write that fails, a verify that mismatches, or a journal that
11// cannot be read leaves the live file byte-for-byte untouched.
12//
13// Contract (ja_archive): 0 OK archived+reset | 1 OK nothing-to-archive (empty journal, no file made)
14// | 3 cannot read journal | 4 cannot create archive dir / write archive | 5 VERIFY MISMATCH (archive
15// kept, live NOT reset) | 6 reset failed (archive verified, live unchanged).
16// Every outcome prints one NX-JRNL-ARCHIVE line naming src, bytes, sha256, the archive path, and
17// verified=/reset= so the announcement IS the audit row. The archive name is <dir>/<base>.<epoch>.
18// jrnlguard: the estate's twelve jg_*.base baselines do not cover comparepub.jrnl (measured
19// 2026-08-23); a journal that IS baselined must be re-snapshotted after an archive (jg's own rule).
20import "nx_syscalls.nx"
21import "nx_sha256.nx"
22
23const JA_DIGEST_BYTES: i64 = 32
24const JA_ARCH_MODE: i64 = 0x1a4 // rw-r--r-- for the archived copy
25const JA_DIR_MODE: i64 = 0x1ed // rwxr-xr-x for the archive dir
26const JA_PATH_MAX: i64 = 4096 // PATH_MAX: the kernel bound on a path, the only constant a path needs
27
28func ja_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
29func ja_puts(s: *u8) -> i64 { sys_write(1, s, ja_slen(s)); return 0 }
30func ja_pn(v: i64) -> i64 {
31 let t: *u8 = sys_mmap(24)
32 var x: i64 = v
33 var k: i64 = 0
34 if x < 0 { t[0] = 45 as u8; sys_write(1, t, 1); x = 0 - x }
35 if x == 0 { t[0] = 48 as u8; k = 1 }
36 while x > 0 { t[k] = (48 + (x % 10)) as u8; x = x / 10; k = k + 1 }
37 let o: *u8 = sys_mmap(24)
38 var i: i64 = 0
39 while i < k { o[i] = t[k - 1 - i]; i = i + 1 }
40 sys_write(1, o, k)
41 return 0
42}
43func ja_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; var p: i64 = o; while s[i] != (0 as u8) { d[p] = s[i]; p = p + 1; i = i + 1 } d[p] = 0 as u8; return p }
44func ja_catn(d: *u8, o: i64, v: i64) -> i64 {
45 let t: *u8 = sys_mmap(24)
46 var x: i64 = v
47 var k: i64 = 0
48 if x == 0 { t[0] = 48 as u8; k = 1 }
49 while x > 0 { t[k] = (48 + (x % 10)) as u8; x = x / 10; k = k + 1 }
50 var p: i64 = o
51 var i: i64 = 0
52 while i < k { d[p] = t[k - 1 - i]; p = p + 1; i = i + 1 }
53 d[p] = 0 as u8
54 return p
55}
56func ja_hex(d: *u8, out: *u8) -> i64 {
57 var i: i64 = 0
58 while i < JA_DIGEST_BYTES {
59 let v: i64 = d[i] as i64
60 let hi: i64 = (v >> 4) & 15
61 let lo: i64 = v & 15
62 if hi < 10 { out[i*2] = (48 + hi) as u8 } else { out[i*2] = (87 + hi) as u8 }
63 if lo < 10 { out[i*2+1] = (48 + lo) as u8 } else { out[i*2+1] = (87 + lo) as u8 }
64 i = i + 1
65 }
66 out[JA_DIGEST_BYTES*2] = 0 as u8
67 return 0
68}
69// basename of a path (the text after the last '/'), and its directory (everything before it, or ".")
70func ja_basename(p: *u8) -> *u8 {
71 var i: i64 = 0
72 var last: i64 = 0 - 1
73 while p[i] != (0 as u8) { if p[i] == (47 as u8) { last = i } i = i + 1 }
74 return (p as i64 + last + 1) as *u8
75}
76func ja_dirname(p: *u8, out: *u8) -> i64 {
77 var i: i64 = 0
78 var last: i64 = 0 - 1
79 while p[i] != (0 as u8) { if p[i] == (47 as u8) { last = i } i = i + 1 }
80 if last < 0 { out[0] = 46 as u8; out[1] = 0 as u8; return 1 }
81 var k: i64 = 0
82 while k < last { out[k] = p[k]; k = k + 1 }
83 out[k] = 0 as u8
84 return k
85}
86// write ALL of buf to path (create/truncate), fsync, close. 0 ok, -1 open failed, -2 short write.
87func ja_write_all(path: *u8, buf: *u8, n: i64, mode: i64) -> i64 {
88 let fd: i64 = sys_openat_wr(path, mode)
89 if fd < 0 { return 0 - 1 }
90 var off: i64 = 0
91 while off < n {
92 let r: i64 = sys_write(fd, (buf as i64 + off) as *u8, n - off)
93 if r <= 0 { sys_close(fd); return 0 - 2 }
94 off = off + r
95 }
96 sys_fsync(fd)
97 sys_close(fd)
98 return 0
99}
100// rep (may be 0): rep[0]=bytes rep[1]=verified rep[2]=reset -- the announce line as numbers a gate can read
101func ja_announce(src: *u8, bytes: i64, hex: *u8, arch: *u8, verified: i64, reset: i64, rc: i64, why: *u8, rep: *i64) -> i64 {
102 if (rep as i64) != 0 { rep[0] = bytes; rep[1] = verified; rep[2] = reset }
103 ja_puts("NX-JRNL-ARCHIVE src=" as *u8); ja_puts(src)
104 ja_puts(" bytes=" as *u8); ja_pn(bytes)
105 ja_puts(" sha256=" as *u8); ja_puts(hex)
106 ja_puts(" archived=" as *u8); ja_puts(arch)
107 ja_puts(" verified=" as *u8); ja_pn(verified)
108 ja_puts(" reset=" as *u8); ja_pn(reset)
109 ja_puts(" rc=" as *u8); ja_pn(rc)
110 ja_puts(" " as *u8); ja_puts(why); ja_puts("\n" as *u8)
111 return rc
112}
113// THE VERB. archive_dir may be 0: then <dirname(journal)>/archive. epoch names the archive.
114func ja_archive(journal: *u8, archive_dir: *u8, epoch: i64, rep: *i64) -> i64 {
115 let hex: *u8 = sys_mmap(JA_DIGEST_BYTES * 2 + 8)
116 hex[0] = 45 as u8; hex[1] = 0 as u8
117 let ln: *i64 = sys_mmap(16) as *i64
118 let src: *u8 = sys_read_file(journal, ln)
119 if (src as i64) == 0 { return ja_announce(journal, 0 - 1, hex, "-" as *u8, 0, 0, 3, "CANNOT-READ-JOURNAL (nothing touched)" as *u8, rep) }
120 let n: i64 = ln[0]
121 if n == 0 { return ja_announce(journal, 0, hex, "-" as *u8, 0, 0, 1, "EMPTY (nothing to archive, nothing touched)" as *u8, rep) }
122 let d0: *u8 = sys_mmap(JA_DIGEST_BYTES + 8)
123 sha256_digest(src, n, d0)
124 ja_hex(d0, hex)
125 // archive dir
126 let dir: *u8 = sys_mmap(JA_PATH_MAX)
127 if (archive_dir as i64) != 0 { ja_cat(dir, 0, archive_dir) } else {
128 var dl: i64 = ja_dirname(journal, dir)
129 dl = ja_cat(dir, dl, "/archive" as *u8)
130 }
131 sys_mkdir(dir, JA_DIR_MODE) // EEXIST is fine; a real failure surfaces at the write below
132 let arch: *u8 = sys_mmap(JA_PATH_MAX)
133 var ao: i64 = ja_cat(arch, 0, dir)
134 ao = ja_cat(arch, ao, "/" as *u8)
135 ao = ja_cat(arch, ao, ja_basename(journal))
136 ao = ja_cat(arch, ao, "." as *u8)
137 ao = ja_catn(arch, ao, epoch)
138 let wr: i64 = ja_write_all(arch, src, n, JA_ARCH_MODE)
139 if wr != 0 { return ja_announce(journal, n, hex, arch, 0, 0, 4, "ARCHIVE-WRITE-FAILED (live journal untouched)" as *u8, rep) }
140 // VERIFY BY RE-READING THE ARCHIVE FROM DISK, never by trusting the write
141 let ln2: *i64 = sys_mmap(16) as *i64
142 let back: *u8 = sys_read_file(arch, ln2)
143 var ok: i64 = 0
144 if (back as i64) != 0 { if ln2[0] == n {
145 let d1: *u8 = sys_mmap(JA_DIGEST_BYTES + 8)
146 sha256_digest(back, n, d1)
147 ok = 1
148 var i: i64 = 0
149 while i < JA_DIGEST_BYTES { if d0[i] != d1[i] { ok = 0 } i = i + 1 }
150 } }
151 if ok == 0 { return ja_announce(journal, n, hex, arch, 0, 0, 5, "VERIFY-MISMATCH (archive kept for inspection, live journal NOT reset)" as *u8, rep) }
152 // reset the live journal: create/truncate to zero bytes. The archive is already verified on disk.
153 let rfd: i64 = sys_openat_wr(journal, JA_ARCH_MODE)
154 if rfd < 0 { return ja_announce(journal, n, hex, arch, 1, 0, 6, "RESET-FAILED (archive verified, live journal unchanged)" as *u8, rep) }
155 sys_fsync(rfd)
156 sys_close(rfd)
157 return ja_announce(journal, n, hex, arch, 1, 1, 0, "OK (hash-verified copy, live journal reset to 0 bytes)" as *u8, rep) }
158
159// Shared append/archive protocol: acquire stable journal+".lock" before opening the journal.
160// Added for checked receipt writers. Legacy ja_archive above does NOT use this lock and remains unsafe to run concurrently; this change does not disable its deployed ELF.
161func ja_lock(journal: *u8) -> i64 {
162 if ja_slen(journal) + 6 >= JA_PATH_MAX { return 0 - 36 }
163 let path: *u8 = sys_mmap_shared(JA_PATH_MAX)
164 if (path as i64) <= 0 { return path as i64 }
165 let off: i64 = ja_cat(path, 0, journal)
166 ja_cat(path, off, ".lock" as *u8)
167 let fd: i64 = sys_openat_lock(path)
168 let released: i64 = sys_munmap(path, JA_PATH_MAX)
169 if released != 0 { if fd >= 0 { sys_close(fd) }; return released }
170 if fd < 0 { return fd }
171 let rc: i64 = sys_flock(fd, SYS_LOCK_EX | SYS_LOCK_NB)
172 if rc != 0 { sys_close(fd); return rc }
173 return fd
174}
175func ja_unlock(fd: i64) -> i64 { return sys_close(fd) }