new: scaffold new Debian source packages (non-interactive core)

This commit is contained in:
2026-09-16 12:14:09 +02:00
parent 9c3394750d
commit d044f757e9
12 changed files with 3243 additions and 1 deletions
+730
View File
@@ -0,0 +1,730 @@
//! 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;
use super::templates::{OutputFile, Template};
/// `3.0 (quilt)` source format, the pkh new default.
pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)";
/// `3.0 (native)` source format, selected by `--native`.
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"];
/// 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<std::path::PathBuf> {
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: &dyn Template) -> Vec<OutputFile> {
let mut files = vec![
source_format(opts),
changelog(opts),
control(opts, template),
rules(template),
copyright(opts),
debian_gitignore(opts),
];
if !opts.native {
files.push(local_options());
}
files
}
/// `debian/source/format`: `3.0 (quilt)` by default, `3.0 (native)` with
/// `--native`.
fn source_format(opts: &NewOptions) -> OutputFile {
OutputFile::new(
"debian/source/format",
format!(
"{}\n",
if opts.native {
SOURCE_FORMAT_NATIVE
} else {
SOURCE_FORMAT_QUILT
}
),
)
}
/// `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).
fn local_options() -> OutputFile {
OutputFile::new("debian/source/local-options", "single-debian-patch\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: &dyn 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");
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));
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()));
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 minimal `dh $@` makefile (plus the template's extra
/// overrides, when any), written with the executable bit.
fn rules(template: &dyn Template) -> OutputFile {
let mut contents = String::from("#!/usr/bin/make -f\n%:\n\tdh $@\n");
let extra = template.rules_extra();
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.
fn debian_gitignore(opts: &NewOptions) -> OutputFile {
OutputFile::new(
"debian/.gitignore",
format!(
"debian/files\n\
debian/.debhelper/\n\
debian/*.log\n\
debian/{}/\n\
debian/debhelper-build-stamp\n\
debian/*.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/",
];
/// Merge the root `.gitignore` entries into `existing` (the current file
/// contents, when there is one): missing entries are appended, an existing
/// file is never overwritten just to duplicate entries. Returns the new
/// contents, or `None` when nothing has to be written.
pub fn merge_root_gitignore(existing: Option<&str>) -> Option<String> {
let have: HashSet<&str> = existing
.map(|content| content.lines().map(str::trim).collect())
.unwrap_or_default();
let missing: Vec<&str> = ROOT_GITIGNORE_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() {
out.push_str("# pkh build artifacts\n");
}
for entry in missing {
out.push_str(entry);
out.push('\n');
}
Some(out)
}
/// Create `../<name>_<upstream_version>.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<std::path::PathBuf, Box<dyn std::error::Error>> {
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)?;
append_tree(&mut builder, tree, &prefix, 0)?;
builder
.finish()
.map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?;
log::info!(
"Created orig tarball {}",
crate::ui::display_path(&tarball_path)
);
Ok(tarball_path)
}
/// Recursively append `dir` to the archive under `archive_path`, skipping
/// the [`ORIG_EXCLUDE`] names and non-regular files.
fn append_tree(
builder: &mut Builder<XzEncoder<std::fs::File>>,
dir: &Path,
archive_path: &str,
depth: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
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" {
continue;
}
if ORIG_EXCLUDE.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)?;
} 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<dyn std::error::Error>> {
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(),
native: false,
git: true,
}
}
#[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"
);
let native = NewOptions {
native: true,
..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 <jane@example.com> ")
);
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("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 rules = super::rules(crate::new::templates::get(TemplateId::Shell).unwrap());
assert!(rules.executable);
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
}
#[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 <jane@example.com>\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,
"debian/files\ndebian/.debhelper/\ndebian/*.log\ndebian/mytool/\n\
debian/debhelper-build-stamp\ndebian/*.substvars\n"
);
}
#[test]
fn root_gitignore_merge() {
// Fresh file: header + all entries.
let fresh = merge_root_gitignore(None).unwrap();
assert!(fresh.starts_with("# pkh build artifacts\n"));
for entry in ROOT_GITIGNORE_ENTRIES {
assert!(fresh.contains(entry), "{entry} missing");
}
// Existing file: only the missing entries are appended, nothing lost.
let existing = "*.deb\nnode_modules/\n";
let merged = merge_root_gitignore(Some(existing)).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_root_gitignore(Some(&full)).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<String> = 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"));
}
#[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);
}
}