//! Generators for the common `debian/` files of a scaffolded package, plus //! the orig tarball creation. //! //! Everything here renders in memory as [`OutputFile`]s; the caller writes //! them all-or-nothing after checking for collisions (see //! [`super::scaffold`]). use std::collections::HashSet; use std::path::Path; use chrono::Datelike; use tar::Builder; use xz2::write::XzEncoder; use super::options::{NewOptions, SourceFormat}; use super::templates::{OutputFile, Template}; /// `3.0 (quilt)` source format, the default when packaging an existing /// project. pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)"; /// `3.0 (native)` source format, the default for a fresh skeleton. pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)"; /// The three source formats pkh knows how to build. pub const KNOWN_SOURCE_FORMATS: [&str; 3] = [SOURCE_FORMAT_QUILT, SOURCE_FORMAT_NATIVE, "1.0"]; /// Current Debian Policy version, written as the `Standards-Version` of the /// generated `debian/control` (mandatory in the source stanza per policy). /// Bump as policy evolves. pub const DEBIAN_POLICY_VERSION: &str = "4.7.4"; /// Directory and file names excluded from the orig tarball, at any depth of /// the tree. const ORIG_EXCLUDE: &[&str] = &[ ".git", "debian", "target", "node_modules", "__pycache__", ".venv", ]; /// Path of the orig tarball for `name`/`upstream_version` next to `tree`. pub fn orig_tarball_path( tree: &Path, name: &str, upstream_version: &str, ) -> Option { tree.parent() .map(|parent| parent.join(format!("{name}_{upstream_version}.orig.tar.xz"))) } /// Render every common `debian/` file of the package. pub fn files(opts: &NewOptions, template: &Template) -> Vec { let mut files = vec![ source_format(opts), changelog(opts), control(opts, template), rules(opts, template), copyright(opts), debian_gitignore(opts), ]; if opts.source_format == SourceFormat::Quilt { files.push(local_options()); } if opts.autopkgtest { files.push(autopkgtest_control()); files.push(autopkgtest_smoke(opts)); } if let Some(watch) = &opts.watch { files.push(OutputFile::new("debian/watch", watch.clone())); } files } /// `debian/tests/control`: the autopkgtest smoke test definition. fn autopkgtest_control() -> OutputFile { OutputFile::new( "debian/tests/control", "Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n", ) } /// `debian/tests/smoke`: run the installed command once; `--help` first, /// `--version` as the fallback (some tools only answer one of them). fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile { OutputFile::executable( "debian/tests/smoke", format!( "#!/bin/sh\n\ set -e\n\ {command} --help >/dev/null 2>&1 || {command} --version\n", command = opts.command, ), ) } /// `debian/source/format`: `3.0 (native)` for a skeleton by default, /// `3.0 (quilt)` for an existing project; either can be forced with /// `--native` / `--quilt`. fn source_format(opts: &NewOptions) -> OutputFile { OutputFile::new( "debian/source/format", format!("{}\n", opts.source_format.deb_string()), ) } /// `debian/source/local-options` with `single-debian-patch`, so later /// upstream-tree edits stay representable as one `debian/patches/debian-changes-*` /// patch instead of failing the build (quilt only). Common build-output /// directories are excluded from the delta as well: compiling locally before /// a source build must not turn `target/`, `node_modules/` or `.venv/` /// binaries into unrepresentable changes (dpkg ignores `__pycache__` and /// friends by default, but not those). fn local_options() -> OutputFile { OutputFile::new( "debian/source/local-options", "single-debian-patch\n\ extend-diff-ignore = ^target/\n\ extend-diff-ignore = ^node_modules/\n\ extend-diff-ignore = ^\\.venv/\n", ) } /// `debian/changelog`: the single initial entry, distribution UNRELEASED by /// default (the dh_make convention: a fresh package is by definition not /// ready for upload, and pkh skips signing for UNRELEASED), or the target /// series with `--release`. fn changelog(opts: &NewOptions) -> OutputFile { let distribution = if opts.release { opts.series.as_str() } else { crate::distro_info::UNRELEASED }; let date = chrono::Local::now().format("%a, %d %b %Y %H:%M:%S %z"); OutputFile::new( "debian/changelog", format!( "{name} ({version}) {distribution}; urgency=medium\n\ \n\ \x20 * Initial release.\n\ \n\ \x20-- {maintainer_name} <{maintainer_email}> {date}\n", name = opts.name, version = opts.full_version(), distribution = distribution, maintainer_name = opts.maintainer.0, maintainer_email = opts.maintainer.1, date = date, ), ) } /// Render a field whose values continue one per line (RFC822 continuation, /// one leading space, commas between values, first value on the field line): /// /// ```text /// Build-Depends: debhelper-compat (= 13), /// python3-all /// ``` fn render_field(name: &str, values: &[String]) -> String { let last = values.len() - 1; let mut out = format!("{}: {}", name, values[0]); if last > 0 { out.push(','); } out.push('\n'); for (i, value) in values.iter().enumerate().skip(1) { out.push_str(&format!(" {value}")); if i != last { out.push(','); } out.push('\n'); } out } /// Render a free-text field body (long description, license paragraphs): /// every line as a continuation, blank lines as ` .` (the deb822 encoding). fn render_continuation_text(text: &str) -> String { let mut out = String::new(); for line in text.lines() { if line.trim().is_empty() { out.push_str(" .\n"); } else { out.push_str(&format!(" {line}\n")); } } out } /// `debian/control`: one source stanza plus one binary stanza. /// /// The binary package name is the source package name, the architecture /// comes from the template (`all` for shell/empty), and a non-empty /// `opts.depends` (the empty/metapackage flavor) lands in the binary /// stanza's `Depends` field. fn control(opts: &NewOptions, template: &Template) -> OutputFile { let mut control = String::new(); // Source stanza. control.push_str(&format!("Source: {}\n", opts.name)); control.push_str("Section: utils\n"); control.push_str("Priority: optional\n"); control.push_str(&format!( "Maintainer: {} <{}>\n", opts.maintainer.0, opts.maintainer.1 )); control.push_str("Rules-Requires-Root: no\n"); control.push_str(&format!("Standards-Version: {DEBIAN_POLICY_VERSION}\n")); let mut build_depends = vec!["debhelper-compat (= 13)".to_string()]; build_depends.extend(template.build_depends(opts)); control.push_str(&render_field("Build-Depends", &build_depends)); for (key, value) in template.source_fields(opts) { control.push_str(&format!("{key}: {value}\n")); } if let Some(homepage) = &opts.homepage { control.push_str(&format!("Homepage: {homepage}\n")); } control.push('\n'); // Binary stanza. control.push_str(&format!("Package: {}\n", opts.name)); control.push_str(&format!("Architecture: {}\n", template.architecture(opts))); if !opts.depends.is_empty() { control.push_str(&render_field("Depends", &opts.depends)); } control.push_str(&format!("Description: {}\n", opts.summary)); control.push_str(&render_continuation_text(&opts.long_description)); OutputFile::new("debian/control", control) } /// `debian/rules`: the shebang and `%:` target whose recipe is the /// template's dh line (plus the template's extra overrides, when any), /// written with the executable bit. fn rules(opts: &NewOptions, template: &Template) -> OutputFile { let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line()); let extra = template.rules_extra(opts); if !extra.is_empty() { contents.push('\n'); contents.push_str(&extra); if !contents.ends_with('\n') { contents.push('\n'); } } OutputFile::executable("debian/rules", contents) } /// Short license-reference paragraph embedded in `debian/copyright`. fn license_reference_paragraph(license: &super::options::License) -> String { use super::options::License; match license { License::Custom(s) if s.eq_ignore_ascii_case("unknown") => { "The licensing terms of this package are not known yet. \ Replace this paragraph with a proper license reference." .to_string() } License::Custom(s) => format!( "The package is distributed under the terms of the '{s}' license. \ Replace this paragraph with the full license reference." ), known => format!( "The package is distributed under the terms of the {} license. \ The full license text is available at <{}>.", known.spdx(), known.spdx_url() ), } } /// `debian/copyright` in the DEP-5 machine-readable format: header, the /// `Files: *` stanza covering the current year, and a standalone license /// stanza with a short reference paragraph. fn copyright(opts: &NewOptions) -> OutputFile { let year = chrono::Local::now().year(); let mut out = String::new(); out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"); out.push_str(&format!("Upstream-Name: {}\n", opts.name)); if let Some(homepage) = &opts.homepage { out.push_str(&format!("Source: {homepage}\n")); } out.push('\n'); out.push_str("Files: *\n"); out.push_str(&format!( "Copyright: {} {} <{}>\n", year, opts.maintainer.0, opts.maintainer.1 )); out.push_str(&format!("License: {}\n", opts.license.spdx())); out.push_str(&render_continuation_text(&license_reference_paragraph( &opts.license, ))); out.push('\n'); out.push_str(&format!("License: {}\n", opts.license.spdx())); out.push_str(&render_continuation_text(&license_reference_paragraph( &opts.license, ))); OutputFile::new("debian/copyright", out) } /// `debian/.gitignore`: the debhelper build artifacts. The patterns are /// relative to `debian/` itself (a `debian/`-prefixed pattern would be /// anchored to `debian/debian/` inside this file, per gitignore(5)). fn debian_gitignore(opts: &NewOptions) -> OutputFile { OutputFile::new( "debian/.gitignore", format!( "files\n\ .debhelper/\n\ *.log\n\ {}/\n\ debhelper-build-stamp\n\ *.substvars\n", opts.name ), ) } /// Entries of the root `.gitignore` written in skeleton mode (build /// artifacts, next to the tree). pub const ROOT_GITIGNORE_ENTRIES: [&str; 6] = [ "*.deb", "*.dsc", "*.changes", "*.buildinfo", "*.tar.xz", "target/", ]; /// Comment heading a root `.gitignore` freshly created by pkh (the skeleton /// build-artifact section). pub const ROOT_GITIGNORE_HEADER: &str = "# pkh build artifacts"; /// Merge `entries` into the root `.gitignore` contents `existing` (the /// current file contents, when there is one): missing entries are appended, /// an existing file is never overwritten just to duplicate entries. A fresh /// file is headed by the `header` comment when one is given; appending to a /// user file adds bare entries. Returns the new contents, or `None` when /// nothing has to be written. pub fn merge_gitignore_entries( existing: Option<&str>, entries: &[&str], header: Option<&str>, ) -> Option { let have: HashSet<&str> = existing .map(|content| content.lines().map(str::trim).collect()) .unwrap_or_default(); let missing: Vec<&str> = entries .iter() .copied() .filter(|entry| !have.contains(entry)) .collect(); if missing.is_empty() { return None; } let mut out = existing.unwrap_or("").to_string(); if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } // Section comment only for a fresh file; appending to a user file adds // bare entries. if existing.is_none() && let Some(header) = header { out.push_str(header); out.push('\n'); } for entry in missing { out.push_str(entry); out.push('\n'); } Some(out) } /// Create `../_.orig.tar.xz` containing the tree, /// excluding `debian/` and VCS/build directories, so the first /// `dpkg-source -b` (quilt) succeeds immediately. Refuses to overwrite an /// existing tarball. pub fn create_orig_tarball( tree: &Path, name: &str, upstream_version: &str, ) -> Result> { create_orig_tarball_excluding(tree, name, upstream_version, false) } /// [`create_orig_tarball`] with the generated `vendor/` directory of a /// vendored rust package excluded from the snapshot: its contents travel in /// the separate `_.orig-vendor.tar.xz` component instead (see /// [`super::orig`]), so they can be regenerated independently of the /// upstream sources. pub fn create_orig_tarball_excluding( tree: &Path, name: &str, upstream_version: &str, exclude_vendor: bool, ) -> Result> { let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| { format!( "cannot determine the parent directory of '{}'", tree.display() ) })?; if tarball_path.exists() { return Err(format!( "'{}' already exists: pkh new refuses to overwrite it. \ Remove it first, or pass --native to skip the orig tarball.", tarball_path.display() ) .into()); } let file = std::fs::File::create(&tarball_path)?; let encoder = XzEncoder::new(file, 6); let mut builder = Builder::new(encoder); // Deterministic-ish ordering: sort entries by name at every level. let prefix = format!("{name}-{upstream_version}"); // The single top-level directory dpkg-source expects. builder.append_dir(&prefix, tree)?; let top_excludes: &[&str] = if exclude_vendor { &["vendor"] } else { &[] }; append_tree(&mut builder, tree, &prefix, 0, ORIG_EXCLUDE, top_excludes)?; builder .finish() .map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?; log::info!( "Created orig tarball {}", crate::report::display_path(&tarball_path) ); Ok(tarball_path) } /// Recursively append `dir` to the archive under `archive_path`, skipping /// non-regular files, the names of `excludes` at any depth, the `debian/` /// directory and the names of `top_excludes` at the top level (depth 0). pub(crate) fn append_tree( builder: &mut Builder>, dir: &Path, archive_path: &str, depth: usize, excludes: &[&str], top_excludes: &[&str], ) -> Result<(), Box> { let mut entries: Vec = std::fs::read_dir(dir)?.collect::>()?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let path = entry.path(); let file_name = entry.file_name(); let name = file_name.to_string_lossy().into_owned(); if depth == 0 && (name == "debian" || top_excludes.contains(&name.as_str())) { continue; } if excludes.contains(&name.as_str()) { continue; } let entry_archive_path = format!("{archive_path}/{name}"); let metadata = std::fs::metadata(&path) .map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?; if metadata.is_dir() { builder.append_dir(&entry_archive_path, &path)?; append_tree( builder, &path, &entry_archive_path, depth + 1, excludes, top_excludes, )?; } else if metadata.is_file() { // The mode (including the exec bit) travels through the header. let mut header = tar::Header::new_gnu(); header.set_metadata(&metadata); header.set_size(metadata.len()); let file = std::fs::File::open(&path) .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?; builder .append_data(&mut header, &entry_archive_path, file) .map_err(|e| format!("cannot add '{}' to the tarball: {}", path.display(), e))?; } else { // Sockets, fifos, devices have no business in an orig tarball. log::warn!( "Skipping non-regular file '{}' while creating the orig tarball", path.display() ); } } Ok(()) } /// Write an in-memory file list to `tree`, creating parent directories and /// applying the executable bit. Callers must have checked collisions first. pub(crate) fn write_files( tree: &Path, files: &[OutputFile], ) -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; for file in files { let path = tree.join(&file.path); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, &file.contents)?; if file.executable { let mut permissions = std::fs::metadata(&path)?.permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&path, permissions)?; } log::debug!("Wrote {}", path.display()); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::new::options::{License, SourceDir, TemplateId}; fn opts() -> NewOptions { NewOptions { name: "mytool".into(), template: TemplateId::SHELL, source_dir: SourceDir::Skeleton, upstream_version: "0.1.0".into(), revision: 1, summary: "A tool that does one thing well".into(), long_description: "A tool that does one thing well".into(), homepage: Some("https://example.com/mytool".into()), license: License::Mit, command: "mytool".into(), maintainer: ("Jane Doe".into(), "jane@example.com".into()), dist: "ubuntu".into(), series: "resolute".into(), release: false, depends: Vec::new(), source_format: SourceFormat::Quilt, orig: None, git: true, autopkgtest: false, pkg_config: false, watch: None, } } #[test] fn source_format_and_local_options() { let o = opts(); let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap()); let find = |path: &str| { files .iter() .find(|f| f.path == path) .unwrap_or_else(|| panic!("{path} missing")) }; assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n"); assert_eq!( find("debian/source/local-options").contents, "single-debian-patch\n\ extend-diff-ignore = ^target/\n\ extend-diff-ignore = ^node_modules/\n\ extend-diff-ignore = ^\\.venv/\n" ); let native = NewOptions { source_format: SourceFormat::Native, ..opts() }; let files = super::files( &native, crate::new::templates::get(TemplateId::SHELL).unwrap(), ); assert!( files .iter() .all(|f| f.path != "debian/source/local-options") ); assert_eq!( files .iter() .find(|f| f.path == "debian/source/format") .unwrap() .contents, "3.0 (native)\n" ); } #[test] fn changelog_rendering_and_parse() { let o = opts(); let changelog = super::changelog(&o); assert_eq!(changelog.path, "debian/changelog"); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("changelog"); std::fs::write(&path, &changelog.contents).unwrap(); let (source, version, distribution) = crate::changelog::parse_changelog_header(&path).unwrap(); assert_eq!(source, "mytool"); assert_eq!(version, "0.1.0-1"); assert_eq!(distribution, "UNRELEASED"); // dpkg-style zero-padded RFC2822 date in the trailer. assert!( changelog .contents .contains(" -- Jane Doe ") ); let date_line = changelog .contents .lines() .find(|l| l.starts_with(" -- ")) .unwrap(); let date = date_line.rsplit_once(" ").unwrap().1; // `%d` is zero-padded: positions 5-6 must be the two-digit day // (e.g. "Tue, 05 Sep 2026 ..."). assert!(date[5..7].bytes().all(|b| b.is_ascii_digit())); // --release writes the target series. let released = NewOptions { release: true, ..opts() }; std::fs::write(&path, super::changelog(&released).contents).unwrap(); let (_, _, distribution) = crate::changelog::parse_changelog_header(&path).unwrap(); assert_eq!(distribution, "resolute"); } #[test] fn control_rendering_and_parse() { let o = opts(); let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap()); // RFC822 continuation: first dep on the field line, the rest indented. assert!( control .contents .contains("Build-Depends: debhelper-compat (= 13)\n") ); let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap(); assert_eq!(parsed.source_name(), "mytool"); assert_eq!(parsed.source.get("Section"), Some("utils")); assert_eq!(parsed.source.get("Priority"), Some("optional")); assert_eq!(parsed.source.get("Rules-Requires-Root"), Some("no")); assert_eq!( parsed.source.get("Standards-Version"), Some(DEBIAN_POLICY_VERSION) ); assert_eq!( parsed.source.get("Homepage"), Some("https://example.com/mytool") ); assert_eq!(parsed.binaries.len(), 1); assert_eq!(parsed.binaries[0].get("Package"), Some("mytool")); assert_eq!(parsed.binaries[0].get("Architecture"), Some("all")); assert_eq!( parsed.binaries[0].get("Description"), Some("A tool that does one thing well\nA tool that does one thing well") ); // Without homepage both the control Homepage field and the DEP-5 // Source field are absent (the stanza's leading `Source:` line is // still there of course). let o = NewOptions { homepage: None, ..opts() }; let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap()); assert!(!control.contents.contains("Homepage:")); let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap(); assert!(parsed.source.get("Homepage").is_none()); } #[test] fn rules_is_executable_minimal_makefile() { let o = opts(); let rules = super::rules(&o, crate::new::templates::get(TemplateId::SHELL).unwrap()); assert!(rules.executable); assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n"); } #[test] fn extra_files_autopkgtest_and_watch() { let mut o = opts(); o.autopkgtest = true; o.watch = Some( "version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n" .to_string(), ); let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap()); let find = |path: &str| { files .iter() .find(|f| f.path == path) .unwrap_or_else(|| panic!("{path} missing")) }; let control = find("debian/tests/control"); assert_eq!( control.contents, "Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n" ); let smoke = find("debian/tests/smoke"); assert!(smoke.executable); assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n")); assert!( smoke .contents .contains("mytool --help >/dev/null 2>&1 || mytool --version") ); assert_eq!( find("debian/watch").contents, "version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n" ); // Without the extras none of the files are rendered. let plain = super::files( &opts(), crate::new::templates::get(TemplateId::SHELL).unwrap(), ); assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests"))); assert!(!plain.iter().any(|f| f.path == "debian/watch")); } #[test] fn copyright_is_dep5() { let c = super::copyright(&opts()); assert!(c.contents.starts_with( "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n" )); assert!(c.contents.contains("Upstream-Name: mytool\n")); assert!(c.contents.contains("Source: https://example.com/mytool\n")); assert!(c.contents.contains("Files: *\n")); assert!(c.contents.contains("License: MIT\n")); assert!(c.contents.contains(&format!( "Copyright: {} Jane Doe \n", chrono::Local::now().year() ))); assert!(c.contents.contains("https://spdx.org/licenses/MIT.html")); // Unknown license: honest reference paragraph, still valid deb822. let o = NewOptions { license: License::Custom("unknown".into()), ..opts() }; let c = super::copyright(&o); assert!(c.contents.contains("not known yet")); assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3); } #[test] fn debian_gitignore_contents() { let g = super::debian_gitignore(&opts()); assert_eq!( g.contents, "files\n.debhelper/\n*.log\nmytool/\n\ debhelper-build-stamp\n*.substvars\n" ); } #[test] fn gitignore_merge() { // Fresh file: header + all entries. let fresh = merge_gitignore_entries(None, &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER)) .unwrap(); assert!(fresh.starts_with("# pkh build artifacts\n")); for entry in ROOT_GITIGNORE_ENTRIES { assert!(fresh.contains(entry), "{entry} missing"); } // A fresh file without a header carries the bare entries. assert_eq!( merge_gitignore_entries(None, &["a/", "b"], None).unwrap(), "a/\nb\n" ); // Existing file: only the missing entries are appended, nothing lost. let existing = "*.deb\nnode_modules/\n"; let merged = merge_gitignore_entries( Some(existing), &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER), ) .unwrap(); assert!(merged.starts_with(existing)); assert!(merged.contains("*.dsc\n")); assert!(!merged.contains("*.deb\n*.deb")); // Everything already there: nothing to write. let full: String = ROOT_GITIGNORE_ENTRIES .iter() .map(|e| format!("{e}\n")) .collect(); assert!( merge_gitignore_entries( Some(&full), &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER), ) .is_none() ); } /// The vendoring entries of the rust template merge into an existing /// user `.gitignore` like any other entry set: appended after the /// user's lines, no header comment, idempotent. #[test] fn gitignore_merge_appends_template_entries() { let entries = ["vendor/", ".cargo/config.toml"]; let merged = merge_gitignore_entries(Some("# my project\n*.log\n"), &entries, None).unwrap(); assert_eq!(merged, "# my project\n*.log\nvendor/\n.cargo/config.toml\n"); // Already ignored: nothing to write. assert!( merge_gitignore_entries(Some("vendor/\n.cargo/config.toml\n"), &entries, None) .is_none() ); } #[test] fn orig_tarball_layout() { let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("mytool"); std::fs::create_dir_all(tree.join("debian")).unwrap(); std::fs::create_dir_all(tree.join("target")).unwrap(); std::fs::create_dir_all(tree.join("src/nested")).unwrap(); std::fs::write(tree.join("debian/control"), "control").unwrap(); std::fs::write(tree.join("target/artifact"), "junk").unwrap(); std::fs::write(tree.join("src/nested/code.txt"), "code").unwrap(); let tarball = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap(); assert_eq!(tarball, dir.path().join("mytool_0.1.0.orig.tar.xz")); assert!(tarball.exists()); let file = std::fs::File::open(&tarball).unwrap(); let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(file)); let mut names: Vec = archive .entries() .unwrap() .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned()) .collect(); // The tree prefix and the nested file are there... assert!( names .iter() .any(|n| n.trim_end_matches('/') == "mytool-0.1.0") ); assert!( names .iter() .any(|n| n == "mytool-0.1.0/src/nested/code.txt") ); // ...but debian/, target/ and other excluded names are not. assert!(!names.iter().any(|n| n.contains("debian"))); assert!(!names.iter().any(|n| n.contains("target"))); names.sort(); } #[test] fn orig_tarball_refuses_overwrite() { let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("mytool"); std::fs::create_dir_all(&tree).unwrap(); std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"existing").unwrap(); let err = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap_err(); assert!(err.to_string().contains("already exists")); } /// The vendored-rust variant excludes the top-level `vendor/` (it /// travels in the orig-vendor component) but keeps unrelated trees. #[test] fn orig_tarball_vendor_exclusion() { let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("mytool"); std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap(); std::fs::create_dir_all(tree.join("src/vendor")).unwrap(); std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap(); std::fs::write(tree.join("src/vendor/mod.rs"), "code").unwrap(); std::fs::write(tree.join("Cargo.toml"), "[package]").unwrap(); let tarball = create_orig_tarball_excluding(&tree, "mytool", "0.1.0", true).unwrap(); let mut archive = tar::Archive::new(xz2::read::XzDecoder::new( std::fs::File::open(&tarball).unwrap(), )); let names: Vec = archive .entries() .unwrap() .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned()) .collect(); // The generated vendored tree is out... assert!( !names.iter().any(|n| n.starts_with("mytool-0.1.0/vendor")), "{names:?}" ); // ...an unrelated nested vendor/ stays in... assert!( names.iter().any(|n| n == "mytool-0.1.0/src/vendor/mod.rs"), "{names:?}" ); // ...and normal files are unaffected. assert!( names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"), "{names:?}" ); } #[test] fn write_files_sets_exec_bit_and_parents() { let dir = tempfile::tempdir().unwrap(); let files = vec![ OutputFile::new("a/b/c.txt", "deep"), OutputFile::executable("debian/rules", "#!/usr/bin/make -f\n"), ]; write_files(dir.path(), &files).unwrap(); assert_eq!( std::fs::read_to_string(dir.path().join("a/b/c.txt")).unwrap(), "deep" ); use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(dir.path().join("debian/rules")) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o777, 0o755); } }