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 { 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::(&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()); } }