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
406 lines
15 KiB
Rust
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());
|
|
}
|
|
}
|