Runner evidence: one allowlist sink for status, quarantine only on a decode failure, a displaced set-aside is journaled
Status writes everything it prints from the evidence store, including the
unreadable-store line, through one function (emit): printable ASCII and tab
pass, every other code point is a \u{..} escape and every invalid UTF-8 byte
a \x.. escape. The bidi and C1 class lists are deleted.
The writer reads the store through a seam. An I/O error leaves the store in
place, keeps no evidence and names its kind; the store is set aside only when
bytes that were read still read the same on a second read and do not decode.
A set-aside that displaces an earlier one says so on the journal (last-written
time, no content). Tests pin a failed set-aside, its no-echo, the I/O seam and
the replacement.
Not yet built or run; verified in the next job.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a68df520f
commit
549f4be093
2 changed files with 366 additions and 107 deletions
|
|
@ -4882,22 +4882,11 @@ fn status(
|
|||
writeln!(out, " pre-fencing abort {abort:?}")?;
|
||||
// The step that failed and its output tail live in their
|
||||
// own private store. An unreadable store costs its lines.
|
||||
match runner_evidence::for_transaction(
|
||||
runner_evidence::show_status(
|
||||
out,
|
||||
&runner_evidence_store_path(store_path),
|
||||
&transaction.transaction_id,
|
||||
) {
|
||||
Ok(Some(record)) => {
|
||||
for line in runner_evidence::render(&record) {
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => writeln!(
|
||||
out,
|
||||
" runner evidence unreadable: {}",
|
||||
error.root_cause()
|
||||
)?,
|
||||
}
|
||||
)?;
|
||||
}
|
||||
if let Some(abort) = &transaction.post_fencing_abort {
|
||||
writeln!(out, " post-fencing abort {abort:?}")?;
|
||||
|
|
@ -10707,9 +10696,15 @@ fn runner_failure_journal_line(
|
|||
Ok(kept) => {
|
||||
let aside = match kept {
|
||||
Kept::Stored => String::new(),
|
||||
Kept::AfterSettingAsideAnUnreadableStore => format!(
|
||||
"; an undecodable evidence store was set aside as {}",
|
||||
unreadable_store_path(store_path).display()
|
||||
Kept::SetAside { replaced } => format!(
|
||||
"; an undecodable evidence store was set aside as {}{}",
|
||||
unreadable_store_path(store_path).display(),
|
||||
match replaced {
|
||||
Some(last_written) => format!(
|
||||
", replacing an earlier set-aside store last written at unix millis {last_written}"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
),
|
||||
};
|
||||
format!(
|
||||
|
|
@ -25020,6 +25015,101 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything status prints from the evidence store goes through one
|
||||
/// allowlist, so a hostile build log reaches the terminal as visible
|
||||
/// escapes whatever its class.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn status_prints_hostile_runner_output_as_visible_escapes_only() -> Result<()> {
|
||||
let (world, _tools) = failing_runner_world()?;
|
||||
let ended = tick_until_ended(&world)?;
|
||||
let hostile: Vec<(&str, &str)> = vec![
|
||||
("\u{1b}[2J", "\\u{1b}[2J"),
|
||||
("\u{7}", "\\u{7}"),
|
||||
("\r", "\\u{d}"),
|
||||
("\u{202e}", "\\u{202e}"),
|
||||
("\u{2066}", "\\u{2066}"),
|
||||
("\u{85}", "\\u{85}"),
|
||||
("\u{e0041}", "\\u{e0041}"),
|
||||
("\u{200b}", "\\u{200b}"),
|
||||
("\u{feff}", "\\u{feff}"),
|
||||
];
|
||||
let mut tail: Vec<u8> = Vec::new();
|
||||
for &(raw, _) in &hostile {
|
||||
tail.extend_from_slice(b"a");
|
||||
tail.extend_from_slice(raw.as_bytes());
|
||||
tail.extend_from_slice(b"b\n");
|
||||
}
|
||||
tail.extend_from_slice(b"bad \xff\xc1 bytes\n");
|
||||
runner_evidence::record(
|
||||
&runner_evidence_store_path(&world.state_store),
|
||||
&RunnerFailureRecord::from_failure(
|
||||
&ended.transaction_id,
|
||||
&ended.command_id,
|
||||
&ended.target,
|
||||
&canary_failure(&tail),
|
||||
9,
|
||||
),
|
||||
)?;
|
||||
let printed = printed_status(&world)?;
|
||||
for &(raw, escaped) in &hostile {
|
||||
assert!(!printed.contains(raw), "{escaped} reached the terminal raw");
|
||||
assert!(printed.contains(&format!("a{escaped}b")), "{escaped} missing: {printed}");
|
||||
}
|
||||
assert!(printed.contains("bad \\xff\\xc1 bytes"), "{printed}");
|
||||
let section = printed.find(" failed step").context("no evidence section")?;
|
||||
assert!(printed[section..].is_ascii(), "non-ASCII reached the terminal: {printed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn status_prints_the_unreadable_store_line_escaped() -> Result<()> {
|
||||
let (world, _tools) = failing_runner_world()?;
|
||||
tick_until_ended(&world)?;
|
||||
let evidence = runner_evidence_store_path(&world.state_store);
|
||||
// The decode error of this store quotes the stored subject kind.
|
||||
let mut bytes = std::fs::read(&evidence)?;
|
||||
let at = bytes
|
||||
.windows(4)
|
||||
.position(|window| window == b"step")
|
||||
.context("no step marker in the evidence store")?;
|
||||
bytes[at..at + 4].copy_from_slice(b"\x1b[2J");
|
||||
std::fs::write(&evidence, bytes)?;
|
||||
let printed = printed_status(&world)?;
|
||||
assert_eq!(printed.matches("runner evidence unreadable").count(), 1, "{printed}");
|
||||
assert!(!printed.contains('\u{1b}'), "an escape byte reached the terminal");
|
||||
assert!(printed.contains("\\u{1b}[2J"), "{printed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_journal_line_names_a_displaced_set_aside_store_by_time_only() -> Result<()> {
|
||||
let (transaction, _) = terminal_transaction_with_command("ghostlight")?;
|
||||
let failure = canary_failure(RUNNER_CANARY.as_bytes());
|
||||
let path = Path::new("/var/lib/gamecult/idunn/runner-evidence.cc");
|
||||
let first = runner_failure_journal_line(
|
||||
&transaction,
|
||||
&failure,
|
||||
path,
|
||||
&Ok(Kept::SetAside { replaced: None }),
|
||||
);
|
||||
assert!(first.contains("was set aside as"), "{first}");
|
||||
assert!(!first.contains("replacing"), "{first}");
|
||||
let second = runner_failure_journal_line(
|
||||
&transaction,
|
||||
&failure,
|
||||
path,
|
||||
&Ok(Kept::SetAside { replaced: Some(1234) }),
|
||||
);
|
||||
assert!(
|
||||
second.contains("replacing an earlier set-aside store last written at unix millis 1234"),
|
||||
"{second}"
|
||||
);
|
||||
assert!(!second.contains(RUNNER_CANARY));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_store_types_are_unchanged_by_a_runner_failure() -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
//!
|
||||
//! The tail is raw runner output. It is kept unredacted and readable by root
|
||||
//! alone (mode 0600): root can already read every secret the runner was given.
|
||||
//! It never enters an error, a transaction record or the journal; the render
|
||||
//! below escapes control characters because the status reader is a terminal.
|
||||
//! It never enters an error, a transaction record or the journal. The status
|
||||
//! reader is a terminal, so everything it prints from this store goes through
|
||||
//! one allowlist sink (`emit`): printable ASCII passes, all else is escaped.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -186,9 +187,11 @@ fn snapshot(path: &Path) -> Result<(Vec<CultCacheEnvelope>, Vec<RunnerFailureRec
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Kept {
|
||||
Stored,
|
||||
/// The store could not be decoded; it was moved to
|
||||
/// `unreadable_store_path` and the record opened a fresh one.
|
||||
AfterSettingAsideAnUnreadableStore,
|
||||
/// The store held bytes that do not decode; it was moved to
|
||||
/// `unreadable_store_path` and the record opened a fresh one. `replaced`
|
||||
/// is the last-written time (unix milliseconds) of an earlier set-aside
|
||||
/// store this one displaced, which is gone: one slot, and it says so.
|
||||
SetAside { replaced: Option<u64> },
|
||||
}
|
||||
|
||||
/// Where an undecodable store is kept. One slot: the next one replaces it, so
|
||||
|
|
@ -197,27 +200,62 @@ pub(crate) fn unreadable_store_path(path: &Path) -> PathBuf {
|
|||
path.with_file_name("runner-evidence.unreadable.cc")
|
||||
}
|
||||
|
||||
/// An undecodable store (a corrupt file, or one a newer Idunn wrote) must not
|
||||
/// stop later failures from keeping their evidence. Move it aside, root-only,
|
||||
/// over the previous one. Nothing of its content is named: a decode error can
|
||||
/// quote stored values. A store that cannot even be read is left in place.
|
||||
fn set_aside_unreadable(path: &Path) -> Result<()> {
|
||||
std::fs::read(path).context("the runner evidence store could not be read")?;
|
||||
/// Moves the store over the previous set-aside one, root-only, and reports
|
||||
/// what it displaced. Nothing of the store's content is named: a decode error
|
||||
/// can quote stored values, and none is carried here.
|
||||
fn set_aside_unreadable(path: &Path) -> Result<Option<u64>> {
|
||||
let aside = unreadable_store_path(path);
|
||||
let replaced = std::fs::symlink_metadata(&aside).ok().map(|earlier| {
|
||||
earlier
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |since| since.as_millis() as u64)
|
||||
});
|
||||
std::fs::rename(path, &aside)
|
||||
.context("the runner evidence store is undecodable and could not be set aside")?;
|
||||
keep_private(&aside)
|
||||
keep_private(&aside)?;
|
||||
Ok(replaced)
|
||||
}
|
||||
|
||||
/// How the writer reads the store's bytes; tests inject an I/O error here.
|
||||
type ReadStore<'a> = &'a dyn Fn(&Path) -> std::io::Result<Vec<u8>>;
|
||||
|
||||
fn unreadable_fault(error: &std::io::Error) -> anyhow::Error {
|
||||
anyhow::anyhow!("the runner evidence store could not be read: {}", error.kind())
|
||||
}
|
||||
|
||||
/// The one writer. Upsert by transaction id; past `RUNNER_EVIDENCE_RECORDS` the
|
||||
/// oldest records go in the same exchange. The file ends at mode 0600. A store
|
||||
/// that cannot be decoded is set aside first and the record opens a new one.
|
||||
/// oldest records go in the same exchange. The file ends at mode 0600.
|
||||
///
|
||||
/// A store is set aside only when bytes that were read do not decode. An I/O
|
||||
/// error leaves the store in place, keeps no evidence for that failure, and
|
||||
/// the error names its kind for the journal.
|
||||
pub(crate) fn record(path: &Path, record: &RunnerFailureRecord) -> Result<Kept> {
|
||||
record_reading(path, record, &|path| std::fs::read(path))
|
||||
}
|
||||
|
||||
fn record_reading(path: &Path, record: &RunnerFailureRecord, read: ReadStore) -> Result<Kept> {
|
||||
let first = match read(path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(unreadable_fault(&error)),
|
||||
};
|
||||
let (envelopes, records, how) = match snapshot(path) {
|
||||
Ok((envelopes, records)) => (envelopes, records, Kept::Stored),
|
||||
Err(_) => {
|
||||
set_aside_unreadable(path)?;
|
||||
(Vec::new(), Vec::new(), Kept::AfterSettingAsideAnUnreadableStore)
|
||||
let Some(first) = first else {
|
||||
bail!("the runner evidence store could not be decoded");
|
||||
};
|
||||
// Undecodable only if the same bytes are still there to read: a
|
||||
// second read that fails, or finds other bytes, is not a verdict.
|
||||
match read(path) {
|
||||
Ok(second) if second == first => {}
|
||||
Ok(_) => bail!("the runner evidence store changed while it was being read"),
|
||||
Err(error) => return Err(unreadable_fault(&error)),
|
||||
}
|
||||
let replaced = set_aside_unreadable(path)?;
|
||||
(Vec::new(), Vec::new(), Kept::SetAside { replaced })
|
||||
}
|
||||
};
|
||||
let mut kept: Vec<(u64, CultCacheEnvelope)> = envelopes
|
||||
|
|
@ -275,58 +313,81 @@ pub(crate) fn for_transaction(
|
|||
.find(|stored| stored.transaction_id == transaction_id))
|
||||
}
|
||||
|
||||
/// The record as `idunn status --command` prints it. The store keeps raw
|
||||
/// bytes; every control character except newline and tab is escaped here, so
|
||||
/// build output cannot drive the operator's terminal.
|
||||
pub(crate) fn render(record: &RunnerFailureRecord) -> Vec<String> {
|
||||
/// The record as `idunn status --command` shows it, as raw lines. They are
|
||||
/// not text yet: `show_status` is the only caller, and `emit` the only
|
||||
/// writer.
|
||||
fn render(record: &RunnerFailureRecord) -> Vec<Vec<u8>> {
|
||||
let noun = match record.subject_kind {
|
||||
SubjectKind::Step => "step",
|
||||
SubjectKind::ExternalInput => "external input",
|
||||
};
|
||||
let mut lines = vec![
|
||||
format!(" failed {noun} {}", escape(&record.subject_id)),
|
||||
format!(" runner {}", escape(&record.runner)),
|
||||
format!(" status {}", runner_status(record.exit_code, record.signal)),
|
||||
let mut lines: Vec<Vec<u8>> = vec![
|
||||
format!(" failed {noun} {}", record.subject_id).into_bytes(),
|
||||
format!(" runner {}", record.runner).into_bytes(),
|
||||
format!(" status {}", runner_status(record.exit_code, record.signal)).into_bytes(),
|
||||
];
|
||||
for (stream, tail, total) in [
|
||||
("stdout", &record.stdout_tail, record.stdout_total_bytes),
|
||||
("stderr", &record.stderr_tail, record.stderr_total_bytes),
|
||||
] {
|
||||
lines.push(format!(
|
||||
" {stream} (last {} of {total} bytes)",
|
||||
tail.len()
|
||||
));
|
||||
let text = escape(&String::from_utf8_lossy(tail));
|
||||
let text = text.strip_suffix('\n').unwrap_or(&text);
|
||||
lines.push(format!(" {stream} (last {} of {total} bytes)", tail.len()).into_bytes());
|
||||
let text = tail.strip_suffix(b"\n").unwrap_or(tail);
|
||||
if !text.is_empty() {
|
||||
lines.extend(text.split('\n').map(|line| format!(" | {line}")));
|
||||
lines.extend(text.split(|byte| *byte == b'\n').map(|line| {
|
||||
let mut shown = b" | ".to_vec();
|
||||
shown.extend_from_slice(line);
|
||||
shown
|
||||
}));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Format characters that reorder what a terminal shows: the set the mind's
|
||||
/// Title rule refuses.
|
||||
fn is_bidi_control(character: char) -> bool {
|
||||
matches!(
|
||||
character,
|
||||
'\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
|
||||
)
|
||||
/// What `idunn status --command` prints for a transaction's runner evidence:
|
||||
/// its record, or one line naming why the store is unreadable. Nothing is
|
||||
/// written when the transaction has no record.
|
||||
pub(crate) fn show_status(
|
||||
out: &mut impl std::io::Write,
|
||||
store: &Path,
|
||||
transaction_id: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let lines = match for_transaction(store, transaction_id) {
|
||||
Ok(Some(record)) => render(&record),
|
||||
Ok(None) => return Ok(()),
|
||||
Err(error) => {
|
||||
vec![format!(" runner evidence unreadable: {}", error.root_cause()).into_bytes()]
|
||||
}
|
||||
};
|
||||
emit(out, &lines)
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut escaped = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
if (character.is_control() || is_bidi_control(character))
|
||||
&& character != '\n'
|
||||
&& character != '\t'
|
||||
{
|
||||
escaped.push_str(&format!("\\u{{{:x}}}", u32::from(character)));
|
||||
} else {
|
||||
escaped.push(character);
|
||||
/// The one sink: every byte the status read prints from the evidence store,
|
||||
/// or from an error about it, is written here and nowhere else. An allowlist:
|
||||
/// printable ASCII and tab pass; every other code point is a visible
|
||||
/// `\u{..}` escape and every invalid UTF-8 byte a `\x..` escape. A newline
|
||||
/// inside a line is escaped too: this function writes the only ones.
|
||||
fn emit(out: &mut impl std::io::Write, lines: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
for line in lines {
|
||||
out.write_all(escape(line).as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn escape(bytes: &[u8]) -> String {
|
||||
let mut shown = String::with_capacity(bytes.len());
|
||||
for chunk in bytes.utf8_chunks() {
|
||||
for character in chunk.valid().chars() {
|
||||
match character {
|
||||
' '..='~' | '\t' => shown.push(character),
|
||||
_ => shown.push_str(&format!("\\u{{{:x}}}", u32::from(character))),
|
||||
}
|
||||
}
|
||||
for byte in chunk.invalid() {
|
||||
shown.push_str(&format!("\\x{byte:02x}"));
|
||||
}
|
||||
}
|
||||
escaped
|
||||
shown
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
|
@ -347,6 +408,17 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// What status writes for a record, through the one sink.
|
||||
fn shown(record: &RunnerFailureRecord) -> String {
|
||||
let mut written = Vec::new();
|
||||
emit(&mut written, &render(record)).expect("a Vec takes any write");
|
||||
String::from_utf8(written).expect("the sink writes ASCII only")
|
||||
}
|
||||
|
||||
fn shown_lines(record: &RunnerFailureRecord) -> Vec<String> {
|
||||
shown(record).lines().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn record_at(transaction: &str, now: u64) -> RunnerFailureRecord {
|
||||
RunnerFailureRecord::from_failure(
|
||||
transaction,
|
||||
|
|
@ -435,7 +507,7 @@ mod tests {
|
|||
stored.stdout_total_bytes = 5000;
|
||||
stored.stderr_tail = b"bad\r\x07".to_vec();
|
||||
stored.stderr_total_bytes = 6;
|
||||
let lines = render(&stored);
|
||||
let lines = shown_lines(&stored);
|
||||
let text = lines.join("\n");
|
||||
assert!(!text.contains('\u{1b}') && !text.contains('\0') && !text.contains('\r'));
|
||||
assert!(!text.contains('\u{7}'));
|
||||
|
|
@ -469,7 +541,7 @@ mod tests {
|
|||
let mut stored = record_at("tx-a", 1);
|
||||
stored.stdout_tail = Vec::new();
|
||||
stored.stdout_total_bytes = 0;
|
||||
let lines = render(&stored);
|
||||
let lines = shown_lines(&stored);
|
||||
assert!(lines.contains(&" stdout (last 0 of 0 bytes)".to_owned()));
|
||||
assert!(!lines.iter().any(|line| line == " | "));
|
||||
}
|
||||
|
|
@ -518,7 +590,7 @@ mod tests {
|
|||
let damage = b"\xc1 not a store \x00 damage";
|
||||
std::fs::write(&path, damage)?;
|
||||
let kept = record(&path, &record_at("tx-a", 5))?;
|
||||
assert_eq!(kept, Kept::AfterSettingAsideAnUnreadableStore);
|
||||
assert_eq!(kept, Kept::SetAside { replaced: None });
|
||||
// Kept, not deleted: the damage is where the journal says, root-only.
|
||||
let aside = unreadable_store_path(&path);
|
||||
assert_eq!(std::fs::read(&aside)?, damage);
|
||||
|
|
@ -544,7 +616,7 @@ mod tests {
|
|||
assert!(for_transaction(&path, "tx-future").is_err());
|
||||
assert_eq!(
|
||||
record(&path, &record_at("tx-a", 5))?,
|
||||
Kept::AfterSettingAsideAnUnreadableStore
|
||||
Kept::SetAside { replaced: None }
|
||||
);
|
||||
assert_eq!(std::fs::read(unreadable_store_path(&path))?, before);
|
||||
assert!(for_transaction(&path, "tx-a")?.is_some());
|
||||
|
|
@ -589,48 +661,145 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn status_escapes_bidi_controls_in_tails_and_in_names() {
|
||||
for bidi in [
|
||||
'\u{61c}', '\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}',
|
||||
'\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
|
||||
] {
|
||||
let mut stored = record_at("tx-a", 1);
|
||||
let tail = format!("ok {bidi}gpj.exe\n");
|
||||
stored.stdout_tail = tail.clone().into_bytes();
|
||||
stored.stdout_total_bytes = tail.len() as u64;
|
||||
stored.stderr_tail = tail.clone().into_bytes();
|
||||
stored.stderr_total_bytes = tail.len() as u64;
|
||||
stored.subject_id = format!("step{bidi}x");
|
||||
stored.runner = format!("rust{bidi}");
|
||||
let text = render(&stored).join("\n");
|
||||
let escaped = format!("\\u{{{:x}}}", bidi as u32);
|
||||
assert!(!text.contains(bidi), "U+{:04X} reached the terminal", bidi as u32);
|
||||
// Escaped in every place it can appear: two tails, the subject, the runner.
|
||||
assert_eq!(text.matches(&escaped).count(), 4, "{text}");
|
||||
fn every_code_point_but_printable_ascii_is_a_visible_escape() {
|
||||
// The allowlist, exhaustively: no class list decides anything.
|
||||
for code in 0..=0x10ffffu32 {
|
||||
let Some(character) = char::from_u32(code) else {
|
||||
continue;
|
||||
};
|
||||
let text = character.to_string();
|
||||
let shown = escape(text.as_bytes());
|
||||
if matches!(character, ' '..='~' | '\t') {
|
||||
assert_eq!(shown, text, "U+{code:04X} should pass");
|
||||
} else {
|
||||
assert_eq!(shown, format!("\\u{{{code:x}}}"), "U+{code:04X}");
|
||||
}
|
||||
}
|
||||
// Format characters either side of the set pass.
|
||||
let mut stored = record_at("tx-a", 1);
|
||||
stored.stdout_tail = "caf\u{e9} \u{200d}\u{202f}\u{2065}\u{206a}\n".as_bytes().to_vec();
|
||||
stored.stdout_total_bytes = stored.stdout_tail.len() as u64;
|
||||
let text = render(&stored).join("\n");
|
||||
assert!(text.contains("caf\u{e9} \u{200d}\u{202f}\u{2065}\u{206a}"), "{text}");
|
||||
// Invalid UTF-8 is escaped byte by byte; valid text round it passes.
|
||||
assert_eq!(escape(b"a\xc1\xffb\xe2\x82"), "a\\xc1\\xffb\\xe2\\x82");
|
||||
assert_eq!(escape(b"ok \xe2\x82\xac"), "ok \\u{20ac}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_escapes_the_c1_controls() {
|
||||
for code in 0x80u32..=0x9f {
|
||||
let character = char::from_u32(code).unwrap();
|
||||
let mut stored = record_at("tx-a", 1);
|
||||
stored.stdout_tail = format!("a{character}b\n").into_bytes();
|
||||
stored.stdout_total_bytes = stored.stdout_tail.len() as u64;
|
||||
let text = render(&stored).join("\n");
|
||||
assert!(!text.contains(character), "U+{code:04X} reached the terminal");
|
||||
assert!(text.contains(&format!("a\\u{{{code:x}}}b")), "U+{code:04X}: {text}");
|
||||
}
|
||||
// U+00A0 is not a control character and passes.
|
||||
fn a_newline_inside_a_line_cannot_forge_a_line() {
|
||||
let mut stored = record_at("tx-a", 1);
|
||||
stored.stdout_tail = "a\u{a0}b\n".as_bytes().to_vec();
|
||||
stored.stdout_total_bytes = stored.stdout_tail.len() as u64;
|
||||
assert!(render(&stored).join("\n").contains("a\u{a0}b"));
|
||||
stored.subject_id = "build\n status exit status 0".into();
|
||||
let lines = shown_lines(&stored);
|
||||
assert_eq!(lines[0], " failed step build\\u{a} status exit status 0");
|
||||
assert_eq!(
|
||||
lines.iter().filter(|line| line.starts_with(" status ")).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// A store whose first record is well-formed except that its subject kind
|
||||
/// holds `marker`: the decode error quotes it.
|
||||
fn store_with_hostile_subject_kind(path: &Path, marker: &[u8; 4]) -> Result<()> {
|
||||
record(path, &record_at("tx-a", 1))?;
|
||||
let mut bytes = std::fs::read(path)?;
|
||||
let at = bytes
|
||||
.windows(4)
|
||||
.position(|window| window == b"step")
|
||||
.context("no step marker")?;
|
||||
bytes[at..at + 4].copy_from_slice(marker);
|
||||
std::fs::write(path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unreadable_store_line_is_escaped_with_the_rest() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = runner_evidence_store_path(&temp.path().join("control.cc"));
|
||||
store_with_hostile_subject_kind(&path, b"\x1b[2J")?;
|
||||
let mut written = Vec::new();
|
||||
show_status(&mut written, &path, "tx-a")?;
|
||||
assert!(!written.contains(&0x1b), "an escape byte reached the terminal");
|
||||
assert!(written.iter().all(|byte| matches!(byte, b' '..=b'~' | b'\t' | b'\n')));
|
||||
let text = String::from_utf8(written)?;
|
||||
assert!(text.starts_with(" runner evidence unreadable: "), "{text}");
|
||||
assert!(text.contains("\\u{1b}[2J"), "the quoted value was not shown escaped: {text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_set_aside_names_itself_keeps_the_store_and_echoes_nothing() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = runner_evidence_store_path(&temp.path().join("control.cc"));
|
||||
store_with_hostile_subject_kind(&path, b"CNRY")?;
|
||||
let damage = std::fs::read(&path)?;
|
||||
// Sanity: this damage decodes to an error that quotes the canary.
|
||||
assert!(format!("{:#}", snapshot(&path).unwrap_err()).contains("CNRY"));
|
||||
// A non-empty directory where the aside goes: rename fails even as root.
|
||||
let aside = unreadable_store_path(&path);
|
||||
std::fs::create_dir(&aside)?;
|
||||
std::fs::write(aside.join("occupied"), b"x")?;
|
||||
let error = record(&path, &record_at("tx-b", 2)).unwrap_err();
|
||||
for text in [format!("{error:#}"), format!("{error:?}")] {
|
||||
assert!(text.contains("could not be set aside"), "{text}");
|
||||
assert!(!text.contains("CNRY"), "stored content reached the error: {text}");
|
||||
}
|
||||
// The failure cost nothing: the store is as it was, with no evidence
|
||||
// written over it.
|
||||
assert_eq!(std::fs::read(&path)?, damage);
|
||||
assert!(aside.join("occupied").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_io_error_leaves_the_store_in_place_and_keeps_no_evidence() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = runner_evidence_store_path(&temp.path().join("control.cc"));
|
||||
record(&path, &record_at("tx-a", 1))?;
|
||||
let before = std::fs::read(&path)?;
|
||||
let denied = || std::io::Error::from(std::io::ErrorKind::PermissionDenied);
|
||||
// The first read fails on a healthy store.
|
||||
let error = record_reading(&path, &record_at("tx-b", 2), &|_| Err(denied())).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("permission denied"), "{error:#}");
|
||||
assert_eq!(std::fs::read(&path)?, before, "the store changed");
|
||||
assert!(!unreadable_store_path(&path).exists());
|
||||
assert!(for_transaction(&path, "tx-b")?.is_none());
|
||||
// The second read, the one that confirms the bytes do not decode, fails.
|
||||
std::fs::write(&path, b"\xc1 not a store")?;
|
||||
let calls = std::cell::Cell::new(0usize);
|
||||
let read = |path: &Path| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() == 2 {
|
||||
return Err(denied());
|
||||
}
|
||||
std::fs::read(path)
|
||||
};
|
||||
let error = record_reading(&path, &record_at("tx-b", 2), &read).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("permission denied"), "{error:#}");
|
||||
assert_eq!(std::fs::read(&path)?, b"\xc1 not a store");
|
||||
assert!(!unreadable_store_path(&path).exists());
|
||||
// Bytes that changed between the reads are no verdict either.
|
||||
let calls = std::cell::Cell::new(0usize);
|
||||
let read = |path: &Path| -> std::io::Result<Vec<u8>> {
|
||||
calls.set(calls.get() + 1);
|
||||
let mut bytes = std::fs::read(path)?;
|
||||
bytes.push(calls.get() as u8);
|
||||
Ok(bytes)
|
||||
};
|
||||
assert!(record_reading(&path, &record_at("tx-b", 2), &read).is_err());
|
||||
assert_eq!(std::fs::read(&path)?, b"\xc1 not a store");
|
||||
assert!(!unreadable_store_path(&path).exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_set_aside_says_it_replaced_the_first() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = runner_evidence_store_path(&temp.path().join("control.cc"));
|
||||
std::fs::write(&path, b"\xc1 first")?;
|
||||
assert_eq!(
|
||||
record(&path, &record_at("tx-a", 1))?,
|
||||
Kept::SetAside { replaced: None }
|
||||
);
|
||||
std::fs::write(&path, b"\xc1 second")?;
|
||||
let Kept::SetAside { replaced: Some(_) } = record(&path, &record_at("tx-b", 2))? else {
|
||||
bail!("a displaced set-aside store went unreported");
|
||||
};
|
||||
assert_eq!(std::fs::read(unreadable_store_path(&path))?, b"\xc1 second");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue