Files
pkh/src/context/mod.rs
T
vhaudiquet 4c26122357 test: keep cargo test output quiet with per-test logs and a failure matrix
cargo test used to be unreadable: subprocesses inherited the terminal, so
dpkg-buildpackage, apt and configure output interleaved with the harness
summary, and env_logger lines from parallel tests crossed each other.

New test_support module, compiled into test binaries only (inert stubs
otherwise) and initialized before main via .init_array:

- all log output goes to target/pkh-test-logs/<test>.log, one file per
  test thread, so concurrent tests never interleave
- context-launched commands are captured line by line into the same file
  (driver-level wrapper); test-code spawns use run_logged()
- a panic hook records failures and an atexit callback prints a matrix
  (test name, panic location, message, log path) after the libtest
  summary; tests panicking on purpose can opt out with a guard

Also fixes two test bugs found on the way:

- diff_checkbuilddeps_matrix compared dpkg-checkbuilddeps diagnostics
  against English messages without pinning the locale
- run_source_build in differential tests now captures output like the
  live-UI path does
2026-09-17 15:18:23 +02:00

406 lines
15 KiB
Rust

mod api;
pub(crate) mod capture;
mod local;
mod manager;
mod schroot;
pub(crate) mod shell;
mod ssh;
mod unshare;
pub use api::{Context, ContextCommand, ContextConfig, LineSink, Stream};
// The driver trait is implementation detail of the context API; it is only
// needed crate-internally (test-run capture wrapper), so keep it out of the
// public surface (and its documentation requirement).
pub(crate) use api::ContextDriver;
pub use manager::ContextManager;
use std::sync::Arc;
/// Obtain global context manager
pub fn manager() -> &'static ContextManager {
&manager::MANAGER
}
/// Obtain current context
pub fn current() -> Arc<Context> {
manager::MANAGER.current()
}
/// Version-control metadata directories that must never leak into a
/// prepared build tree.
///
/// Their presence flips autotools' "building from VCS" detection (e.g. GNU
/// hello's `BUILD_FROM_GIT`, triggered by a `.git` directory next to
/// `configure.ac`), which activates maintainer-only regeneration rules
/// requiring tools that are deliberately not declared as build-dependencies
/// (e.g. `help2man`). Source packages produced by dpkg-source never contain
/// them, so package builds must not see them either.
pub(crate) fn is_vcs_dir_name(name: &std::ffi::OsStr) -> bool {
matches!(
name.to_str(),
Some(".git") | Some(".hg") | Some(".svn") | Some(".bzr") | Some("CVS")
)
}
/// Recursively remove version-control metadata directories below `root`.
///
/// Used after an overlay mount, where the source tree is exposed verbatim
/// and entries cannot be filtered during the copy.
pub(crate) fn prune_vcs_dirs(root: &std::path::Path) -> std::io::Result<()> {
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
// symlink_metadata: never follow symlinks while pruning.
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
if meta.is_dir() {
if is_vcs_dir_name(&entry.file_name()) {
log::debug!("Removing VCS metadata from build tree: {}", path.display());
std::fs::remove_dir_all(&path)?;
} else {
pending.push(path);
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn test_ensure_available_local() {
let temp_dir = tempfile::tempdir().unwrap();
let src_file = temp_dir.path().join("src.txt");
fs::write(&src_file, "local").unwrap();
let ctx = Context::new(ContextConfig::Local).unwrap();
let dest = ctx.ensure_available(&src_file, "/tmp").unwrap();
// Should return a path that exists and has the same content
assert!(dest.exists());
let content = fs::read_to_string(&dest).unwrap();
assert_eq!(content, "local");
// The dest should be in the /tmp directory
assert!(dest.starts_with("/tmp"));
}
#[test]
fn test_context_manager_crud() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let mgr = ContextManager::with_path(path.clone());
// Add
let ssh_cfg = ContextConfig::Ssh {
host: "10.0.0.1".into(),
user: Some("admin".into()),
port: Some(2222),
};
mgr.add_context("myserver", ssh_cfg.clone()).unwrap();
assert!(mgr.list_contexts().contains(&"myserver".to_string()));
// List
let list = mgr.list_contexts();
assert!(list.contains(&"myserver".to_string()));
// Set Current
mgr.set_current("myserver").unwrap();
assert_eq!(mgr.current_name(), "myserver".to_string());
// Remove
mgr.remove_context("myserver").unwrap();
assert!(!mgr.list_contexts().contains(&"myserver".to_string()));
}
#[test]
fn test_persistence() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("contexts.json");
{
let mgr = ContextManager::with_path(config_path.clone());
mgr.add_context("persistent", ContextConfig::Local).unwrap();
mgr.set_current("persistent").unwrap();
}
let content = fs::read_to_string(&config_path).unwrap();
let loaded_config: super::manager::Config = serde_json::from_str(&content).unwrap();
assert_eq!(loaded_config.context, "persistent".to_string());
assert!(loaded_config.contexts.contains_key("persistent"));
}
#[test]
fn test_context_fallback_on_removal() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let mgr = ContextManager::with_path(path);
// 1. Add and set a context
mgr.add_context("temp", ContextConfig::Local).unwrap();
mgr.set_current("temp").unwrap();
assert_eq!(mgr.current_name(), "temp");
// 2. Remove it
mgr.remove_context("temp").unwrap();
// 3. Should have fallen back to local
assert_eq!(mgr.current_name(), "local");
assert!(mgr.list_contexts().contains(&"local".to_string()));
}
/// `set_current` on a context whose configuration carries a `parent`
/// must complete without deadlocking: building the Context resolves the
/// parent chain, which used to re-enter the config lock while
/// `set_current` still held it for writing (a guaranteed deadlock on a
/// std::sync::RwLock, same-thread write-then-read).
#[test]
fn test_set_current_parented_context_no_deadlock() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = Arc::new(ContextManager::with_path(temp_file.path().to_path_buf()));
mgr.add_context("base", ContextConfig::Local).unwrap();
mgr.add_context(
"child",
ContextConfig::Schroot {
name: "testchroot".to_string(),
parent: Some("base".to_string()),
},
)
.unwrap();
// Run with a timeout so a regression fails fast instead of hanging
// the test binary forever.
let (tx, rx) = std::sync::mpsc::channel();
let worker = {
let mgr = mgr.clone();
std::thread::spawn(move || {
let result = mgr.set_current("child");
tx.send(()).expect("receiver still waiting");
result
})
};
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(()) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("set_current() deadlocked building a parented context");
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("set_current() thread panicked before completing");
}
}
worker.join().unwrap().unwrap();
assert_eq!(mgr.current_name(), "child");
}
/// A context referencing a missing parent must produce an error, not a
/// panic (the parent lookup used to `.expect()`).
#[test]
fn test_set_current_dangling_parent_errors() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
mgr.add_context(
"orphan",
ContextConfig::Unshare {
path: "/some/chroot".to_string(),
parent: Some("missing".to_string()),
},
)
.unwrap();
let err = mgr.set_current("orphan").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
// Nothing was committed: the current context is unchanged.
assert_eq!(mgr.current_name(), "local");
}
/// A parent cycle in a hand-edited configuration must be rejected with
/// an error instead of recursing until the stack overflows.
#[test]
fn test_set_current_parent_cycle_errors() {
let temp_file = NamedTempFile::new().unwrap();
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
mgr.add_context(
"a",
ContextConfig::Schroot {
name: "schroot-a".to_string(),
parent: Some("b".to_string()),
},
)
.unwrap();
mgr.add_context(
"b",
ContextConfig::Unshare {
path: "/chroot-b".to_string(),
parent: Some("a".to_string()),
},
)
.unwrap();
let err = mgr.set_current("a").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
/// A corrupt contexts.json must not take the manager down:
/// `load_config` falls back to the default (local-only) configuration,
/// keeps the corrupt file in place and backs it up to contexts.json.bak
/// so a later save cannot silently destroy its content.
#[test]
fn test_load_config_corrupt_file_falls_back_and_backs_up() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("contexts.json");
let garbage = "{ this is definitely not valid json";
fs::write(&config_path, garbage).unwrap();
let config = ContextManager::load_config(&config_path);
// Falls back to the default (local-only) configuration...
assert_eq!(config.context, "local");
assert!(config.contexts.contains_key("local"));
// ...preserving the corrupt file via the backup, original untouched.
let backup_path = temp_dir.path().join("contexts.json.bak");
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
assert_eq!(fs::read_to_string(&config_path).unwrap(), garbage);
// A subsequent save replaces only the original, never the backup.
let mgr = ContextManager::with_path(config_path.clone());
mgr.add_context("newctx", ContextConfig::Local).unwrap();
let rewritten = fs::read_to_string(&config_path).unwrap();
serde_json::from_str::<super::manager::Config>(&rewritten).unwrap();
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
}
/// A missing contexts.json yields the default configuration and writes
/// nothing (no file, no backup) until an explicit save.
#[test]
fn test_load_config_missing_file_defaults() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("contexts.json");
let config = ContextManager::load_config(&config_path);
assert_eq!(config.context, "local");
assert!(config.contexts.contains_key("local"));
assert!(!config_path.exists());
assert!(!temp_dir.path().join("contexts.json.bak").exists());
}
#[test]
fn test_context_file_ops() {
let temp_dir = tempfile::tempdir().unwrap();
let ctx = Context::new(ContextConfig::Local).unwrap();
let file_path = temp_dir.path().join("test.txt");
let content = "hello world";
// 1. Write file
ctx.write_file(&file_path, content).unwrap();
// 2. Read file
let read_content = ctx.read_file(&file_path).unwrap();
assert_eq!(read_content, content);
// 3. Copy path
let dest_path = temp_dir.path().join("test_copy.txt");
ctx.copy_path(&file_path, &dest_path).unwrap();
let copied_content = ctx.read_file(&dest_path).unwrap();
assert_eq!(copied_content, content);
// 4. Recursive copy
let subdir = temp_dir.path().join("subdir");
std::fs::create_dir_all(&subdir).unwrap();
let subfile = subdir.join("subfile.txt");
ctx.write_file(&subfile, "subcontent").unwrap();
let subdir_copy = temp_dir.path().join("subdir_copy");
ctx.copy_path(&subdir, &subdir_copy).unwrap();
assert!(subdir_copy.exists());
assert!(subdir_copy.join("subfile.txt").exists());
assert_eq!(
ctx.read_file(&subdir_copy.join("subfile.txt")).unwrap(),
"subcontent"
);
}
/// Copying a tree containing a dangling symlink must reproduce the link
/// itself rather than failing to follow it (which previously surfaced as
/// a bare "No such file or directory (os error 2)").
#[test]
fn test_context_copy_preserves_dangling_symlink() {
use std::os::unix::fs::symlink;
let temp_dir = tempfile::tempdir().unwrap();
let ctx = Context::new(ContextConfig::Local).unwrap();
let src_dir = temp_dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
// A regular file alongside the symlink, to ensure normal copies still work.
std::fs::write(src_dir.join("real.txt"), "data").unwrap();
// A dangling symlink pointing to a non-existent target.
symlink("/nonexistent/target", src_dir.join("dangling")).unwrap();
let dest_dir = temp_dir.path().join("dest");
ctx.copy_path(&src_dir, &dest_dir).unwrap();
// The regular file was copied.
assert_eq!(ctx.read_file(&dest_dir.join("real.txt")).unwrap(), "data");
// The symlink was reproduced as a symlink (not followed).
let meta = std::fs::symlink_metadata(dest_dir.join("dangling")).unwrap();
assert!(meta.file_type().is_symlink());
assert_eq!(
std::fs::read_link(dest_dir.join("dangling")).unwrap(),
std::path::Path::new("/nonexistent/target")
);
}
/// Copying a source tree into a build context must strip version-control
/// metadata directories at any depth: their presence flips autotools
/// "building from git" detection and activates maintainer-only rules
/// needing undeclared tools (e.g. help2man for GNU hello's man page).
#[test]
fn test_ensure_available_strips_vcs_metadata() {
let temp_dir = tempfile::tempdir().unwrap();
let src_root = temp_dir.path().join("pkg");
fs::create_dir_all(src_root.join(".git/objects")).unwrap();
fs::write(src_root.join(".git/HEAD"), "ref: refs/heads/main").unwrap();
fs::create_dir_all(src_root.join("src/.svn")).unwrap();
fs::write(src_root.join("src/hello.c"), "int main() {}").unwrap();
let ctx = Context::new(ContextConfig::Local).unwrap();
let dest = ctx.ensure_available(&src_root, "/tmp").unwrap();
assert!(dest.join("src/hello.c").exists());
assert!(!dest.join(".git").exists());
assert!(!dest.join("src/.svn").exists());
}
/// The overlay-mount path exposes the tree verbatim, so pruning happens
/// after the fact: nested VCS metadata must be removed recursively.
#[test]
fn test_prune_vcs_dirs_removes_nested_metadata() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path().join("tree");
fs::create_dir_all(root.join("a/.git/objects")).unwrap();
fs::create_dir_all(root.join("b/c/CVS")).unwrap();
fs::write(root.join("a/.git/HEAD"), "ref").unwrap();
fs::write(root.join("b/keep.txt"), "x").unwrap();
prune_vcs_dirs(&root).unwrap();
assert!(!root.join("a/.git").exists());
assert!(!root.join("b/c/CVS").exists());
assert!(root.join("b/keep.txt").exists());
}
}