1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
//! Two processes sharing one audit log.
//!
//! The chain is per-process state — `seq` and `last_hash` live in memory — so
//! two shells appending to one file interleave two independent chains. That is
//! not an exotic configuration: in agent mode the log defaults to
//! `<workspace>/.ae/audit.log`, so two agents in one workspace share it by
//! default.
//!
//! Probing that case found three problems, in increasing order of seriousness:
//!
//! * `verify_audit` reported `broken chain link`, which reads as tampering
//! and sends the reader hunting for an attacker who is not there;
//! * each process's tail check fired on the *other's* writes, filling the log
//! with `tamper-detected` markers — an alarm that goes off whenever two
//! agents run is an alarm nobody reads;
//! * and records could be **torn**. `writeln!` emits the content and the
//! newline as separate writes, and `O_APPEND` only guarantees atomicity per
//! write, so two entries could land on one line and the log stopped being
//! valid JSON at all.
//!
//! The third is the one that mattered most: a log that fails to verify still
//! tells you something happened, and a log that cannot be parsed tells you
//! nothing.
//!
//! What is *not* claimed here: that a shared log forms a single verifiable
//! chain. It does not, and cannot without cross-process locking. The tests
//! assert that it stays parseable, stays quiet, and explains itself.
#![cfg(feature = "native")]
use std::process::Command;
fn ae() -> std::path::PathBuf {
let mut p = std::env::current_exe().expect("test exe");
p.pop();
if p.ends_with("deps") {
p.pop();
}
p.join(format!("ae{}", std::env::consts::EXE_SUFFIX))
}
struct Shared {
dir: std::path::PathBuf,
}
impl Shared {
fn new() -> Self {
// pid + a process-wide counter + the clock.
//
// pid alone is not enough: these tests run as threads of one process.
// pid + clock is not enough either — macOS's `SystemTime` is coarse
// enough that two tests starting together read the *same* nanosecond,
// land in one directory and share an audit log. That is exactly what
// happened: the concurrent-writer test read the single-writer test's
// log and saw 10 entries where it expected 80, on macOS only. The
// counter is what actually guarantees uniqueness here; the clock just
// keeps names readable across runs.
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"ae_auditconc_{}_{}_{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("dir");
Self { dir }
}
fn log(&self) -> std::path::PathBuf {
self.dir.join("audit.log")
}
/// Start one shell writing `n` audited operations, without waiting.
fn spawn_writer(&self, tag: &str, n: usize) -> std::process::Child {
let src: String = (0..n)
.map(|i| format!("file_write(\"{tag}{i}.txt\", \"x\")\n"))
.collect();
let script = self.dir.join(format!("{tag}.ae"));
std::fs::write(&script, src).expect("write script");
Command::new(ae())
.arg("--deterministic")
.arg(&script)
.current_dir(&self.dir)
.env("AETHER_MODE", "agent")
.env("AETHER_WORKSPACE", &self.dir)
.env("AETHER_AUDIT_LOG", self.log())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn writer")
}
fn entries(&self) -> Vec<serde_json::Value> {
let text = std::fs::read_to_string(self.log()).unwrap_or_default();
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
serde_json::from_str(l).unwrap_or_else(|e| {
panic!(
"audit log line is not valid JSON ({e}); a torn record means two \
processes interleaved inside one line:\n{l}"
)
})
})
.collect()
}
}
impl Drop for Shared {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Run two writers at once and return their entries. Forty operations each is
/// enough that they genuinely overlap rather than running back to back — which
/// the interleaving assertion below checks rather than assumes.
fn run_concurrently() -> (Shared, Vec<serde_json::Value>) {
let shared = Shared::new();
let mut a = shared.spawn_writer("a", 40);
let mut b = shared.spawn_writer("b", 40);
a.wait().expect("writer a");
b.wait().expect("writer b");
let entries = shared.entries();
// Hand the workspace back rather than leaking it: an earlier draft used
// `mem::forget` to keep the directory alive past the caller's assertions,
// which left one temp tree behind per test run.
(shared, entries)
}
#[test]
fn concurrent_writers_never_tear_a_record() {
// `entries()` panics on unparseable JSON, so reaching the assertion at all
// means every line survived intact.
let (_ws, entries) = run_concurrently();
assert_eq!(
entries.len(),
80,
"expected exactly one entry per audited operation; got {}",
entries.len()
);
}
#[test]
fn the_writers_really_do_interleave() {
// Without this the other tests could pass because the two processes
// happened to run one after the other, which is the easy case and not the
// one under test.
let (_ws, entries) = run_concurrently();
let switches = entries
.windows(2)
.filter(|w| w[0]["writer"] != w[1]["writer"])
.count();
assert!(
switches > 0,
"the two writers never interleaved, so this file proves nothing about \
concurrency; re-run or raise the operation count"
);
}
/// A concurrent writer is not an attacker. Before this distinction existed, the
/// tail check fired on the other process's entries and every concurrent run
/// left `tamper-detected` markers behind.
#[test]
fn concurrency_does_not_raise_a_tamper_alarm() {
let (_ws, entries) = run_concurrently();
let markers: Vec<_> = entries
.iter()
.filter(|e| e["decision"] == "tamper-detected")
.collect();
assert!(
markers.is_empty(),
"{} false tamper alarms from ordinary concurrent use; an alarm that \
fires whenever two agents run is one nobody will read",
markers.len()
);
}
#[test]
fn every_entry_records_which_writer_produced_it() {
let (_ws, entries) = run_concurrently();
for e in &entries {
let w = e["writer"].as_str().unwrap_or_default();
assert_eq!(
w.len(),
16,
"entry has no usable writer id, so a shared log cannot be \
attributed: {e}"
);
}
let distinct: std::collections::BTreeSet<&str> = entries
.iter()
.filter_map(|e| e["writer"].as_str())
.collect();
assert_eq!(distinct.len(), 2, "expected two distinct writers");
}
/// A single writer must still produce one clean, verifiable chain — the
/// concurrency handling must not have loosened the ordinary case.
#[test]
fn a_single_writer_still_produces_one_valid_chain() {
let shared = Shared::new();
let mut only = shared.spawn_writer("solo", 10);
only.wait().expect("writer");
let entries = shared.entries();
assert_eq!(entries.len(), 10);
let seqs: Vec<u64> = entries.iter().filter_map(|e| e["seq"].as_u64()).collect();
assert_eq!(
seqs,
(1..=10).collect::<Vec<u64>>(),
"a single writer should number its entries 1..n with no gaps"
);
let n = aethershell::safety::verify_audit_with(&shared.log(), None)
.expect("a single writer's log must verify");
assert_eq!(n, 10);
}
/// The writer id is *authenticated*, not merely recorded.
///
/// This matters because of what the id now controls: a differing writer
/// suppresses the tamper alarm and changes what `verify_audit` reports. If the
/// field sat outside the hashed core, an attacker who rewrote the log could
/// stamp fresh writer ids onto their entries and have the result read as
/// ordinary concurrency instead of tampering — turning a mitigation into a
/// laundering mechanism.
///
/// It is inside the core, so relabelling breaks the entry hash.
#[test]
fn relabelling_a_writer_id_is_detected_as_tampering() {
let shared = Shared::new();
let mut w = shared.spawn_writer("auth", 3);
w.wait().expect("writer");
assert_eq!(
aethershell::safety::verify_audit_with(&shared.log(), None).expect("clean log verifies"),
3
);
// Rewrite one entry's writer id, leaving its stored hash untouched.
let text = std::fs::read_to_string(shared.log()).expect("read log");
let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
let mut first: serde_json::Value = serde_json::from_str(&lines[0]).expect("parse first entry");
first["writer"] = serde_json::Value::String("deadbeefdeadbeef".into());
lines[0] = serde_json::to_string(&first).expect("reserialize");
std::fs::write(shared.log(), lines.join("\n") + "\n").expect("write log");
let err = aethershell::safety::verify_audit_with(&shared.log(), None)
.expect_err("a relabelled writer id must not verify");
assert!(
err.contains("tampered") || err.contains("mismatch"),
"expected the hash to catch the relabelling, got: {err}"
);
}