diff --git a/Cargo.lock b/Cargo.lock index eb222d8..c73353c 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.18.0" +version = "0.18.1" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index d456351..3b9d45d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.18.0" +version = "0.18.1" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index dc033ae..1a9689a 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.18.0', + version: '0.18.1', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/commands.rs b/src/commands.rs index a449b12..cb4f2af 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -703,6 +703,66 @@ fn output_destdir_for(base_destdir: &Path, primary_pkg: &str, output_pkg: &str) } } +fn spec_for_output( + pkg_spec: &package::PackageSpec, + output: package::PackageInfo, +) -> package::PackageSpec { + let output_name = output.name.clone(); + let mut spec_for_out = pkg_spec.clone(); + spec_for_out.package = output; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + spec_for_out +} + +fn destdir_has_packagable_content(destdir: &Path) -> Result { + if !destdir.exists() { + return Ok(false); + } + + let manifest = staging::generate_manifest_with_dirs(destdir)?; + Ok(!manifest.files.is_empty() || !manifest.directories.is_empty()) +} + +fn staged_output_specs( + pkg_spec: &package::PackageSpec, + destdir: &Path, +) -> Result> { + let declared_outputs = pkg_spec.outputs(); + let declared_names: HashSet = declared_outputs + .iter() + .map(|output| output.name.clone()) + .collect(); + let mut outputs = Vec::new(); + let mut seen = HashSet::new(); + + for output in declared_outputs { + let output_name = output.name.clone(); + let out_destdir = output_destdir_for(destdir, &pkg_spec.package.name, &output_name); + outputs.push((spec_for_output(pkg_spec, output.clone()), out_destdir)); + seen.insert(output_name.clone()); + + if !pkg_spec.build.flags.split_docs || output_name.ends_with("-docs") { + continue; + } + + let docs_pkg = pkg_spec.docs_package_for_output(&output); + if declared_names.contains(&docs_pkg.name) || seen.contains(&docs_pkg.name) { + continue; + } + + let docs_destdir = output_destdir_for(destdir, &pkg_spec.package.name, &docs_pkg.name); + if !destdir_has_packagable_content(&docs_destdir)? { + continue; + } + + seen.insert(docs_pkg.name.clone()); + outputs.push((spec_for_output(pkg_spec, docs_pkg), docs_destdir)); + } + + Ok(outputs) +} + fn lib32_package_name(name: &str) -> String { format!("lib32-{name}") } @@ -1187,15 +1247,15 @@ fn plan_staged_install( let db_path = config.installed_db_path(rootfs); let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some(); - let new_files = staging::generate_manifest_with_dirs(destdir)?; + let new_manifest = staging::generate_manifest_with_dirs(destdir)?; let remove_paths = - db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?; + db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_manifest)?; let operation = if is_update { install::hooks::HookOperation::Update } else { install::hooks::HookOperation::Install }; - let mut affected_paths = new_files.files.clone(); + let mut affected_paths = new_manifest.files.clone(); affected_paths.extend(remove_paths.iter().cloned()); affected_paths.sort(); affected_paths.dedup(); @@ -1218,13 +1278,7 @@ fn plan_package_outputs_for_install( config: &config::Config, ) -> Result> { let mut plans = Vec::new(); - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = output_destdir_for(destdir, &pkg_spec.package.name, &output_name); + for (spec_for_out, out_destdir) in staged_output_specs(pkg_spec, destdir)? { let staged = plan_staged_install(&spec_for_out, &out_destdir, rootfs, config)?; plans.push(PlannedPackageInstall { spec: spec_for_out, @@ -4077,17 +4131,18 @@ pub fn run(cli: Cli) -> Result<()> { .unwrap_or(std::env::consts::ARCH); let mut created_files = Vec::new(); + let staged_outputs = if !cli.lib32_only { + staged_output_specs(&pkg_spec, &destdir)? + } else { + Vec::new() + }; if !cli.lib32_only { - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = - output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); - let packager = - package::Packager::new(spec_for_out.clone(), out_destdir, config.clone()); + for (spec_for_out, out_destdir) in &staged_outputs { + let packager = package::Packager::new( + spec_for_out.clone(), + out_destdir.clone(), + config.clone(), + ); let pkg_file = packager.create_package(Path::new("."), arch)?; if let Some(sig_path) = signing::auto_sign_zst_file_detached(&cli.rootfs, &pkg_file)? @@ -4137,7 +4192,8 @@ pub fn run(cli: Cli) -> Result<()> { if install { let mut install_targets = Vec::new(); if !cli.lib32_only { - for out in pkg_spec.outputs() { + for (spec_for_out, _) in &staged_outputs { + let out = &spec_for_out.package; install_targets .push(format!("{} v{}-{}", out.name, out.version, out.revision)); } @@ -4213,7 +4269,8 @@ pub fn run(cli: Cli) -> Result<()> { install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?; } else { if !cli.lib32_only { - for out in pkg_spec.outputs() { + for (spec_for_out, _) in &staged_outputs { + let out = &spec_for_out.package; ui::success(format!( "Built successfully: {}-{}-{}", out.name, out.version, out.revision diff --git a/src/db/mod.rs b/src/db/mod.rs index 59b8544..b15a359 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -199,6 +199,36 @@ pub fn get_package_files(db_path: &Path, name: &str) -> Result> { Ok(files) } +/// Return the list of directories owned by an installed package. +pub fn get_package_directories(db_path: &Path, name: &str) -> Result> { + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + + let pkg_id_res: rusqlite::Result = conn.query_row( + "SELECT id FROM packages WHERE name = ?1", + params![name], + |row| row.get(0), + ); + + let pkg_id = match pkg_id_res { + Ok(id) => id, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + + let mut stmt = conn.prepare("SELECT path FROM directories WHERE package_id = ?1")?; + let mut directories: Vec = stmt + .query_map(params![pkg_id], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + directories.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); + Ok(directories) +} + /// Remove a package from the database and filesystem pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { if !db_path.exists() { @@ -510,18 +540,24 @@ fn detect_rootfs_from_db_path(db_path: &Path) -> Option { pub fn calculate_upgrade_paths( db_path: &Path, name: &str, - new_files: &[String], + new_manifest: &staging::Manifest, ) -> Result> { let old_files = get_package_files(db_path, name)?; - let mut new_set = std::collections::HashSet::new(); - for f in new_files { - new_set.insert(f); - } + let old_directories = get_package_directories(db_path, name)?; + let new_files: std::collections::HashSet<_> = new_manifest.files.iter().cloned().collect(); + let new_directories: std::collections::HashSet<_> = + new_manifest.directories.iter().cloned().collect(); - let remove_paths: Vec = old_files + let mut remove_paths: Vec = old_files .into_iter() - .filter(|p| !new_set.contains(p)) + .filter(|p| !new_files.contains(p)) .collect(); + remove_paths.extend( + old_directories + .into_iter() + .filter(|p| !new_directories.contains(p)), + ); + remove_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); Ok(remove_paths) } @@ -871,9 +907,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); std::fs::File::create(&db_path).unwrap(); + let manifest = staging::Manifest { + files: vec!["usr/bin/foo".to_string()], + directories: Vec::new(), + }; - let remove_paths = - calculate_upgrade_paths(&db_path, "nonexistent", &["usr/bin/foo".to_string()]).unwrap(); + let remove_paths = calculate_upgrade_paths(&db_path, "nonexistent", &manifest).unwrap(); assert!(remove_paths.is_empty()); } @@ -943,11 +982,14 @@ mod tests { std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap(); let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap(); - let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2.files).unwrap(); + let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2).unwrap(); assert_eq!( remove_paths, - vec!["usr/bin/shared_dir/old_file".to_string()] + vec![ + "usr/bin/shared_dir/old_file".to_string(), + "usr/bin/shared_dir".to_string() + ] ); let tx = diff --git a/src/package/spec.rs b/src/package/spec.rs index 75e4b34..0bd3880 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -188,7 +188,7 @@ impl PackageSpec { matches!(self.build.build_type, BuildType::Meta) } - /// Return all package outputs this spec will produce (primary + any extras) + /// Return all declared package outputs for this spec (primary + any extras). pub fn outputs(&self) -> Vec { let mut v = Vec::new(); v.push(self.package.clone()); @@ -196,10 +196,47 @@ impl PackageSpec { v } + /// Return the derived documentation package name for an output package. + pub fn docs_package_name(pkg_name: &str) -> String { + format!("{pkg_name}-docs") + } + + /// Build package metadata for an automatically generated documentation output. + pub fn docs_package_for_output(&self, output: &PackageInfo) -> PackageInfo { + let mut docs = output.clone(); + docs.name = Self::docs_package_name(&output.name); + docs.description = format!("Documentation for {}", output.name); + docs + } + + fn docs_parent_output_name(&self, pkg_name: &str) -> Option { + if !self.build.flags.split_docs { + return None; + } + + let base = pkg_name.strip_suffix("-docs")?; + self.outputs() + .into_iter() + .find(|output| output.name == base) + .map(|output| output.name) + } + /// Return dependencies for a specific output package name. /// /// If no per-output override exists, returns the top-level dependencies. pub fn dependencies_for_output(&self, pkg_name: &str) -> Dependencies { + if let Some(parent_output) = self.docs_parent_output_name(pkg_name) { + return self + .package_dependencies + .get(pkg_name) + .cloned() + .unwrap_or_else(|| { + let mut deps = Dependencies::default(); + deps.runtime.push(parent_output); + deps + }); + } + self.package_dependencies .get(pkg_name) .cloned() @@ -226,6 +263,14 @@ impl PackageSpec { /// /// If no per-output override exists, returns the top-level alternatives. pub fn alternatives_for_output(&self, pkg_name: &str) -> Alternatives { + if self.docs_parent_output_name(pkg_name).is_some() { + return self + .package_alternatives + .get(pkg_name) + .cloned() + .unwrap_or_default(); + } + self.package_alternatives .get(pkg_name) .cloned() @@ -424,6 +469,22 @@ impl PackageSpec { self.build.flags.keep = vec![s.to_string()]; } } + "split_docs" | "split-docs" => { + if let Some(b) = toml_value_as_boolish(v) { + self.build.flags.split_docs = b; + } + } + "doc_dirs" | "doc-dirs" => { + if let Some(arr) = v.as_array() { + self.build.flags.doc_dirs = arr + .iter() + .filter_map(|x| x.as_str()) + .map(String::from) + .collect(); + } else if let Some(s) = v.as_str() { + self.build.flags.doc_dirs = vec![s.to_string()]; + } + } "cc" => { if let Some(s) = v.as_str() { self.build.flags.cc = s.to_string(); @@ -978,6 +1039,18 @@ impl PackageSpec { } } } + "doc_dirs" | "doc-dirs" => { + for v in values { + if let Some(arr) = v.as_array() { + self.build + .flags + .doc_dirs + .extend(arr.iter().filter_map(|x| x.as_str()).map(String::from)); + } else if let Some(s) = v.as_str() { + self.build.flags.doc_dirs.push(s.to_string()); + } + } + } "configure" => { for v in values { if let Some(arr) = v.as_array() { @@ -1452,6 +1525,11 @@ impl PackageSpec { self.build.flags.build_32 = b; } } + "split_docs" | "split-docs" => { + if let Some(b) = values.last().and_then(toml_value_as_boolish) { + self.build.flags.split_docs = b; + } + } _ => {} } } @@ -2936,6 +3014,44 @@ keep = ["etc/locale.gen", "etc/resolv.conf"] ); } + #[test] + fn parse_split_docs_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +split_docs = true +doc_dirs = ["/opt/docs", "usr/share/devhelp"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.split_docs); + assert_eq!( + spec.build.flags.doc_dirs, + vec!["/opt/docs".to_string(), "usr/share/devhelp".to_string()] + ); + } + #[test] fn parse_build_flags_appends_from_spec_file() { let tmp = tempfile::tempdir().unwrap(); @@ -3289,6 +3405,31 @@ type = "custom" assert_eq!(outputs[1].name, "foo-dev"); } + #[test] + fn docs_output_uses_runtime_dependency_on_parent_package() { + let mut spec = mk_spec("foo", "1.0"); + spec.build.flags.split_docs = true; + let docs_name = PackageSpec::docs_package_name("foo"); + + let deps = spec.dependencies_for_output(&docs_name); + assert_eq!(deps.runtime, vec!["foo".to_string()]); + + let alternatives = spec.alternatives_for_output(&docs_name); + assert!(alternatives.provides.is_empty()); + assert!(alternatives.conflicts.is_empty()); + } + + #[test] + fn docs_package_for_output_derives_name_and_description() { + let mut spec = mk_spec("foo", "1.0"); + spec.build.flags.split_docs = true; + + let docs = spec.docs_package_for_output(&spec.package); + assert_eq!(docs.name, "foo-docs"); + assert_eq!(docs.description, "Documentation for foo"); + assert_eq!(docs.version, "1.0"); + } + fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { @@ -3625,6 +3766,21 @@ pub struct BuildFlags { /// Keep existing files and install package-provided replacement as `.depotnew`. #[serde(default, deserialize_with = "deserialize_string_or_array")] pub keep: Vec, + /// Split documentation trees into a derived `-docs` output during staging. + #[serde( + default, + alias = "split-docs", + deserialize_with = "deserialize_boolish" + )] + pub split_docs: bool, + /// Additional documentation directories to move into `-docs`. + #[serde( + default, + alias = "doc-dirs", + alias = "doc_dirs", + deserialize_with = "deserialize_string_or_array_no_split" + )] + pub doc_dirs: Vec, /// Disable automatic LTOFLAGS injection into CFLAGS/CXXFLAGS/LDFLAGS. #[serde( default = "default_use_lto", @@ -3960,6 +4116,8 @@ impl Default for BuildFlags { ltoflags: Vec::new(), replace_ltoflags: Vec::new(), keep: Vec::new(), + split_docs: false, + doc_dirs: Vec::new(), use_lto: default_use_lto(), no_flags: false, no_strip: false, diff --git a/src/staging/mod.rs b/src/staging/mod.rs index 0aa4a1e..340e78a 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -208,6 +208,202 @@ fn is_manpage_rel_path(rel_path: &str) -> bool { rel.starts_with("usr/share/man/") && !has_known_compressed_suffix(rel) } +fn normalize_doc_dir(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + anyhow::bail!("doc_dirs entries must not be empty"); + } + + let relative = trimmed.trim_start_matches('/'); + if relative.is_empty() { + anyhow::bail!("doc_dirs entries must not resolve to the filesystem root"); + } + + let p = Path::new(relative); + let mut normalized = PathBuf::new(); + for comp in p.components() { + match comp { + Component::Normal(seg) => normalized.push(seg), + Component::CurDir => {} + _ => { + anyhow::bail!( + "doc_dirs entries must not contain traversal or root components: {}", + trimmed + ); + } + } + } + + let normalized = normalized + .to_str() + .context("doc_dirs entries must be valid UTF-8")? + .to_string(); + if normalized.is_empty() { + anyhow::bail!( + "doc_dirs entries must not resolve to an empty path: {}", + trimmed + ); + } + Ok(normalized) +} + +fn cleanup_empty_parent_dirs(root: &Path, start: &Path) -> Result<()> { + let mut current = start.parent(); + while let Some(dir) = current { + if dir == root { + break; + } + + match fs::remove_dir(dir) { + Ok(()) => current = dir.parent(), + Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => break, + Err(err) if err.kind() == io::ErrorKind::NotFound => current = dir.parent(), + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to prune empty dir {}", dir.display())); + } + } + } + Ok(()) +} + +fn move_tree_preserving_layout(src: &Path, dst: &Path) -> Result<()> { + let metadata = src + .symlink_metadata() + .with_context(|| format!("Failed to inspect {}", src.display()))?; + let file_type = metadata.file_type(); + + if file_type.is_dir() { + match dst.symlink_metadata() { + Ok(dst_meta) => { + if !dst_meta.file_type().is_dir() { + anyhow::bail!( + "Failed to move {} into {}: destination exists and is not a directory", + src.display(), + dst.display() + ); + } + for entry in fs::read_dir(src) + .with_context(|| format!("Failed to read {}", src.display()))? + { + let entry = entry?; + let child_src = entry.path(); + let child_dst = dst.join(entry.file_name()); + move_tree_preserving_layout(&child_src, &child_dst)?; + } + fs::remove_dir(src) + .with_context(|| format!("Failed to remove {}", src.display()))?; + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + fs::rename(src, dst).with_context(|| { + format!( + "Failed to move documentation tree {} -> {}", + src.display(), + dst.display() + ) + })?; + } + Err(err) => { + return Err(err).with_context(|| format!("Failed to inspect {}", dst.display())); + } + } + } else { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + if dst.symlink_metadata().is_ok() { + anyhow::bail!( + "Failed to move {} into {}: destination already exists", + src.display(), + dst.display() + ); + } + fs::rename(src, dst).with_context(|| { + format!( + "Failed to move documentation path {} -> {}", + src.display(), + dst.display() + ) + })?; + } + + Ok(()) +} + +fn split_docs_for_output( + output_destdir: &Path, + docs_destdir: &Path, + doc_dirs: &[String], +) -> Result { + let mut moved = 0usize; + + for rel_dir in doc_dirs { + let src = output_destdir.join(rel_dir); + let metadata = match src.symlink_metadata() { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + return Err(err).with_context(|| format!("Failed to inspect {}", src.display())); + } + }; + + if !metadata.file_type().is_dir() && !metadata.file_type().is_symlink() { + continue; + } + + let dst = docs_destdir.join(rel_dir); + move_tree_preserving_layout(&src, &dst)?; + cleanup_empty_parent_dirs(output_destdir, &src)?; + moved += 1; + } + + Ok(moved) +} + +fn split_docs_outputs(destdir: &Path, spec: &PackageSpec) -> Result { + if !spec.build.flags.split_docs { + return Ok(0); + } + + let mut doc_dirs = vec![ + normalize_doc_dir("/usr/share/doc")?, + normalize_doc_dir("/usr/share/gtk-doc")?, + ]; + for custom in &spec.build.flags.doc_dirs { + let normalized = normalize_doc_dir(custom)?; + if !doc_dirs.contains(&normalized) { + doc_dirs.push(normalized); + } + } + + let mut moved = 0usize; + for output in spec.outputs() { + if output.name.ends_with("-docs") { + continue; + } + + let output_destdir = if output.name == spec.package.name { + destdir.to_path_buf() + } else { + output_staging_dir(destdir, &output.name) + }; + if !output_destdir.exists() { + continue; + } + + let docs_pkg = spec.docs_package_for_output(&output); + let docs_destdir = output_staging_dir(destdir, &docs_pkg.name); + moved += split_docs_for_output(&output_destdir, &docs_destdir, &doc_dirs)?; + } + + Ok(moved) +} + fn append_os_suffix(path: &Path, suffix: &str) -> PathBuf { let mut s = OsString::from(path.as_os_str()); s.push(suffix); @@ -460,6 +656,14 @@ pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> { } } + let moved_docs = split_docs_outputs(destdir, spec)?; + if moved_docs > 0 { + crate::log_info!( + "Moved {} documentation tree(s) into docs outputs", + moved_docs + ); + } + Ok(()) } @@ -528,6 +732,111 @@ pub struct FsTransaction { removed: Vec, } +fn is_directory_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path) + .with_context(|| format!("Failed to read directory {}", path.display()))?; + Ok(entries.next().transpose()?.is_none()) +} + +fn backup_existing_path(src: &Path, backup_path: &Path, rel: &str) -> Result<()> { + let metadata = src + .symlink_metadata() + .with_context(|| format!("Failed to inspect existing path {}", rel))?; + + if let Some(parent) = backup_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create backup dir {}", parent.display()))?; + } + + if metadata.file_type().is_symlink() { + let target = fs::read_link(src) + .with_context(|| format!("Failed to read existing symlink target {}", rel))?; + std::os::unix::fs::symlink(&target, backup_path) + .with_context(|| format!("Failed to backup symlink {}", rel))?; + } else if metadata.file_type().is_dir() { + fs::create_dir_all(backup_path) + .with_context(|| format!("Failed to backup directory {}", rel))?; + apply_unix_mode(backup_path, &metadata)?; + } else { + fs::copy(src, backup_path).with_context(|| format!("Failed to backup file {}", rel))?; + } + + Ok(()) +} + +fn remove_path_in_place(path: &Path, rel: &str) -> Result<()> { + let metadata = path + .symlink_metadata() + .with_context(|| format!("Failed to inspect existing path {}", rel))?; + + if metadata.file_type().is_dir() { + fs::remove_dir(path) + .with_context(|| format!("Failed to remove obsolete directory {}", rel))?; + } else { + fs::remove_file(path) + .with_context(|| format!("Failed to remove obsolete file/symlink {}", rel))?; + } + + Ok(()) +} + +fn backup_and_remove_obsolete_path( + tx: &mut FsTransaction, + rootfs: &Path, + rel: &str, + require_empty_dir: bool, +) -> Result { + let dest_path = rootfs.join(rel); + let metadata = match dest_path.symlink_metadata() { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => { + return Err(err).with_context(|| { + format!("Failed to inspect obsolete path before removal: {}", rel) + }); + } + }; + + if metadata.file_type().is_dir() { + let empty = is_directory_empty(&dest_path)?; + if !empty { + if require_empty_dir { + anyhow::bail!( + "Refusing to replace existing non-empty directory with packaged file/symlink: {}", + rel + ); + } + return Ok(false); + } + } + + let backup_path = tx.removed_backup_path(rel); + backup_existing_path(&dest_path, &backup_path, rel)?; + remove_path_in_place(&dest_path, rel)?; + tx.removed.push(rel.to_string()); + Ok(true) +} + +fn remove_obsolete_children_for_dir( + tx: &mut FsTransaction, + rootfs: &Path, + dir_rel: &str, + remove_paths: &[String], +) -> Result<()> { + let prefix = format!("{dir_rel}/"); + let mut nested_paths: Vec<&str> = remove_paths + .iter() + .filter_map(|path| path.strip_prefix(&prefix).map(|_| path.as_str())) + .collect(); + nested_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); + + for rel in nested_paths { + let _ = backup_and_remove_obsolete_path(tx, rootfs, rel, false)?; + } + + Ok(()) +} + impl FsTransaction { fn backup_path(&self, rel: &str) -> PathBuf { self.tx_dir.join("backup").join(rel) @@ -537,44 +846,78 @@ impl FsTransaction { self.tx_dir.join("removed").join(rel) } - /// Roll back file operations performed by `install_atomic`. - pub fn rollback(&self) -> Result<()> { - // Restore removed files - for rel in &self.removed { - let src = self.removed_backup_path(rel); - let dst = self.rootfs.join(rel); - if src.symlink_metadata().is_ok() { - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent)?; - } - // Best-effort remove if something exists now. - let _ = fs::remove_file(&dst); - match fs::rename(&src, &dst) { - Ok(()) => {} - Err(_) => { - fs::copy(&src, &dst)?; - fs::remove_file(&src)?; - } - } - } + fn restore_backup_entry(&self, src: &Path, dst: &Path) -> Result<()> { + let metadata = src + .symlink_metadata() + .with_context(|| format!("Failed to inspect backup entry {}", src.display()))?; + + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create restore dir {}", parent.display()))?; } - // Restore overwritten files + if metadata.file_type().is_dir() { + match dst.symlink_metadata() { + Ok(dst_meta) if dst_meta.file_type().is_dir() => {} + Ok(dst_meta) if dst_meta.file_type().is_symlink() => { + fs::remove_file(dst) + .with_context(|| format!("Failed to remove {}", dst.display()))?; + } + Ok(_) => { + fs::remove_file(dst) + .with_context(|| format!("Failed to remove {}", dst.display()))?; + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to inspect {}", dst.display())); + } + } + fs::create_dir_all(dst) + .with_context(|| format!("Failed to restore directory {}", dst.display()))?; + apply_unix_mode(dst, &metadata)?; + return Ok(()); + } + + let _ = fs::remove_file(dst); + match fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(_) if metadata.file_type().is_symlink() => { + let target = fs::read_link(src) + .with_context(|| format!("Failed to read backup symlink {}", src.display()))?; + std::os::unix::fs::symlink(&target, dst) + .with_context(|| format!("Failed to restore symlink {}", dst.display())) + } + Err(_) => { + fs::copy(src, dst).with_context(|| { + format!( + "Failed to restore file {} from {}", + dst.display(), + src.display() + ) + })?; + Ok(()) + } + } + } + + /// Roll back file operations performed by `install_atomic`. + pub fn rollback(&self) -> Result<()> { + // Restore overwritten paths first so removed children have their original parent layout. for rel in &self.backed_up { let src = self.backup_path(rel); let dst = self.rootfs.join(rel); if src.symlink_metadata().is_ok() { - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent)?; - } - let _ = fs::remove_file(&dst); - match fs::rename(&src, &dst) { - Ok(()) => {} - Err(_) => { - fs::copy(&src, &dst)?; - fs::remove_file(&src)?; - } - } + self.restore_backup_entry(&src, &dst)?; + } + } + + // Restore removed files/directories. + for rel in &self.removed { + let src = self.removed_backup_path(rel); + let dst = self.rootfs.join(rel); + if src.symlink_metadata().is_ok() { + self.restore_backup_entry(&src, &dst)?; } } @@ -618,6 +961,7 @@ pub fn install_atomic( KeepMatcher::Pattern(_) => None, }) .collect(); + let remove_set: HashSet<&str> = remove_paths.iter().map(String::as_str).collect(); fs::create_dir_all(tx_base_dir) .with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?; @@ -640,6 +984,7 @@ pub fn install_atomic( created: Vec::new(), removed: Vec::new(), }; + let mut staged_paths = HashSet::new(); let result: Result<()> = (|| { // First, create all directories from destdir (for packages with only directories) @@ -664,6 +1009,7 @@ pub fn install_atomic( fs::create_dir_all(&dest_path)?; apply_unix_mode(&dest_path, &src_path.symlink_metadata()?)?; } + staged_paths.insert(rel_path_str); } // Copy in new files. @@ -708,26 +1054,22 @@ pub fn install_atomic( } if let Ok(dest_meta) = dest_path.symlink_metadata() { - // lexists checks existence without following symlinks - // Backup existing let backup_path = tx.backup_path(&install_rel_path); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent)?; + if dest_meta.file_type().is_dir() { + if !remove_set.contains(install_rel_path.as_str()) { + anyhow::bail!( + "Refusing to replace existing directory with packaged file/symlink: {}", + install_rel_path + ); + } + remove_obsolete_children_for_dir( + &mut tx, + rootfs, + &install_rel_path, + remove_paths, + )?; } - - // Fallback: if symlink, read link and recreate at backup. - if dest_meta.file_type().is_symlink() { - let target = fs::read_link(&dest_path)?; - std::os::unix::fs::symlink(&target, &backup_path)?; - } else if dest_meta.file_type().is_dir() { - anyhow::bail!( - "Refusing to replace existing directory with packaged file/symlink: {}", - install_rel_path - ); - } else { - fs::copy(&dest_path, &backup_path)?; - } - + backup_existing_path(&dest_path, &backup_path, &install_rel_path)?; tx.backed_up.push(install_rel_path.clone()); } else { tx.created.push(install_rel_path.clone()); @@ -737,12 +1079,22 @@ pub fn install_atomic( // Remove destination if it exists (we backed it up) so we can overwrite if let Ok(dest_meta) = dest_path.symlink_metadata() { if dest_meta.file_type().is_dir() { - anyhow::bail!( - "Refusing to replace existing directory with packaged file/symlink: {}", - install_rel_path - ); + if !remove_set.contains(install_rel_path.as_str()) { + anyhow::bail!( + "Refusing to replace existing directory with packaged file/symlink: {}", + install_rel_path + ); + } + if !is_directory_empty(&dest_path)? { + anyhow::bail!( + "Refusing to replace existing non-empty directory with packaged file/symlink: {}", + install_rel_path + ); + } + fs::remove_dir(&dest_path)?; + } else { + fs::remove_file(&dest_path)?; } - fs::remove_file(&dest_path)?; } if file_type.is_symlink() { @@ -754,45 +1106,15 @@ pub fn install_atomic( .with_context(|| format!("Failed to install: {}", install_rel_path))?; apply_unix_mode(&dest_path, &metadata)?; } + staged_paths.insert(install_rel_path); } - // Remove obsolete files (update only) + // Remove obsolete files/directories left behind by the previous version. for rel in remove_paths { - let rel = rel.as_str(); - let dest_path = rootfs.join(rel); - let dest_meta = match dest_path.symlink_metadata() { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => { - return Err(e).with_context(|| { - format!("Failed to inspect obsolete path before removal: {}", rel) - }); - } - }; - - if dest_meta.file_type().is_dir() { - // Only obsolete files/symlinks are removed here. + if staged_paths.contains(rel) || tx.removed.iter().any(|removed| removed == rel) { continue; } - - let backup_path = tx.removed_backup_path(rel); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent)?; - } - - if dest_meta.file_type().is_symlink() { - let target = fs::read_link(&dest_path) - .with_context(|| format!("Failed to read obsolete symlink target: {}", rel))?; - std::os::unix::fs::symlink(&target, &backup_path) - .with_context(|| format!("Failed to backup removed symlink: {}", rel))?; - } else { - fs::copy(&dest_path, &backup_path) - .with_context(|| format!("Failed to backup removed file: {}", rel))?; - } - - fs::remove_file(&dest_path) - .with_context(|| format!("Failed to remove obsolete file/symlink: {}", rel))?; - tx.removed.push(rel.to_string()); + let _ = backup_and_remove_obsolete_path(&mut tx, rootfs, rel, false)?; } Ok(()) @@ -874,6 +1196,72 @@ mod tests { assert!(!destdir.join("usr/lib/libfoo.la").exists()); } + #[test] + fn process_splits_docs_into_docs_output() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/share/doc/foo")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/gtk-doc/html/foo")).unwrap(); + std::fs::create_dir_all(destdir.join("opt/foo-docs")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/share/doc/foo/README"), "doc").unwrap(); + std::fs::write(destdir.join("usr/share/gtk-doc/html/foo/index.html"), "gtk").unwrap(); + std::fs::write(destdir.join("opt/foo-docs/guide.txt"), "guide").unwrap(); + std::fs::write(destdir.join("usr/bin/foo"), "bin").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.build.flags.split_docs = true; + spec.build.flags.doc_dirs = vec!["/opt/foo-docs".to_string()]; + + process(&destdir, &spec).unwrap(); + + let docs_destdir = output_staging_dir(&destdir, "foo-docs"); + assert!(docs_destdir.join("usr/share/doc/foo/README").exists()); + assert!( + docs_destdir + .join("usr/share/gtk-doc/html/foo/index.html") + .exists() + ); + assert!(docs_destdir.join("opt/foo-docs/guide.txt").exists()); + assert!(destdir.join("usr/bin/foo").exists()); + assert!(!destdir.join("usr/share/doc/foo/README").exists()); + assert!( + !destdir + .join("usr/share/gtk-doc/html/foo/index.html") + .exists() + ); + assert!(!destdir.join("opt/foo-docs/guide.txt").exists()); + } + + #[test] + fn process_splits_docs_for_additional_outputs() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + let dev_destdir = output_staging_dir(&destdir, "foo-dev"); + std::fs::create_dir_all(dev_destdir.join("usr/share/doc/foo-dev")).unwrap(); + std::fs::create_dir_all(dev_destdir.join("usr/include")).unwrap(); + std::fs::write(dev_destdir.join("usr/share/doc/foo-dev/README"), "doc").unwrap(); + std::fs::write(dev_destdir.join("usr/include/foo.h"), "header").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.packages.push(PackageInfo { + name: "foo-dev".into(), + version: "1.0".into(), + revision: 1, + description: "dev".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }); + spec.build.flags.split_docs = true; + + process(&destdir, &spec).unwrap(); + + let docs_destdir = output_staging_dir(&destdir, "foo-dev-docs"); + assert!(docs_destdir.join("usr/share/doc/foo-dev/README").exists()); + assert!(dev_destdir.join("usr/include/foo.h").exists()); + assert!(!dev_destdir.join("usr/share/doc/foo-dev/README").exists()); + } + #[test] fn add_licenses_copies_common_files() { let tmp = tempfile::tempdir().unwrap(); @@ -1056,6 +1444,43 @@ mod tests { ); } + #[test] + #[cfg(unix)] + fn install_atomic_replaces_obsolete_directory_with_symlink() { + 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.join("usr/sbin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr")).unwrap(); + std::fs::write(rootfs.join("usr/sbin/legacy"), "old").unwrap(); + std::fs::write(destdir.join("usr/bin/legacy"), "new").unwrap(); + std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); + + let remove_paths = vec!["usr/sbin/legacy".to_string(), "usr/sbin".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + let sbin_meta = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(sbin_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(rootfs.join("usr/sbin")).unwrap(), + PathBuf::from("bin") + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/legacy")).unwrap(), + "new" + ); + + tx.rollback().unwrap(); + let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(restored.file_type().is_dir()); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/sbin/legacy")).unwrap(), + "old" + ); + } + #[test] fn keep_glob_matches_question_mark_and_not_path_separator() { assert!(glob_match_path(