Skip to main content

shep_core/
barks.rs

1//! `barks.jsonl`: the size-capped ring of fired alerts (spec §10.4).
2//!
3//! One [`Bark`] per line, appended by two writers in two different
4//! processes — the bark dog when a rule fires, and the shepherd itself
5//! when an enabled dog exhausts its restart budget — and read by a third,
6//! `shep barks`. [`append`] keeps the file under a byte cap by evicting
7//! whole lines oldest-first, rewriting the survivors plus the new record
8//! to a sibling temp file and `rename`ing it over the original — the same
9//! atomic-replace shape `shep-daemon`'s `snapshot::write_atomic` uses —
10//! rather than truncating in place, so a writer that dies mid-rewrite
11//! never leaves the reader a fragment.
12//!
13//! [`read`] is the forgiving half: a line that will not parse — a
14//! partially-written record from a writer that died mid-append, or a
15//! record from a future shep — costs the reader that one record, not the
16//! whole history. This file is read during an incident; refusing the
17//! whole ring over one bad line would be the wrong failure mode.
18//!
19//! Two writer processes is also why [`append`] takes an advisory lock on
20//! a sibling `<path>.lock` and holds it across the whole
21//! read-evict-rewrite-rename sequence. Without it the two writers
22//! interleave read-modify-write and the later `rename` silently discards
23//! every record the other appended in between — reproduced, not
24//! theorised: two processes appending 200 records each left 200 of the
25//! expected 400 in the file. Nothing about the atomic-replace shape
26//! prevents that; atomicity buys the reader a whole file, not the writer
27//! a whole transaction.
28//!
29//! Lives in shep-core, not shep-daemon, because it has two writers that
30//! are two different processes (the shepherd and the bark dog) and
31//! neither is the other's crate — one shared cap implementation, or the
32//! two writers evict differently, and that is exactly the kind of drift
33//! nobody watches until an incident.
34
35use core::fmt;
36use std::io::Write as _;
37use std::path::Path;
38
39use serde::{Deserialize, Serialize};
40
41/// Mode `barks.jsonl` (and the temp file it is rewritten through) is
42/// created with: owner read/write, nobody else.
43///
44/// `$SHEP_HOME` itself is already `0700` (`boot::DIR_MODE` in
45/// `shep-daemon`), so this is belt-and-braces, not the only guard between
46/// this file and another local user — and it is belt-and-braces for a
47/// different reason than `snapshot::write_atomic`'s own `0600`. That file
48/// holds `AppConfig::env` verbatim, a real secret. This one does not: a
49/// [`Bark`] carries a rule name, a subject and a message, and
50/// [`SinkOutcome`] names a sink by its `[dog.bark.sinks]` config key,
51/// never by the webhook URL or token behind it. The mode stays tight
52/// anyway, matching the rest of `$SHEP_HOME`'s posture (spec §10: no
53/// other user, at all) and because this is still a record of what the
54/// shepherd told an outside service — and so that a future field that DID
55/// carry a URL would arrive into a file that was already narrow, not one
56/// that has to widen for it.
57#[cfg(unix)]
58const BARK_FILE_MODE: u32 = 0o600;
59
60/// Cap the ring keeps itself under when nobody configured one.
61pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
62
63/// One fired alert, as it lands in `$SHEP_HOME/barks.jsonl`.
64///
65/// One JSON object per line, because the file is appended to by two
66/// writers (the bark dog when a rule fires, and the shepherd when an
67/// enabled dog exhausts its budget) and read by a third (`shep barks`).
68/// A line-delimited format is the one shape where an interrupted write
69/// costs the reader one record instead of the file.
70///
71/// `Debug` is derived, not redacted. Every field here is shep's own
72/// prose or a config key — never a sink's target — so printing a `Bark`
73/// is safe, and it must stay that way: a field that ever carried a
74/// webhook URL or token would need its own redacted `Debug` (IR-41) the
75/// day it lands, and this comment is the tripwire for that review.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Bark {
78    /// Unix millis when the alert fired.
79    pub at_ms: u64,
80    /// The rule that fired, or `daemon` when the shepherd wrote this
81    /// itself.
82    pub rule: String,
83    /// What it is about: a sheep's name, or a dog's.
84    pub subject: String,
85    /// The human-readable line. Plain English, no theme — this is read
86    /// during an incident.
87    pub message: String,
88    /// Which sinks the alert was delivered to, and whether each took it.
89    /// Empty when the shepherd wrote the record itself: it has no sinks
90    /// and no webhook code, and says so by carrying none.
91    pub sinks: Vec<SinkOutcome>,
92}
93
94/// What one sink made of one alert.
95///
96/// Names the sink by its `[dog.bark.sinks]` config key, never by its
97/// webhook URL or bearer token — that is what keeps this type, and
98/// [`Bark`] alongside it, safe to print with a derived `Debug`.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct SinkOutcome {
101    /// The sink's name from `[dog.bark.sinks]`.
102    pub sink: String,
103    /// `None` when it was delivered; the failure otherwise.
104    pub error: Option<String>,
105}
106
107/// Error type returned by [`append`] and [`read`].
108///
109/// Wraps `io::Error`/`serde_json::Error` directly rather than
110/// stringifying them (same reasoning as `shep-daemon`'s
111/// `SnapshotError`) so callers keep the underlying diagnostic via
112/// [`core::error::Error::source`] — the cost is that this enum cannot
113/// derive `Clone`/`PartialEq`/`Eq` (IR-19's documented exception for
114/// variants wrapping `io::Error`).
115///
116/// `#[non_exhaustive]`: shep-core is a published library and this enum is
117/// reachable from it, so a third failure shape — a ring whose on-disk format
118/// this build does not recognise, say — must not break an out-of-tree
119/// consumer's `match` (IR-20).
120#[non_exhaustive]
121#[derive(Debug)]
122pub enum BarkError {
123    /// The ring file could not be read, written, or replaced.
124    Io(std::io::Error),
125    /// A [`Bark`] could not be serialized to JSON.
126    Encode(serde_json::Error),
127}
128
129impl fmt::Display for BarkError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Io(err) => write!(f, "bark ring I/O failed: {err}"),
133            Self::Encode(err) => write!(f, "bark record failed to serialize: {err}"),
134        }
135    }
136}
137
138impl core::error::Error for BarkError {
139    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
140        match self {
141            Self::Io(err) => Some(err),
142            Self::Encode(err) => Some(err),
143        }
144    }
145}
146
147impl From<std::io::Error> for BarkError {
148    fn from(source: std::io::Error) -> Self {
149        Self::Io(source)
150    }
151}
152
153impl From<serde_json::Error> for BarkError {
154    fn from(source: serde_json::Error) -> Self {
155        Self::Encode(source)
156    }
157}
158
159/// Appends `bark` to `path`, evicting oldest-first to keep the file under
160/// `max_bytes`.
161///
162/// Eviction is oldest-out by whole lines: the file is rewritten with a
163/// prefix of its lines dropped, atomically, so a reader never sees a
164/// truncated one. A single record larger than `max_bytes` is written
165/// anyway and leaves the file over the cap — the alternative is silently
166/// dropping the alert that was too interesting to fit.
167///
168/// Serialized against other appenders — in this process or any other — by
169/// an advisory lock on a sibling `<path>.lock`, held across the whole
170/// read-modify-rename. Two writers without it lose each other's records
171/// outright; see this module's own doc. Concurrent [`read`]s are not
172/// blocked and do not need to be: the ring is only ever replaced whole,
173/// by `rename`.
174///
175/// # Errors
176/// - [`BarkError::Io`] — the file could not be read, written, or
177///   replaced, or the lock beside it could not be taken.
178/// - [`BarkError::Encode`] — the record could not be serialized.
179pub fn append(path: &Path, bark: &Bark, max_bytes: u64) -> Result<(), BarkError> {
180    // Held until this function returns, so the read below and the rename
181    // at the end are one transaction as far as any other writer is
182    // concerned — see [`RingLock`] for why the lock is not on `path`.
183    let _lock = RingLock::acquire(path)?;
184
185    let mut lines = read_lines(path)?;
186    let new_line = serde_json::to_string(bark)?;
187    lines.push(new_line);
188
189    // Oldest-out: drop the front line until the ring fits under the cap,
190    // or only the record just appended is left — see this function's own
191    // doc for why a lone oversized record is kept rather than dropped.
192    loop {
193        if lines.len() <= 1 || ring_bytes(&lines) <= max_bytes {
194            break;
195        }
196        lines.remove(0);
197    }
198
199    write_ring(path, &lines)
200}
201
202/// Reads every bark in `path`, oldest first, skipping any line that will
203/// not parse.
204///
205/// A line that will not parse is a partially-written record from a writer
206/// that died mid-append, or a record from a future shep. Neither is a
207/// reason to refuse the whole history during an incident, which is the one
208/// time this file is read.
209///
210/// # Errors
211/// - [`BarkError::Io`] — the file exists and could not be read. A missing
212///   file is `Ok(Vec::new())`: no barks yet is not a fault.
213pub fn read(path: &Path) -> Result<Vec<Bark>, BarkError> {
214    match std::fs::read_to_string(path) {
215        Ok(text) => Ok(text
216            .lines()
217            .filter_map(|line| serde_json::from_str(line).ok())
218            .collect()),
219        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
220        Err(err) => Err(BarkError::Io(err)),
221    }
222}
223
224/// `path`'s existing lines, raw and unparsed, or an empty ring if the
225/// file does not exist yet.
226///
227/// Deliberately does not parse: eviction operates on whole lines exactly
228/// as they sit on disk, so a line a future shep wrote (or a fragment a
229/// dead writer left) still counts toward the byte cap and still survives
230/// an eviction it does not trigger — [`read`], not this, is where an
231/// unparseable line is finally dropped.
232fn read_lines(path: &Path) -> Result<Vec<String>, BarkError> {
233    match std::fs::read_to_string(path) {
234        Ok(text) => Ok(text.lines().map(str::to_owned).collect()),
235        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
236        Err(err) => Err(BarkError::Io(err)),
237    }
238}
239
240/// Total on-disk size, in bytes, if `lines` were written one per line
241/// (each line plus its trailing `\n`).
242fn ring_bytes(lines: &[String]) -> u64 {
243    lines.iter().map(|line| line.len() as u64 + 1).sum()
244}
245
246/// Rewrites `path` to hold exactly `lines`: the new content lands in a
247/// uniquely-named sibling temp file, is `fsync`ed, then `rename`d over
248/// `path` — the same shape `snapshot::write_atomic` uses, so an
249/// interrupted write leaves the original file exactly as it was rather
250/// than a fragment (this module's own doc: rewrite-and-replace, not
251/// truncate-in-place).
252///
253/// The name is unique per call, not a fixed `<path>.tmp`. A shared temp
254/// name is not merely untidy: two writers racing on it had one process's
255/// `rename` consume the other's staging file, and the loser died with
256/// `ENOENT` renaming a path that no longer existed. [`RingLock`] already
257/// keeps two appenders apart, so this is the second lock on the same door
258/// — deliberately, because it is the half that survives a caller who ever
259/// reaches `write_ring` by another route.
260fn write_ring(path: &Path, lines: &[String]) -> Result<(), BarkError> {
261    let parent = path.parent().unwrap_or_else(|| Path::new("."));
262    let mut tmp = create_ring_file(parent)?;
263
264    for line in lines {
265        tmp.write_all(line.as_bytes())?;
266        tmp.write_all(b"\n")?;
267    }
268    tmp.as_file().sync_all()?;
269
270    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
271    // inside the error and its `Drop` removes the staging file, so a
272    // failed replace does not leave one behind.
273    tmp.persist(path).map_err(|err| BarkError::Io(err.error))?;
274    Ok(())
275}
276
277/// Creates the staging file the ring is rewritten through, in `parent` so
278/// the later `rename` stays within one filesystem.
279///
280/// Mode-at-creation rather than a separate `chmod` pass ([`tempfile`]
281/// passes these permissions to the `open` call itself): there is no window
282/// where the file sits at whatever the process umask leaves it, the same
283/// TOCTOU `boot::create_dir_at_dir_mode`'s own doc explains for
284/// directories. On Windows the permissions are left alone — there is no
285/// unix permission-bit equivalent (the same split
286/// `snapshot::write_atomic`'s own `write_atomic_is_owner_only_on_unix`
287/// test uses) — and `tempfile`'s own default is already owner-only on
288/// unix, so this call only makes that choice explicit rather than
289/// inherited.
290fn create_ring_file(parent: &Path) -> std::io::Result<tempfile::NamedTempFile> {
291    let mut builder = tempfile::Builder::new();
292    builder.prefix("barks").suffix(".tmp");
293
294    #[cfg(unix)]
295    {
296        use std::os::unix::fs::PermissionsExt as _;
297        builder.permissions(std::fs::Permissions::from_mode(BARK_FILE_MODE));
298    }
299
300    builder.tempfile_in(parent)
301}
302
303/// An exclusive advisory lock over one bark ring, held for as long as the
304/// value lives and released when it drops (including on an early `?`, and
305/// by the kernel if the process dies holding it).
306///
307/// The lock is on a sibling `<path>.lock`, never on the ring itself, and
308/// that is the whole design decision: `append` finishes by `rename`ing a
309/// new file over `path`, which replaces the inode. A lock taken on the
310/// ring would be a lock on an inode that the very next successful append
311/// unlinks — the next writer would open the *new* inode, find it
312/// unlocked, and the two would be excluding nothing. The lock file is
313/// never renamed, never rewritten, and never read; it exists only to be
314/// an inode with a stable identity, and it is left on disk between
315/// appends on purpose so both writers keep agreeing on which one it is.
316struct RingLock {
317    /// `flock(2)` is released by this handle's `Drop`. Named with a
318    /// leading underscore because it is held, never read.
319    #[cfg(unix)]
320    _flock: nix::fcntl::Flock<std::fs::File>,
321}
322
323impl RingLock {
324    /// Blocks until this process holds the ring's lock exclusively.
325    ///
326    /// # Errors
327    /// The lock file could not be created beside `path`, or `flock` failed
328    /// for a reason other than contention (contention blocks rather than
329    /// failing).
330    #[cfg(unix)]
331    fn acquire(path: &Path) -> std::io::Result<Self> {
332        use nix::fcntl::{Flock, FlockArg};
333        use std::os::unix::fs::OpenOptionsExt as _;
334
335        let file = std::fs::OpenOptions::new()
336            .write(true)
337            .create(true)
338            .truncate(false)
339            .mode(BARK_FILE_MODE)
340            .open(lock_path(path))?;
341
342        // `LockExclusive` blocks; the non-blocking variant would need a
343        // retry loop and a deadline, and an append that waits its turn is
344        // exactly the behaviour wanted here.
345        Flock::lock(file, FlockArg::LockExclusive)
346            .map(|flock| Self { _flock: flock })
347            .map_err(|(_file, errno)| std::io::Error::from(errno))
348    }
349
350    /// Deliberate no-op on Windows: there is no `flock(2)`, shep-core is
351    /// `#![forbid(unsafe_code)]` so `LockFileEx` is not ours to call
352    /// directly, and Windows is shep's 0% tier — every verb prints "not
353    /// yet supported" and exits, so nothing on that platform runs two bark
354    /// writers to serialise. This is a documented gap, not an oversight:
355    /// the day a Windows daemon is real, this is one of the things that
356    /// has to become real with it, and the unique temp name above already
357    /// removes the `ENOENT` half of the race regardless of platform.
358    ///
359    /// # Errors
360    /// Never — the signature matches the unix arm so the caller has one
361    /// shape.
362    /// # Non-unix
363    /// There is no lock here — this returns a handle that holds nothing, and
364    /// two concurrent writers can lose each other's edits. That is sound only
365    /// because every verb refuses on Windows before reaching this code
366    /// (`shep-cli`'s entry point). Anyone un-gating Windows must build the
367    /// lock first: `LockFileEx` is mandatory rather than advisory, so the
368    /// unix design does not port directly, but `OpenOptionsExt::share_mode(0)`
369    /// is safe std and needs a retry loop where `flock` blocks.
370    #[cfg(not(unix))]
371    fn acquire(_path: &Path) -> std::io::Result<Self> {
372        Ok(Self {})
373    }
374}
375
376/// The lock file that guards `path`: its own name with `.lock` appended,
377/// so it sits in `$SHEP_HOME` next to the ring and inherits that
378/// directory's `0700`.
379///
380/// `cfg(unix)` alongside its only caller — [`RingLock::acquire`] is a
381/// documented no-op on Windows, so there is no lock file to name there.
382#[cfg(unix)]
383fn lock_path(path: &Path) -> std::path::PathBuf {
384    let mut name = path
385        .file_name()
386        .map(std::ffi::OsStr::to_os_string)
387        .unwrap_or_default();
388    name.push(".lock");
389    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    /// A representative fired alert. `at_ms` is a caller-chosen tag, not a
397    /// real timestamp — tests use it to tell records apart, not to
398    /// exercise time handling.
399    fn bark_for(subject: &str, at_ms: u64) -> Bark {
400        Bark {
401            at_ms,
402            rule: "watchdog".to_string(),
403            subject: subject.to_string(),
404            message: "restart budget exhausted".to_string(),
405            sinks: vec![SinkOutcome {
406                sink: "discord".to_string(),
407                error: None,
408            }],
409        }
410    }
411
412    /// The serialized length (plus its trailing newline) of one
413    /// `bark_for`-shaped line, measured rather than hard-coded — a
414    /// constant that happened to equal the implementation's own byte
415    /// count would pass for any cap, which is the assertion-against-the-
416    /// same-constant shape this project has shipped before.
417    fn one_bark_len() -> u64 {
418        let line = serde_json::to_string(&bark_for("second", 1)).unwrap();
419        line.len() as u64 + 1
420    }
421
422    /// The eviction, which is the whole reason this is a ring and not an
423    /// append. A cap the test never reaches leaves an append-only file with
424    /// extra code, so the cap here is deliberately small enough that the
425    /// third write MUST evict — and the assertion names the surviving
426    /// subject rather than counting lines, so a ring that evicted the
427    /// NEWEST record would fail here rather than pass on the count.
428    #[test]
429    fn the_ring_drops_the_oldest_bark_to_stay_under_its_cap() {
430        let dir = tempfile::tempdir().unwrap();
431        let path = dir.path().join("barks.jsonl");
432        let cap = 2 * one_bark_len();
433
434        for (i, subject) in ["first", "second", "third"].iter().enumerate() {
435            append(&path, &bark_for(subject, i as u64), cap).unwrap();
436        }
437
438        let barks = read(&path).unwrap();
439        let subjects: Vec<&str> = barks.iter().map(|b| b.subject.as_str()).collect();
440        assert_eq!(subjects, ["second", "third"], "oldest out, newest kept");
441        assert!(
442            std::fs::metadata(&path).unwrap().len() <= cap,
443            "the cap is a cap"
444        );
445    }
446
447    /// fails if a record larger than the whole cap is silently dropped. An
448    /// alert too interesting to fit is exactly the one an operator needs;
449    /// leaving the file over its cap for one record is the cheaper wrong.
450    #[test]
451    fn a_bark_bigger_than_the_cap_is_written_anyway() {
452        let dir = tempfile::tempdir().unwrap();
453        let path = dir.path().join("barks.jsonl");
454        let huge = Bark {
455            message: "x".repeat(4096),
456            ..bark_for("web", 0)
457        };
458        append(&path, &huge, 64).unwrap();
459        assert_eq!(read(&path).unwrap().len(), 1);
460    }
461
462    /// fails if one unparseable line refuses the whole history. That line
463    /// is a writer that died mid-append or a record from a future shep, and
464    /// this file is read during an incident — the surviving records are
465    /// what the reader came for.
466    #[test]
467    fn a_line_that_will_not_parse_costs_one_record_and_not_the_file() {
468        let dir = tempfile::tempdir().unwrap();
469        let path = dir.path().join("barks.jsonl");
470        append(&path, &bark_for("web", 1), DEFAULT_MAX_BYTES).unwrap();
471        std::fs::OpenOptions::new()
472            .append(true)
473            .open(&path)
474            .unwrap()
475            .write_all(b"{\"at_ms\": 2, \"rul\n")
476            .unwrap();
477        append(&path, &bark_for("api", 3), DEFAULT_MAX_BYTES).unwrap();
478
479        let barks = read(&path).unwrap();
480        assert_eq!(
481            barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
482            ["web", "api"]
483        );
484    }
485
486    /// fails if a missing file is an error. No barks yet is the state every
487    /// machine starts in.
488    #[test]
489    fn no_file_yet_is_no_barks_rather_than_a_failure() {
490        let dir = tempfile::tempdir().unwrap();
491        assert_eq!(read(&dir.path().join("nothing.jsonl")).unwrap(), vec![]);
492    }
493
494    /// Env var naming the ring file the re-executed child should append
495    /// to. Its presence is also what tells the child it is a child.
496    #[cfg(unix)]
497    const CHILD_PATH_VAR: &str = "SHEP_BARK_RACE_PATH";
498    /// Env var carrying the child's tag, which it stamps into every
499    /// record's `subject` so the parent can tell the two writers apart.
500    #[cfg(unix)]
501    const CHILD_TAG_VAR: &str = "SHEP_BARK_RACE_TAG";
502    /// How many records each of the two writers appends. Large enough that
503    /// the two read-modify-rename sequences overlap many times over; the
504    /// reviewed reproduction of the lost-update bug used this count and
505    /// lost half the records.
506    #[cfg(unix)]
507    const RECORDS_PER_WRITER: u64 = 200;
508
509    /// Not a test — the child half of
510    /// [`two_writer_processes_do_not_lose_each_other_s_barks`], which
511    /// re-executes this binary with `--ignored --exact` to reach it. It is
512    /// `#[ignore]`d so a normal run never picks it up, and it asserts
513    /// nothing: its job is to hammer [`append`] from a second OS process,
514    /// and the parent does the judging.
515    #[cfg(unix)]
516    #[test]
517    #[ignore = "child process of two_writer_processes_do_not_lose_each_other_s_barks"]
518    fn bark_race_child() {
519        let Ok(path) = std::env::var(CHILD_PATH_VAR) else {
520            panic!("{CHILD_PATH_VAR} unset — this test is only run as a child process");
521        };
522        let tag = std::env::var(CHILD_TAG_VAR).expect("child needs a tag");
523        let path = std::path::PathBuf::from(path);
524
525        for i in 0..RECORDS_PER_WRITER {
526            append(&path, &bark_for(&tag, i), DEFAULT_MAX_BYTES).expect("child append");
527        }
528    }
529
530    /// fails if two writers in two *processes* lose each other's records —
531    /// the whole reason this module lives in shep-core rather than in
532    /// shep-daemon. Two OS processes, not two threads: any in-process
533    /// mutex would serialise threads and prove nothing about the bug,
534    /// which is a read-modify-write across a `rename` with no lock between
535    /// address spaces.
536    ///
537    /// `cfg(unix)` because [`RingLock`] is a documented no-op on Windows —
538    /// shep's 0% tier, where no verb runs and nothing appends twice — so
539    /// asserting this there would assert a guarantee the code openly does
540    /// not make. If a Windows daemon ever becomes real, this gate coming
541    /// off is part of that work.
542    ///
543    /// Without the advisory lock this fails hard rather than flakily —
544    /// measured at roughly half the records surviving, plus one child
545    /// dying outright on `ENOENT` when the writers shared one temp name.
546    #[cfg(unix)]
547    #[test]
548    fn two_writer_processes_do_not_lose_each_other_s_barks() {
549        let dir = tempfile::tempdir().unwrap();
550        let path = dir.path().join("barks.jsonl");
551        let exe = std::env::current_exe().expect("test binary path");
552
553        let children: Vec<_> = ["alpha", "beta"]
554            .iter()
555            .map(|tag| {
556                std::process::Command::new(&exe)
557                    .args(["--exact", "--ignored", "barks::tests::bark_race_child"])
558                    .env(CHILD_PATH_VAR, &path)
559                    .env(CHILD_TAG_VAR, tag)
560                    // Piped, not inherited: a passing run should not
561                    // interleave two child harnesses' output into this
562                    // one's, and a failing child's harness output is
563                    // exactly what the assertion below needs to show.
564                    .stdout(std::process::Stdio::piped())
565                    .spawn()
566                    .expect("spawn writer")
567            })
568            .collect();
569
570        for child in children {
571            let out = child.wait_with_output().expect("wait for writer");
572            assert!(
573                out.status.success(),
574                "a writer process failed: {}\n{}",
575                out.status,
576                String::from_utf8_lossy(&out.stdout)
577            );
578        }
579
580        let barks = read(&path).unwrap();
581        for tag in ["alpha", "beta"] {
582            let mut seen: Vec<u64> = barks
583                .iter()
584                .filter(|b| b.subject == tag)
585                .map(|b| b.at_ms)
586                .collect();
587            seen.sort_unstable();
588            let expected: Vec<u64> = (0..RECORDS_PER_WRITER).collect();
589            assert_eq!(
590                seen, expected,
591                "{tag}'s records did not all survive the other writer"
592            );
593        }
594        assert_eq!(
595            barks.len() as u64,
596            2 * RECORDS_PER_WRITER,
597            "the ring holds records nobody wrote"
598        );
599    }
600
601    /// fails if the ring lands wider than owner-only. `Bark` carries no
602    /// credential today (see [`BARK_FILE_MODE`]'s own doc), but this file
603    /// is still a record of what the shepherd told an outside service, and
604    /// the mode is the one guarantee a reader of this test can check
605    /// without a live process.
606    #[cfg(unix)]
607    #[test]
608    fn append_creates_the_ring_owner_only_on_unix() {
609        use std::os::unix::fs::PermissionsExt;
610        let dir = tempfile::tempdir().unwrap();
611        let path = dir.path().join("barks.jsonl");
612
613        append(&path, &bark_for("web", 0), DEFAULT_MAX_BYTES).unwrap();
614
615        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
616        assert_eq!(
617            mode, 0o600,
618            "barks.jsonl is not the credential file, but stays narrow anyway"
619        );
620    }
621}