use std::fs;
use std::io::Write;
use std::path::PathBuf;
use crate::ui;
pub const FORMAT: &str = "amont-agent-v1";
const MAX_RECORD: usize = 512;
const MAX_EXCERPT: usize = 200;
const MAX_BYTES: u64 = 256 * 1024;
const GENERATIONS: u32 = 10;
pub fn dir() -> Option<PathBuf> {
Some(crate::settings::config_dir()?.join("amont-agent"))
}
fn open_private(path: &std::path::Path) -> Option<fs::File> {
let f = fs::OpenOptions::new().append(true).open(path).ok()?;
private(path, 0o600);
Some(f)
}
#[cfg(unix)]
pub(crate) fn private(path: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = fs::metadata(path) else { return };
let now = meta.permissions().mode() & 0o777;
if now & !mode != 0 {
let _ = fs::set_permissions(path, fs::Permissions::from_mode(now & mode));
}
}
#[cfg(not(unix))]
pub(crate) fn private(_path: &std::path::Path, _mode: u32) {}
pub fn path() -> Option<PathBuf> {
Some(dir()?.join("journal.log"))
}
fn generation(base: &std::path::Path, n: u32) -> PathBuf {
let mut s = base.as_os_str().to_os_string();
s.push(format!(".{n}"));
PathBuf::from(s)
}
fn paths_of(base: &std::path::Path) -> Vec<PathBuf> {
(1..=GENERATIONS)
.rev()
.map(|n| generation(base, n))
.chain(std::iter::once(base.to_path_buf()))
.filter(|p| p.exists())
.collect()
}
pub fn paths() -> Vec<PathBuf> {
path().map(|p| paths_of(&p)).unwrap_or_default()
}
pub fn oldest_record() -> Option<u64> {
paths().iter().find_map(|p| {
fs::read_to_string(p).ok()?.lines().find_map(|l| {
let mut f = l.strip_prefix("F ")?.split_whitespace();
f.next()?.parse::<u64>().ok()
})
})
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Seen {
pub denied: u32,
pub advised: u32,
pub watched: u32,
pub unconfirmed: u32,
pub reasons: std::collections::BTreeMap<String, u32>,
}
impl Seen {
pub fn total(&self) -> u32 {
self.denied + self.advised + self.watched + self.unconfirmed
}
pub fn top_reason(&self) -> Option<(&str, u32)> {
self.reasons
.iter()
.max_by_key(|(_, n)| **n)
.map(|(r, n)| (r.as_str(), *n))
}
}
pub fn tally(since: u64) -> std::collections::BTreeMap<String, Seen> {
let mut out = Default::default();
let now = now();
for p in paths() {
tally_into(&mut out, &p, since, now);
}
out
}
#[cfg(test)]
pub fn tally_at(
path: &std::path::Path,
since: u64,
now: u64,
) -> std::collections::BTreeMap<String, Seen> {
let mut out: std::collections::BTreeMap<String, Seen> = Default::default();
tally_into(&mut out, path, since, now);
out
}
fn tally_into(
out: &mut std::collections::BTreeMap<String, Seen>,
path: &std::path::Path,
since: u64,
now: u64,
) {
let Ok(text) = fs::read_to_string(path) else {
return;
};
let floor = now.saturating_sub(since);
for line in text.lines() {
if !line.starts_with("F ") {
continue;
}
let mut f = line.split_whitespace();
let (Some(_), Some(ts), Some(rule), Some(stance), Some(outcome)) =
(f.next(), f.next(), f.next(), f.next(), f.next())
else {
continue;
};
let Ok(ts) = ts.parse::<u64>() else {
continue;
};
if ts < floor {
continue;
}
let seen = out.entry(rule.to_string()).or_default();
match (stance, outcome) {
("unconfirmed" | "unverified", why) => {
seen.unconfirmed += 1;
*seen.reasons.entry(why.to_string()).or_default() += 1;
}
(_, "denied") => seen.denied += 1,
(_, "advised") => seen.advised += 1,
(_, "watched") => seen.watched += 1,
_ => {}
}
}
}
pub struct Entry<'a> {
pub rule: &'a str,
pub stance: &'a str,
pub outcome: &'a str,
pub session: &'a str,
pub repo: &'a str,
pub mode: &'a str,
pub excerpt: &'a str,
}
pub const ENV_PROBE: &str = "AMONT_AGENT_PROBE";
pub fn record(entry: &Entry) {
if std::env::var_os(ENV_PROBE).is_some() {
return;
}
let _ = try_record(entry);
}
fn try_record(entry: &Entry) -> Option<()> {
let path = path()?;
let dir = path.parent()?;
fs::create_dir_all(dir).ok()?;
private(dir, 0o700);
append(&path, MAX_BYTES, &line_for(entry))
}
fn append(path: &std::path::Path, max: u64, line: &str) -> Option<()> {
let mut stuck = None;
if fs::metadata(path).map(|m| m.len() > max).unwrap_or(false) {
stuck = rotate(path, max).err();
}
let mut f = (0..3).find_map(|_| {
create_with_header(path)?;
open_private(path)
})?;
if let Some(why) = stuck {
let _ = f.write_all(format!("F {} - - rotate-failed - - - {why}\n", now()).as_bytes());
}
f.write_all(line.as_bytes()).ok()?;
Some(())
}
fn create_with_header(path: &std::path::Path) -> Option<()> {
if path.exists() {
return Some(());
}
let staged = {
let mut s = path.as_os_str().to_os_string();
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
s.push(format!(".new.{}.{n}", std::process::id()));
PathBuf::from(s)
};
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let wrote = opts
.open(&staged)
.and_then(|mut f| f.write_all(format!("{FORMAT}\n").as_bytes()));
let linked = wrote.and_then(|()| match fs::hard_link(&staged, path) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(_) => {
let mut opts = fs::OpenOptions::new();
opts.append(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
match opts.open(path) {
Ok(mut f) => f.write_all(format!("{FORMAT}\n").as_bytes()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(e) => Err(e),
}
}
Ok(()) => Ok(()),
});
let _ = fs::remove_file(&staged);
linked.ok()
}
fn rotate(path: &std::path::Path, max: u64) -> Result<(), String> {
let lock = generation(path, 0);
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
if opts.open(&lock).is_err() {
let stale = fs::metadata(&lock)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() > 60);
if !stale || fs::remove_file(&lock).is_err() || opts.open(&lock).is_err() {
return Ok(());
}
}
let mut moved = Ok(());
if fs::metadata(path).map(|m| m.len() > max).unwrap_or(false) {
let _ = fs::remove_file(generation(path, GENERATIONS));
for n in (1..GENERATIONS).rev() {
let _ = fs::rename(generation(path, n), generation(path, n + 1));
}
moved = fs::rename(path, generation(path, 1)).map_err(|e| e.kind().to_string());
}
let _ = fs::remove_file(&lock);
moved
.map(|_| ())
.map_err(|why| why.replace(char::is_whitespace, "-"))
}
pub fn line_for(entry: &Entry) -> String {
let excerpt = redact_and_trim(entry.excerpt);
let mut line = format!(
"F {} {} {} {} {} {} {} {}\n",
now(),
field(entry.rule),
field(entry.stance),
field(entry.outcome),
field(&entry.session.chars().take(8).collect::<String>()),
field(entry.repo),
field(entry.mode),
excerpt
);
if line.len() > MAX_RECORD {
let keep = MAX_RECORD - 2;
let cut = (0..=keep)
.rev()
.find(|i| line.is_char_boundary(*i))
.unwrap_or(0);
line.truncate(cut);
line.push('\n');
}
line
}
fn field(s: &str) -> String {
let cleaned: String = ui::sanitize(s)
.chars()
.map(|c| if c.is_whitespace() { '_' } else { c })
.collect();
if cleaned.is_empty() {
"-".to_string()
} else {
cleaned
}
}
pub fn redact_and_trim(text: &str) -> String {
let mut s = redact(text);
s = ui::sanitize(&s);
s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.chars().count() > MAX_EXCERPT {
s = s.chars().take(MAX_EXCERPT).collect::<String>() + "…";
}
s
}
pub fn redact(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut announced = false;
for word in text.split_inclusive(char::is_whitespace) {
let trimmed = word.trim_end();
if announced && !trimmed.is_empty() && !trimmed.starts_with('-') {
let closing: String = trimmed
.chars()
.rev()
.take_while(|c| !c.is_alphanumeric() && !matches!(c, '-' | '_' | '.'))
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
out.push_str("***");
out.push_str(&closing);
out.push_str(&word[trimmed.len()..]);
announced = false;
continue;
}
announced = announces_a_secret(trimmed);
out.push_str(&redact_word(word));
}
out
}
fn announces_a_secret(word: &str) -> bool {
if word.eq_ignore_ascii_case("bearer") || word.eq_ignore_ascii_case("basic") {
return true;
}
let Some(name) = word.strip_prefix('-') else {
return false;
};
if word.contains('=') {
return false; }
let name = name.strip_prefix('-').unwrap_or(name);
!name.is_empty() && secret_named(name)
}
fn redact_word(word: &str) -> String {
let trimmed = word.trim_end();
let tail = &word[trimmed.len()..];
if let Some(scheme) = trimmed.find("://") {
let mut out = String::from(&trimmed[..scheme + 3]);
let rest = &trimmed[scheme + 3..];
let rest = match rest.find('@') {
Some(at) if !rest[..at].contains('/') => {
out.push_str("***:***");
&rest[at..]
}
_ => rest,
};
out.push_str(&redact_query(rest));
return out + tail;
}
if let Some(eq) = trimmed.find('=') {
let (name, _) = trimmed.split_at(eq);
if !name.contains('/') && !name.contains(':') && secret_named(name) {
return format!("{name}=***") + tail;
}
}
for prefix in [
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"github_pat_",
"sk-",
"AKIA",
] {
if trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 8 {
return format!("{prefix}***{tail}");
}
}
word.to_string()
}
fn secret_named(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
["TOKEN", "SECRET", "PASSWORD", "PASSWD", "KEY", "CREDENTIAL"]
.iter()
.any(|needle| upper.contains(needle))
}
fn redact_query(rest: &str) -> String {
let Some(q) = rest.find('?') else {
return rest.to_string();
};
let (base, query) = rest.split_at(q + 1);
let mut out = String::from(base);
for (i, param) in query.split('&').enumerate() {
if i > 0 {
out.push('&');
}
match param.split_once('=') {
Some((name, value)) if secret_named(name) => {
let keep: String = value
.chars()
.rev()
.take_while(|c| !c.is_alphanumeric() && *c != '-' && *c != '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
out.push_str(name);
out.push_str("=***");
out.push_str(&keep);
}
_ => out.push_str(param),
}
}
out
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry<'a>(excerpt: &'a str) -> Entry<'a> {
Entry {
rule: "pipe-to-tail",
stance: "deny",
outcome: "denied",
session: "0123456789abcdef",
repo: "amont",
mode: "default",
excerpt,
}
}
#[test]
fn the_tally_reads_what_was_written_and_nothing_else() {
let dir = std::env::temp_dir().join(format!("amont-agent-tally-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir");
let path = dir.join("journal.log");
let now = 2_000_000u64;
let text = format!(
"{FORMAT}\n\
F {} pipe-to-tail deny denied sess repo default git push | tail -1\n\
F {} stale-base advise advised sess repo default git checkout -b x\n\
F {} stale-base unconfirmed the_start_point_is_not_behind_origin sess repo default git checkout -b y\n\
F {} stale-base unconfirmed the_start_point_is_not_behind_origin sess repo default git checkout -b z\n\
F {} stale-base unconfirmed skipped sess repo default git checkout -b w\n\
F {} branch-force-delete observe watched sess repo default git branch -D q\n\
F {} branch-force-delete observe watched sess repo default git branch -D old\n\
F {} pipe-to-tail deny denied\n\
F notatime pipe-to-tail deny denied sess repo default x\n\
this line is torn and has no F\n",
now - 10,
now - 20,
now - 30,
now - 40,
now - 50,
now - 60,
now - 400_000, now - 5, );
fs::write(&path, text).expect("write journal");
let seen = tally_at(&path, 100_000, now);
let pipe = &seen["pipe-to-tail"];
assert_eq!((pipe.denied, pipe.total()), (2, 2), "{pipe:?}");
let stale = &seen["stale-base"];
assert_eq!(stale.advised, 1);
assert_eq!(stale.unconfirmed, 3);
assert_eq!(
stale.top_reason(),
Some(("the_start_point_is_not_behind_origin", 2)),
"the leading reason, not the legacy `skipped`"
);
let bfd = &seen["branch-force-delete"];
assert_eq!(bfd.watched, 1, "the old record is outside the window");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_tally_keeps_the_implementation_reviews_reasons_apart() {
let dir = std::env::temp_dir().join(format!("amont-agent-tally-ir-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir");
let path = dir.join("journal.log");
let now = 2_000_000u64;
let t = now - 10;
let text = format!(
"{FORMAT}\n\
F {t} implementation-review unconfirmed reviewed s1 app default git push -u origin feat/x\n\
F {t} implementation-review unconfirmed reviewed s1 app default git push -u origin feat/x\n\
F {t} implementation-review unconfirmed no-plan s1 app default git push -u origin chore/b\n\
F {t} implementation-review unconfirmed overruled s1 app default git push -u origin feat/x\n\
F {t} implementation-review advise advised s1 app default tree=5085edae1d66 review=missing verdict=-\n\
F {t} implementation-review advise advised s1 app default tree=5085edae1d66 review=stale verdict=-\n\
F {t} implementation-review deny denied s1 app default tree=5085edae1d66 review=missing verdict=-\n\
F {t} implementation-review observe watched s1 app default tree=5085edae1d66 review=missing verdict=-\n\
F {t} implementation-review - overruled s1 app - tree=5085edae1d66 review=overruled verdict=rework\n"
);
fs::write(&path, text).expect("write journal");
let seen = tally_at(&path, 100_000, now);
let ir = &seen["implementation-review"];
assert_eq!((ir.advised, ir.denied, ir.watched), (2, 1, 1), "{ir:?}");
assert_eq!(ir.unconfirmed, 4);
assert_eq!(ir.reasons.get("reviewed"), Some(&2));
assert_eq!(ir.reasons.get("no-plan"), Some(&1));
assert_eq!(ir.reasons.get("overruled"), Some(&1));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_record_is_exactly_one_line() {
let line = line_for(&entry("git push \n\n origin main | tail -1"));
assert_eq!(line.matches('\n').count(), 1);
assert!(line.ends_with('\n'));
assert!(line.len() <= MAX_RECORD);
}
#[test]
fn an_enormous_command_still_fits_one_write() {
let line = line_for(&entry(&"x".repeat(20_000)));
assert!(line.len() <= MAX_RECORD, "{} bytes", line.len());
assert_eq!(line.matches('\n').count(), 1);
}
#[test]
fn a_credential_never_reaches_the_journal() {
for (raw, must_not_contain) in [
("git push https://fred:hunter2@github.com/x", "hunter2"),
(
"GITHUB_TOKEN=ghp_abcdefghijklmnop1234 git push",
"ghp_abcdefghijklmnop1234",
),
("curl -H x --password=s3cr3t https://x", "s3cr3t"),
(
"gh auth login --with-token ghp_zzzzzzzzzzzzzzzzzzzz",
"ghp_zzzzzzzzzzzzzzzzzzzz",
),
] {
let got = redact_and_trim(raw);
assert!(
!got.contains(must_not_contain),
"leaked {must_not_contain:?} from {raw:?} → {got}"
);
}
let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
let got = redact_and_trim(&format!("aws --key {key} s3 ls"));
assert!(!got.contains(&key), "leaked an access key id → {got}");
}
#[test]
fn redaction_leaves_the_command_readable() {
let got = redact_and_trim("GITHUB_TOKEN=ghp_abcdefghijklmnop1234 git push | tail -1");
assert!(got.contains("git push"), "{got}");
assert!(got.contains("tail"), "{got}");
}
#[test]
fn control_bytes_are_escaped_not_stored_raw() {
let got = redact_and_trim("git push \u{1b}[8m hidden");
assert!(!got.contains('\u{1b}'), "{got}");
}
#[test]
fn an_api_path_is_not_a_secret_assignment() {
let raw = r#"curl -sS "http://localhost:3000/api/v1/repos/x/actions/tasks?limit=5" | jq"#;
assert_eq!(redact(raw), raw, "a benign URL was rewritten");
}
#[test]
fn a_secret_query_parameter_is_still_redacted() {
let got = redact("curl \"https://hooks.example.com/x?token=abc123def456&limit=5\"");
assert!(!got.contains("abc123def456"), "{got}");
assert!(
got.contains("limit=5"),
"the benign parameter survived: {got}"
);
assert!(got.ends_with('"'), "the closing quote survived: {got}");
}
#[test]
fn redaction_leaves_a_command_the_lexer_can_still_read() {
for raw in [
r#"curl -sS "http://x/api/v1/tasks?limit=5" | jq '.total'"#,
r#"GITHUB_TOKEN=ghp_aaaaaaaaaaaaaaaaaaaa git push origin main"#,
r#"git commit -m "msg" --no-verify && git push"#,
] {
let redacted = redact(raw);
assert!(
!matches!(
crate::shell::lex(&redacted),
crate::shell::Parsed::Opaque(_)
),
"redaction made this unreadable: {redacted}"
);
}
}
#[test]
fn a_field_never_contains_a_space() {
assert!(!field("two words").contains(' '));
assert_eq!(field(""), "-");
}
#[test]
fn a_credential_in_the_following_word_is_redacted_too() {
for (raw, secret) in [
("curl --password s3cr3t https://x", "s3cr3t"),
(
"gh auth login --with-token gitlab-abcdefghij",
"gitlab-abcdefghij",
),
(
r#"curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.body.sig" https://x"#,
"eyJhbGciOiJIUzI1NiJ9.body.sig",
),
(
"vault write secret/x api_key=zzz --token hvs.CAESIJx",
"hvs.CAESIJx",
),
] {
let got = redact(raw);
assert!(
!got.contains(secret),
"leaked {secret:?} from {raw:?} → {got}"
);
}
}
#[test]
fn the_following_word_keeps_what_closes_it() {
let got = redact(r#"curl -H "Authorization: Bearer abcdefghij" https://x"#);
assert!(
got.contains(r#"***""#),
"the closing quote was swallowed: {got}"
);
assert!(
!matches!(crate::shell::lex(&got), crate::shell::Parsed::Opaque(_)),
"redaction made this unreadable: {got}"
);
}
#[test]
fn a_flag_after_an_announcement_is_not_the_value() {
let got = redact("curl --token --verbose https://x");
assert!(got.contains("--verbose"), "{got}");
}
#[cfg(unix)]
#[test]
fn the_journal_is_not_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("amont-agent-mode-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let fresh = dir.join("journal.log");
create_with_header(&fresh).expect("create");
open_private(&fresh).expect("open");
let mode = fs::metadata(&fresh).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a journal we create is ours alone");
let old = dir.join("legacy.log");
fs::write(&old, "F 1 x\n").unwrap();
fs::set_permissions(&old, fs::Permissions::from_mode(0o644)).unwrap();
open_private(&old).expect("open");
let mode = fs::metadata(&old).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"an existing 0644 journal is narrowed, not left"
);
let strict = dir.join("strict.log");
fs::write(&strict, "F 1 x\n").unwrap();
fs::set_permissions(&strict, fs::Permissions::from_mode(0o400)).unwrap();
private(&strict, 0o600);
let mode = fs::metadata(&strict).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o400, "narrowing must never widen");
let _ = fs::remove_dir_all(&dir);
}
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("amont-agent-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn rec(ts: u64, tag: &str) -> String {
format!("F {ts} r deny denied sess repo default {tag}\n")
}
#[test]
fn rotation_keeps_ten_generations_and_every_file_has_a_header() {
let dir = scratch("rotate");
let path = dir.join("journal.log");
let big = "x".repeat(300);
for i in 0..60 {
append(&path, 1000, &rec(1, &format!("late{i} {big}"))).expect("append");
}
let all = paths_of(&path);
assert_eq!(all.len(), 11, "journal.log + .1..=.10: {all:?}");
assert!(!generation(&path, 11).exists(), "the oldest was dropped");
for p in &all {
let text = fs::read_to_string(p).unwrap();
assert_eq!(text.lines().next(), Some(FORMAT), "{p:?}");
}
assert_eq!(all.last(), Some(&path));
assert!(all[0].ends_with("journal.log.10"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_rotation_that_cannot_move_the_live_file_is_journalled() {
let dir = scratch("stuck");
let path = dir.join("journal.log");
append(&path, 100, &rec(1, &"z".repeat(200))).expect("first");
for n in 1..=GENERATIONS {
fs::create_dir(generation(&path, n)).unwrap();
fs::write(generation(&path, n).join("x"), "x").unwrap();
}
append(&path, 100, &rec(2, "late")).expect("still appends");
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains(" rotate-failed "), "{text}");
assert!(text.contains(" late"), "{text}");
assert!(!generation(&path, 0).exists(), "the lock is released");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_tally_reads_every_generation_with_the_floor() {
let dir = scratch("gens");
let path = dir.join("journal.log");
let now = 2_000_000u64;
let w = |p: &std::path::Path, ts: u64, rule: &str| {
fs::write(
p,
format!("{FORMAT}\nF {ts} {rule} deny denied s r default x\n"),
)
.unwrap();
};
w(&generation(&path, 3), now - 100, "in-three");
w(&generation(&path, 9), now - 500_000, "old-nine");
w(&path, now - 5, "live");
let mut seen = std::collections::BTreeMap::new();
for p in paths_of(&path) {
tally_into(&mut seen, &p, 100_000, now);
}
assert_eq!(seen["in-three"].denied, 1);
assert_eq!(seen["live"].denied, 1);
assert!(!seen.contains_key("old-nine"), "outside the window");
let _ = fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "a helper: spawned by eight_processes_lose_no_record_and_tear_no_line"]
fn child_writer() {
let (Some(p), Some(id)) = (
std::env::var_os("AMONT_JOURNAL_TEST_PATH"),
std::env::var("AMONT_JOURNAL_TEST_ID").ok(),
) else {
return;
};
for i in 0..120 {
append(
std::path::Path::new(&p),
20_000,
&rec(1, &format!("w{id}-{i} {}", "y".repeat(100))),
);
}
}
#[test]
fn eight_processes_lose_no_record_and_tear_no_line() {
let dir = scratch("procs");
let path = dir.join("journal.log");
let exe = std::env::current_exe().unwrap();
let kids: Vec<_> = (0..8)
.map(|id| {
std::process::Command::new(&exe)
.args([
"--exact",
"--ignored",
"journal::tests::child_writer",
"--test-threads=1",
])
.env("AMONT_JOURNAL_TEST_PATH", &path)
.env("AMONT_JOURNAL_TEST_ID", id.to_string())
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn")
})
.collect();
for mut k in kids {
assert!(k.wait().unwrap().success());
}
let mut found = std::collections::BTreeSet::new();
for p in paths_of(&path) {
let text = fs::read_to_string(&p).unwrap();
assert_eq!(text.lines().next(), Some(FORMAT), "{p:?}");
for line in text.lines() {
if line.starts_with("F ") {
let f: Vec<&str> = line.split_whitespace().collect();
assert!(f.len() >= 9, "torn line {line:?} in {p:?}");
found.insert(f[8].to_string());
}
}
}
assert!(
paths_of(&path).len() > 2,
"the threshold was crossed several times"
);
for id in 0..8 {
for i in 0..120 {
assert!(found.contains(&format!("w{id}-{i}")), "lost w{id}-{i}");
}
}
let _ = fs::remove_dir_all(&dir);
}
}