From 0c676f674340d57906b86b1e35b5ca730dbe1bdb Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sat, 21 Feb 2026 14:50:30 -0600 Subject: [PATCH] feat: Implement lifecycle script management with staging and execution hooks --- src/builder/cmake.rs | 2 - src/install/mod.rs | 3 + src/install/scripts.rs | 593 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 75 +++++- src/staging/mod.rs | 35 +++ 5 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 src/install/mod.rs create mode 100644 src/install/scripts.rs diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs index 6b78aaa..3489268 100755 --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -97,8 +97,6 @@ pub fn build( anyhow::bail!("cmake build failed"); } - // Note: CMake doesn't have a direct "after make, before install" hook as easy as autotools, - // but we can run it here. crate::source::hooks::run_post_compile_commands(spec, &actual_src, destdir)?; state.mark_done(BuildStep::PostCompileDone)?; } else { diff --git a/src/install/mod.rs b/src/install/mod.rs new file mode 100644 index 0000000..4ff53a6 --- /dev/null +++ b/src/install/mod.rs @@ -0,0 +1,3 @@ +//! Installation helpers. + +pub mod scripts; diff --git a/src/install/scripts.rs b/src/install/scripts.rs new file mode 100644 index 0000000..6c92a3a --- /dev/null +++ b/src/install/scripts.rs @@ -0,0 +1,593 @@ +//! Lifecycle script staging, installation, and execution. + +use crate::fakeroot; +use crate::package::PackageSpec; +use anyhow::{Context, Result, bail}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use walkdir::WalkDir; + +const STAGED_SCRIPTS_DIR: &str = "scripts"; + +/// Lifecycle hook names supported by Depot package scripts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hook { + /// Runs before a first-time install. + PreInstall, + /// Runs after a first-time install. + PostInstall, + /// Runs before an update/upgrade. + PreUpdate, + /// Runs after an update/upgrade. + PostUpdate, + /// Runs before package removal. + PreRemove, + /// Runs after package removal. + PostRemove, +} + +impl Hook { + fn canonical_name(self) -> &'static str { + match self { + Hook::PreInstall => "pre_install", + Hook::PostInstall => "post_install", + Hook::PreUpdate => "pre_update", + Hook::PostUpdate => "post_update", + Hook::PreRemove => "pre_remove", + Hook::PostRemove => "post_remove", + } + } + + fn action(self) -> &'static str { + match self { + Hook::PreInstall | Hook::PostInstall => "install", + Hook::PreUpdate | Hook::PostUpdate => "update", + Hook::PreRemove | Hook::PostRemove => "remove", + } + } + + fn phase(self) -> &'static str { + match self { + Hook::PreInstall | Hook::PreUpdate | Hook::PreRemove => "pre", + Hook::PostInstall | Hook::PostUpdate | Hook::PostRemove => "post", + } + } + + fn candidate_names(self) -> [String; 4] { + let canonical = self.canonical_name(); + let dashed = canonical.replace('_', "-"); + [ + canonical.to_string(), + format!("{}.sh", canonical), + dashed.clone(), + format!("{}.sh", dashed), + ] + } +} + +/// Return the staged scripts directory path inside a package staging tree. +pub fn staged_scripts_dir(destdir: &Path) -> PathBuf { + destdir.join(STAGED_SCRIPTS_DIR) +} + +/// Return the installed scripts directory path inside the root filesystem. +pub fn installed_scripts_dir(rootfs: &Path, pkg_name: &str) -> PathBuf { + rootfs + .join("usr/share/depot") + .join(pkg_name) + .join("scripts") +} + +/// Copy optional scripts from `/scripts` into `/scripts`. +/// +/// Returns `true` if scripts were found and staged. +pub fn stage_scripts_from_spec_dir(spec: &PackageSpec, destdir: &Path) -> Result { + let source_dir = spec.spec_dir.join(STAGED_SCRIPTS_DIR); + if !source_dir.exists() { + return Ok(false); + } + if !source_dir.is_dir() { + bail!( + "Scripts path exists but is not a directory: {}", + source_dir.display() + ); + } + + let staged_dir = staged_scripts_dir(destdir); + if staged_dir.exists() { + remove_tree_or_file(&staged_dir).with_context(|| { + format!( + "Failed to remove existing staged scripts dir: {}", + staged_dir.display() + ) + })?; + } + + copy_tree(&source_dir, &staged_dir) + .with_context(|| format!("Failed to stage scripts from {}", source_dir.display()))?; + + Ok(true) +} + +/// Synchronize staged scripts into `/usr/share/depot//scripts`. +/// +/// If no staged scripts are present, any previously installed scripts for the +/// package are removed. +/// +/// Returns `true` when scripts are present after synchronization. +pub fn sync_staged_scripts_to_rootfs( + staged_dir: &Path, + rootfs: &Path, + pkg_name: &str, +) -> Result { + let installed_dir = installed_scripts_dir(rootfs, pkg_name); + + if staged_dir.exists() { + if !staged_dir.is_dir() { + bail!( + "Staged scripts path exists but is not a directory: {}", + staged_dir.display() + ); + } + + if installed_dir.exists() { + remove_tree_or_file(&installed_dir).with_context(|| { + format!( + "Failed to remove existing installed scripts: {}", + installed_dir.display() + ) + })?; + } + + copy_tree(staged_dir, &installed_dir).with_context(|| { + format!("Failed to install scripts into {}", installed_dir.display()) + })?; + + return Ok(true); + } + + remove_installed_scripts(rootfs, pkg_name)?; + Ok(false) +} + +/// Remove installed scripts for a package and clean empty package metadata dir. +pub fn remove_installed_scripts(rootfs: &Path, pkg_name: &str) -> Result<()> { + let installed_dir = installed_scripts_dir(rootfs, pkg_name); + if installed_dir.exists() { + remove_tree_or_file(&installed_dir).with_context(|| { + format!( + "Failed to remove installed scripts directory: {}", + installed_dir.display() + ) + })?; + } + + cleanup_empty_package_dir(rootfs, pkg_name)?; + Ok(()) +} + +/// Run a lifecycle hook if its script exists in `script_dir`. +/// +/// Returns `true` if a hook script was found and executed. +pub fn run_hook_if_present( + script_dir: &Path, + hook: Hook, + rootfs: &Path, + pkg_name: &str, +) -> Result { + let Some(script_path) = resolve_hook_script(script_dir, hook)? else { + return Ok(false); + }; + + println!( + "Running lifecycle hook {}: {}", + hook.canonical_name(), + script_path.display() + ); + + let status = run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)?; + + if !status.success() { + bail!( + "Lifecycle hook {} failed: {}", + hook.canonical_name(), + script_path.display() + ); + } + + Ok(true) +} + +fn run_script_with_rootfs_context( + script_path: &Path, + rootfs: &Path, + pkg_name: &str, + hook: Hook, +) -> Result { + // When root and rootfs provides /bin/sh, run inside a real chroot so scripts + // can rely on `cd /` and relative paths resolving within that rootfs. + if fakeroot::is_root() + && rootfs.join("bin/sh").exists() + && let Ok(rel) = script_path.strip_prefix(rootfs) + { + let rel_script = format!("./{}", rel.to_string_lossy()); + return Command::new("chroot") + .arg(rootfs) + .arg("/bin/sh") + .arg("-c") + .arg("cd / && exec \"$1\"") + .arg("sh") + .arg(rel_script) + .env("DEPOT_PACKAGE", pkg_name) + .env("DEPOT_ROOTFS", rootfs) + .env("DEPOT_ACTION", hook.action()) + .env("DEPOT_PHASE", hook.phase()) + .status() + .with_context(|| { + format!( + "Failed to execute lifecycle hook {} in chroot at {}", + hook.canonical_name(), + rootfs.display() + ) + }); + } + + // Fallback (non-root / no rootfs shell / script outside rootfs): + // execute with host /bin/sh while setting cwd to rootfs, so relative paths + // inside scripts resolve against that rootfs root. + Command::new("/bin/sh") + .arg(script_path) + .current_dir(rootfs) + .env("DEPOT_PACKAGE", pkg_name) + .env("DEPOT_ROOTFS", rootfs) + .env("DEPOT_ACTION", hook.action()) + .env("DEPOT_PHASE", hook.phase()) + .status() + .with_context(|| { + format!( + "Failed to execute lifecycle hook {} at {}", + hook.canonical_name(), + script_path.display() + ) + }) +} + +fn resolve_hook_script(script_dir: &Path, hook: Hook) -> Result> { + if !script_dir.exists() { + return Ok(None); + } + + if !script_dir.is_dir() { + bail!( + "Scripts path exists but is not a directory: {}", + script_dir.display() + ); + } + + let mut found = Vec::new(); + + for candidate in hook.candidate_names() { + let path = script_dir.join(&candidate); + let metadata = match path.symlink_metadata() { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to inspect script path: {}", path.display())); + } + }; + + let file_type = metadata.file_type(); + if !file_type.is_file() && !file_type.is_symlink() { + bail!( + "Lifecycle hook candidate exists but is not a file: {}", + path.display() + ); + } + + found.push(path); + } + + if found.len() > 1 { + let names = found + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + bail!( + "Ambiguous lifecycle hook '{}': multiple script candidates found: {}", + hook.canonical_name(), + names + ); + } + + Ok(found.into_iter().next()) +} + +fn safe_rel_path(rel: &Path) -> Result { + let mut normalized = PathBuf::new(); + for component in rel.components() { + match component { + Component::Normal(seg) => normalized.push(seg), + Component::CurDir => {} + _ => { + bail!( + "Unsafe scripts path component encountered: {}", + rel.display() + ) + } + } + } + + if normalized.as_os_str().is_empty() { + bail!("Scripts path resolves to empty path: {}", rel.display()); + } + + Ok(normalized) +} + +fn copy_tree(src_root: &Path, dst_root: &Path) -> Result<()> { + fs::create_dir_all(dst_root) + .with_context(|| format!("Failed to create directory: {}", dst_root.display()))?; + + for entry in WalkDir::new(src_root).follow_links(false) { + let entry = entry + .with_context(|| format!("Failed to walk scripts directory: {}", src_root.display()))?; + + let src_path = entry.path(); + let rel = src_path + .strip_prefix(src_root) + .with_context(|| format!("Failed to strip script root: {}", src_path.display()))?; + + if rel.as_os_str().is_empty() { + continue; + } + + let rel = safe_rel_path(rel)?; + let dst_path = dst_root.join(rel); + let file_type = entry.file_type(); + + if file_type.is_dir() { + fs::create_dir_all(&dst_path) + .with_context(|| format!("Failed to create directory: {}", dst_path.display()))?; + continue; + } + + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + if file_type.is_symlink() { + let target = fs::read_link(src_path) + .with_context(|| format!("Failed to read symlink: {}", src_path.display()))?; + if dst_path.symlink_metadata().is_ok() { + remove_tree_or_file(&dst_path).with_context(|| { + format!("Failed to replace existing path: {}", dst_path.display()) + })?; + } + std::os::unix::fs::symlink(target, &dst_path) + .with_context(|| format!("Failed to create symlink: {}", dst_path.display()))?; + } else if file_type.is_file() { + fs::copy(src_path, &dst_path).with_context(|| { + format!( + "Failed to copy script '{}' to '{}'", + src_path.display(), + dst_path.display() + ) + })?; + ensure_executable(&dst_path)?; + } else { + bail!( + "Unsupported file type in scripts directory: {}", + src_path.display() + ); + } + } + + Ok(()) +} + +fn ensure_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to inspect script permissions: {}", path.display()))?; + let mut perms = metadata.permissions(); + let mode = perms.mode(); + let new_mode = mode | 0o111; + if new_mode != mode { + perms.set_mode(new_mode); + fs::set_permissions(path, perms).with_context(|| { + format!("Failed to set executable permissions: {}", path.display()) + })?; + } + } + + Ok(()) +} + +fn remove_tree_or_file(path: &Path) -> Result<()> { + let metadata = path + .symlink_metadata() + .with_context(|| format!("Failed to inspect path: {}", path.display()))?; + if metadata.file_type().is_dir() { + fs::remove_dir_all(path) + .with_context(|| format!("Failed to remove directory: {}", path.display()))?; + } else { + fs::remove_file(path) + .with_context(|| format!("Failed to remove file: {}", path.display()))?; + } + Ok(()) +} + +fn cleanup_empty_package_dir(rootfs: &Path, pkg_name: &str) -> Result<()> { + let package_dir = rootfs.join("usr/share/depot").join(pkg_name); + if !package_dir.exists() { + return Ok(()); + } + + let is_empty = fs::read_dir(&package_dir) + .with_context(|| format!("Failed to read directory: {}", package_dir.display()))? + .next() + .is_none(); + + if is_empty { + fs::remove_dir(&package_dir) + .with_context(|| format!("Failed to remove directory: {}", package_dir.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source, + }; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + fn mk_spec(spec_dir: &Path) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + spec_dir: spec_dir.to_path_buf(), + } + } + + #[test] + fn stage_scripts_from_spec_dir_copies_tree() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(spec_dir.join("scripts/lib")).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(spec_dir.join("scripts/pre_install"), "echo pre").unwrap(); + std::fs::write(spec_dir.join("scripts/lib/common.sh"), "echo lib").unwrap(); + + let spec = mk_spec(&spec_dir); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(staged); + assert!(destdir.join("scripts/pre_install").exists()); + assert!(destdir.join("scripts/lib/common.sh").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(destdir.join("scripts/pre_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } + } + + #[test] + fn run_hook_if_present_executes_script() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("pre_install"), + "echo \"$DEPOT_ACTION:$DEPOT_PHASE:$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), + "install:pre:foo\n" + ); + } + + #[test] + fn run_hook_if_present_rejects_ambiguous_names() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write(scripts.join("pre_update"), "echo one").unwrap(); + std::fs::write(scripts.join("pre-update"), "echo two").unwrap(); + + let err = run_hook_if_present(&scripts, Hook::PreUpdate, &rootfs, "foo") + .expect_err("expected ambiguous script names to fail"); + assert!(err.to_string().contains("Ambiguous lifecycle hook")); + } + + #[test] + fn sync_staged_scripts_to_rootfs_replaces_existing_tree() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let staged = tmp.path().join("staged"); + std::fs::create_dir_all(staged.join("scripts")).unwrap(); + + let installed = installed_scripts_dir(&rootfs, "foo"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write(installed.join("old"), "old").unwrap(); + + std::fs::write(staged.join("scripts/post_install"), "echo ok").unwrap(); + let has_scripts = + sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); + + assert!(has_scripts); + let installed = installed_scripts_dir(&rootfs, "foo"); + assert!(!installed.join("old").exists()); + assert!(installed.join("post_install").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(installed.join("post_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } + } + + #[test] + fn sync_staged_scripts_to_rootfs_removes_old_when_none_staged() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let staged = tmp.path().join("staged"); + std::fs::create_dir_all(&staged).unwrap(); + + let installed = installed_scripts_dir(&rootfs, "foo"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write(installed.join("pre_remove"), "echo old").unwrap(); + + let has_scripts = + sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); + assert!(!has_scripts); + assert!(!installed_scripts_dir(&rootfs, "foo").exists()); + } +} diff --git a/src/main.rs b/src/main.rs index 1905d91..8bac1ec 100755 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod db; mod deps; mod fakeroot; mod index; +mod install; mod package; mod source; mod staging; @@ -71,6 +72,35 @@ fn install_staged_to_rootfs( })?; let db_path = config.db_dir.join("packages.db"); + let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some(); + let staged_scripts_dir = install::scripts::staged_scripts_dir(destdir); + let installed_scripts_dir = + install::scripts::installed_scripts_dir(rootfs, &pkg_spec.package.name); + + if is_update { + let has_staged_pre = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + if !has_staged_pre { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + } + } else { + let _ = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreInstall, + rootfs, + &pkg_spec.package.name, + )?; + } + let new_files = staging::generate_manifest_with_dirs(destdir)?; let remove_paths = db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?; @@ -94,6 +124,28 @@ fn install_staged_to_rootfs( } tx.commit()?; + install::scripts::sync_staged_scripts_to_rootfs( + &staged_scripts_dir, + rootfs, + &pkg_spec.package.name, + )?; + + if is_update { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PostUpdate, + rootfs, + &pkg_spec.package.name, + )?; + } else { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PostInstall, + rootfs, + &pkg_spec.package.name, + )?; + } + Ok(()) } @@ -347,11 +399,6 @@ fn main() -> Result<()> { // Install from spec (normal build) let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; pkg_spec.apply_config(&config); - - // ... existing build logic ... - // Jump to the part where build actually happens. - // To keep the code clean, I'll move the build/stage logic into a helper or similar? - // Actually, I'll just structure it so we can skip build if staging_dir is Some. (pkg_spec, None) }; @@ -513,6 +560,7 @@ fn main() -> Result<()> { // 3.1 Copy license files into staged tree staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; destdir }; @@ -551,7 +599,23 @@ fn main() -> Result<()> { println!("Removing package: {}", package); let config = config::Config::for_rootfs(&cli.rootfs); let db_path = config.db_dir.join("packages.db"); + let script_dir = install::scripts::installed_scripts_dir(&cli.rootfs, &package); + let _ = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PreRemove, + &cli.rootfs, + &package, + )?; db::remove_package(&db_path, &package, &cli.rootfs)?; + let post_remove = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PostRemove, + &cli.rootfs, + &package, + ); + let cleanup_scripts = install::scripts::remove_installed_scripts(&cli.rootfs, &package); + post_remove?; + cleanup_scripts?; println!("Successfully removed {}", package); } Commands::Build { @@ -610,6 +674,7 @@ fn main() -> Result<()> { )?; staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; staging::process(&destdir, &pkg_spec)?; diff --git a/src/staging/mod.rs b/src/staging/mod.rs index 2e15723..40cd36b 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -14,6 +14,8 @@ fn is_skipped_install_path(rel_path: &str) -> bool { let p = rel_path.trim_start_matches('/'); p == ".metadata.toml" || p == ".files.yaml" + || p == "scripts" + || p.starts_with("scripts/") || p == "usr/share/info/dir" || p.starts_with("usr/share/info/dir.") } @@ -651,6 +653,24 @@ mod tests { assert!(rootfs.join("usr/bin/ok").exists()); } + #[test] + fn install_atomic_skips_package_scripts_dir() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("scripts")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("scripts/pre_install"), "#!/bin/sh\necho pre\n").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join("scripts/pre_install").exists()); + assert!(rootfs.join("usr/bin/ok").exists()); + } + #[test] fn generate_manifest_skips_info_dir_index() { let tmp = tempfile::tempdir().unwrap(); @@ -691,6 +711,21 @@ mod tests { assert!(!manifest.files.contains(&".files.yaml".to_string())); assert!(manifest.files.contains(&"usr/bin/ok".to_string())); } + + #[test] + fn generate_manifest_skips_package_scripts_dir() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("scripts")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("scripts/pre_install"), "echo pre").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&"scripts/pre_install".to_string())); + assert!(manifest.files.contains(&"usr/bin/ok".to_string())); + } } /// Manifest containing files and directories for a package