crates/veilvoice-verify/tests/release_manifest.rs
what this file is for · veilvoice-verify · 208 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! **Roadmap item 97.** The release job's contents list, read back by the parser
//! that will read it for real.
//!
//! # Why this test exists
//!
//! `CONTENTS.sha256` is the newest link in the chain a verifier follows:
//!
//! ```text
//! SHA256SUMS.asc -> SHA256SUMS -> CONTENTS.sha256 -> each file on disk
//! ```
//!
//! Every other link has tests on both sides of it. This one had a writer that
//! ran once a release, in a job nobody can run on a laptop, and a reader with
//! unit tests over hand-written samples. Two halves that were never introduced
//! to each other, and the failure mode is the worst shape a verifier has: a
//! manifest the reader parses happily and whose paths do not line up with what
//! is actually on disk, so every file reads as `MISSING` and a genuine release
//! is refused. Or worse, paths that line up by accident on one platform.
//!
//! So this builds a release the way the release job does, runs the real
//! generator over it, and checks the real reader against the real extracted
//! files. Nothing here is a stand-in.
//!
//! # Why it is allowed to skip
//!
//! It needs Python, and the `test` job does not install one. Every runner this
//! project uses has one anyway, so the test runs on all three in practice; on a
//! machine without one it returns rather than failing, because "Python is not
//! installed here" is a fact about the machine and not a defect in the release
//! job. The generator is also run by the release workflow itself, which is
//! where its absence would actually matter and where it cannot be absent.
use std::path::{Path, PathBuf};
use std::process::Command;
use veilvoice_verify::check::contents;
/// The repository root, from this test's own location.
fn repository() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("the repository root")
}
/// A Python to run, if this machine has one.
fn python() -> Option<&'static str> {
["python3", "python"]
.into_iter()
.find(|name| Command::new(name).arg("--version").output().is_ok())
}
/// Somewhere to build a release, removed by the caller.
fn room(what: &str) -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("veilvoice-{what}-{stamp:x}"));
std::fs::create_dir_all(&path).expect("a directory to work in");
path
}
/// Build one release directory, the shape the release job stages.
///
/// A program, a second program, a README and a document in a subdirectory:
/// enough that a generator which forgot to recurse, or which wrote paths
/// relative to the wrong place, produces something this notices.
fn stage(root: &Path, name: &str) -> PathBuf {
let release = root.join(name);
std::fs::create_dir_all(release.join("docs")).unwrap();
std::fs::write(release.join("veilvoice"), b"the command line").unwrap();
std::fs::write(release.join("veilvoice-gui"), b"the window").unwrap();
std::fs::write(release.join("README.md"), b"# VeilVoice\n").unwrap();
std::fs::write(release.join("docs/INSTALL.md"), b"install this\n").unwrap();
// **F-102.** A name beginning with a dot. The generator used to normalise
// member paths with `lstrip("./")`, which takes a set of characters rather
// than a prefix, so this file was published as `hidden` and every verifier
// reported it missing on a release that was perfectly sound. One file here
// is cheaper than the round trip that found it.
std::fs::write(release.join(".hidden"), b"a dotfile\n").unwrap();
release
}
/// Whether a program is on this machine at all.
fn have(program: &str) -> bool {
Command::new(program)
.arg("--help")
.output()
.map(|out| out.status.success() || !out.stdout.is_empty() || !out.stderr.is_empty())
.unwrap_or(false)
}
/// The whole seam: stage, archive, generate, parse, extract, check.
///
/// The one test in this file, deliberately. Splitting it would mean staging a
/// release three times to assert three things about the same run, and the
/// property being tested is that the whole sequence agrees with itself.
#[test]
fn what_the_release_job_writes_is_what_the_verifier_reads() {
let Some(python) = python() else {
return;
};
if !have("tar") {
return;
}
let repo = repository();
let work = room("release-manifest");
let dist = work.join("dist");
let staging = work.join("staging");
std::fs::create_dir_all(&dist).unwrap();
std::fs::create_dir_all(&staging).unwrap();
let name = "veilvoice-v0.1.15-linux-x86_64";
stage(&dist, name);
// Archived exactly as the release job does it: from the parent, naming the
// release directory, so the archive carries that directory at its root.
let tarball = staging.join(format!("{name}.tar.gz"));
let made = Command::new("tar")
.arg("-C")
.arg(&dist)
.arg("-czf")
.arg(&tarball)
.arg(name)
.status()
.expect("tar runs");
assert!(made.success(), "the archive could not be built");
// The generator the release job runs. Not a copy of it.
let generator = repo.join("tools/release/contents.py");
assert!(generator.is_file(), "{} is missing", generator.display());
let list = staging.join(contents::CONTENTS);
let ran = Command::new(python)
.arg(&generator)
.arg(&staging)
.arg("-o")
.arg(&list)
.output()
.expect("the generator runs");
assert!(
ran.status.success(),
"the generator failed: {}",
String::from_utf8_lossy(&ran.stderr)
);
// The reader the verifier uses. Not a stand-in for it either.
let text = std::fs::read_to_string(&list).expect("the generator wrote something");
let all = contents::parse(&text).unwrap_or_else(|why| {
panic!("the verifier cannot read what the release job wrote: {why}\n{text}")
});
let section = contents::for_archive(&all, &format!("{name}.tar.gz")).unwrap_or_else(|| {
panic!("the manifest does not mention the archive it was made from:\n{text}")
});
assert_eq!(
section.members.len(),
5,
"every file, including the one in a subdirectory and the dotfile:\n{text}"
);
assert!(
section.members.iter().any(|m| m.path.ends_with("/.hidden")),
"F-102: the dotfile kept its name:\n{text}"
);
// Extracted the way somebody extracts a download, beside the archive, and
// then checked file by file. This is the assertion the whole file is for:
// the paths the generator wrote line up with the paths the reader looks
// for, on this platform, without anybody having agreed on them by hand.
let out = Command::new("tar")
.arg("-C")
.arg(&staging)
.arg("-xzf")
.arg(&tarball)
.status()
.expect("tar runs");
assert!(out.success(), "the archive could not be extracted");
let outcomes = contents::check(&staging, section);
for outcome in &outcomes {
assert!(
outcome.is_good(),
"{}: {:?}\n{text}",
outcome.path,
outcome.verdict
);
}
let sweep = contents::extras(&staging, section);
assert!(
sweep.is_clean(),
"an untouched extraction is not the release: {sweep:?}"
);
// And a changed file is caught, so the pass above is not the check being
// asleep. Written after the clean run rather than instead of it: a test
// that only proves failure cannot tell a working verifier from one that
// refuses everything.
std::fs::write(staging.join(name).join("veilvoice"), b"something else").unwrap();
let after = contents::check(&staging, section);
let changed: Vec<&contents::Outcome> = after.iter().filter(|o| !o.is_good()).collect();
assert_eq!(changed.len(), 1, "exactly the file that was changed");
assert!(changed[0].path.ends_with("veilvoice"), "{:?}", changed[0]);
std::fs::remove_dir_all(&work).ok();
}