diff --git a/src/lib.rs b/src/lib.rs index e022e15..5aa8cad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,8 @@ pub mod debian; pub mod distro_info; /// Launchpad integration: PPA upload targets and account discovery pub mod launchpad; +/// Lint a source tree: lintian wrapper for full parity plus pkh-native checks (`pkh lint`) +pub mod lint; /// Scaffold a new Debian source package (`pkh new`) pub mod new; /// Obtain information about one or multiple packages diff --git a/src/lint/check.rs b/src/lint/check.rs new file mode 100644 index 0000000..16c874b --- /dev/null +++ b/src/lint/check.rs @@ -0,0 +1,106 @@ +//! The check trait and the static check registry. +//! +//! Checks are zero-sized structs, one module per packaging area, registered +//! in one explicit array — reviewable, greppable, and free of proc macros. +//! A registry test fails the build on duplicate tag names, undocumented +//! tags or casing drift, so the catalog cannot rot silently. + +use crate::lint::collect::LintData; +use crate::lint::emit::Emitter; +use crate::lint::tag::Tag; + +/// A group of checks over one area of the packaging (control, changelog, +/// git workflow, ...). One check instance may emit any of the tags it +/// declares; the emitter resolves metadata and applies suppression. +pub trait Check: Sync { + /// Registry identifier, also the `--check` value (one word, e.g. `pkh-git`). + fn id(&self) -> &'static str; + + /// Static metadata of every tag this check may emit. + fn tags(&self) -> &'static [Tag]; + + /// Run against the collected package information, reporting findings + /// through `emit`. + fn run(&self, data: &LintData, emit: &mut Emitter); +} + +/// Every registered check, in catalog order. +pub static CHECKS: &[&dyn Check] = &[&super::checks::pkh::PkhGit as &dyn Check]; + +/// Look up a registered check by its id (`--check` value). +pub fn find_check(id: &str) -> Option<&'static dyn Check> { + CHECKS.iter().copied().find(|check| check.id() == id) +} + +/// Look up tag metadata by tag name across the whole registry. +pub fn find_tag(name: &str) -> Option<&'static Tag> { + CHECKS + .iter() + .flat_map(|check| check.tags()) + .find(|tag| tag.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_tag_names_are_unique_and_well_formed() { + let mut seen = Vec::new(); + for tag in CHECKS.iter().flat_map(|check| check.tags()) { + assert!( + !seen.contains(&tag.name), + "duplicate tag name: {}", + tag.name + ); + seen.push(tag.name); + let valid = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || "+.-".contains(c); + assert!( + tag.name.starts_with(valid) + && tag.name.chars().all(valid) + && !tag.name.ends_with('-'), + "tag name is not kebab-case: {}", + tag.name + ); + assert!( + !tag.description.trim().is_empty(), + "tag without a description: {}", + tag.name + ); + } + } + + #[test] + fn check_ids_are_unique_and_resolvable() { + let mut seen = Vec::new(); + for check in CHECKS { + assert!( + !seen.contains(&check.id()), + "duplicate check id: {}", + check.id() + ); + seen.push(check.id()); + assert!( + find_check(check.id()).is_some(), + "find_check cannot resolve its own registry: {}", + check.id() + ); + } + } + + #[test] + fn pkh_native_tags_are_namespaced() { + for check in CHECKS { + if check.id().starts_with("pkh-") { + for tag in check.tags() { + assert!( + tag.name.starts_with("pkh-"), + "pkh-native check '{}' emits non-namespaced tag '{}'", + check.id(), + tag.name + ); + } + } + } + } +} diff --git a/src/lint/checks/mod.rs b/src/lint/checks/mod.rs new file mode 100644 index 0000000..a5c846b --- /dev/null +++ b/src/lint/checks/mod.rs @@ -0,0 +1,4 @@ +//! Check areas; one module per area, registered in [`crate::lint::check::CHECKS`]. + +/// Pkh-native workflow checks (git-centric trees, PPA uploads). +pub mod pkh; diff --git a/src/lint/checks/pkh.rs b/src/lint/checks/pkh.rs new file mode 100644 index 0000000..d6d06fd --- /dev/null +++ b/src/lint/checks/pkh.rs @@ -0,0 +1,148 @@ +//! Pkh-native checks: workflow knowledge lintian cannot have, because it +//! lives in pkh's flows (git-centric trees, PPA uploads, scaffolding). + +use crate::lint::check::Check; +use crate::lint::collect::LintData; +use crate::lint::emit::Emitter; +use crate::lint::tag::{Certainty, Severity, Tag}; + +/// Metadata of every tag the pkh-native checks emit. +pub static TAGS: &[Tag] = &[Tag { + name: "pkh-debian-changes-not-committed", + severity: Severity::Warning, + certainty: Certainty::Certain, + experimental: false, + description: "The debian/ directory contains changes that are not committed to git. \ +pkh builds and uploads the tree as-is (pkh deb, pkh put); committing first keeps \ +the upload and the git history in sync.", + references: &[], +}]; + +/// Flags `debian/` content that exists in the tree but is not committed to +/// git. Skips trees outside any git repository: archive-pulled sources +/// legitimately have none. +pub struct PkhGit; + +impl Check for PkhGit { + fn id(&self) -> &'static str { + "pkh-git" + } + + fn tags(&self) -> &'static [Tag] { + TAGS + } + + fn run(&self, data: &LintData, emit: &mut Emitter) { + let Some(git) = &data.git else { + return; + }; + if git.dirty_debian.is_empty() { + return; + } + let examples: Vec<&str> = git + .dirty_debian + .iter() + .take(3) + .map(String::as_str) + .collect(); + let more = if git.dirty_debian.len() > examples.len() { + ", ..." + } else { + "" + }; + emit.tag( + "pkh-debian-changes-not-committed", + format!( + "{} uncommitted change(s) under debian/ (e.g. {}{more})", + git.dirty_debian.len(), + examples.join(", ") + ), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn write(path: &Path, content: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + fn commit_all(repo: &git2::Repository) { + let signature = git2::Signature::now("pkh test", "test@example.com").unwrap(); + let mut index = repo.index().unwrap(); + index + .add_all(["*"], git2::IndexAddOption::DEFAULT, None) + .unwrap(); + // write_tree alone does not persist the index; without this, a + // committed worktree still reads as index-deleted + untracked. + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[]) + .unwrap(); + } + + #[test] + fn no_git_repository_is_no_finding() { + let dir = tempfile::tempdir().unwrap(); + write(&dir.path().join("debian/control"), "Source: hello\n"); + let data = LintData::collect(dir.path(), None, None); + assert!(data.git.is_none()); + + let mut findings = Vec::new(); + let mut emitter = Emitter::new(data.source_name.clone(), &mut findings); + PkhGit.run(&data, &mut emitter); + assert!(findings.is_empty()); + } + + #[test] + fn clean_worktree_is_no_finding() { + let dir = tempfile::tempdir().unwrap(); + write(&dir.path().join("debian/control"), "Source: hello\n"); + let repo = git2::Repository::init(dir.path()).unwrap(); + commit_all(&repo); + + let data = LintData::collect(dir.path(), None, None); + let mut findings = Vec::new(); + let mut emitter = Emitter::new(data.source_name.clone(), &mut findings); + PkhGit.run(&data, &mut emitter); + assert!(findings.is_empty()); + } + + #[test] + fn dirty_debian_tree_is_a_finding() { + let dir = tempfile::tempdir().unwrap(); + write(&dir.path().join("debian/control"), "Source: hello\n"); + write(&dir.path().join("hello.txt"), "upstream\n"); + let repo = git2::Repository::init(dir.path()).unwrap(); + commit_all(&repo); + + // A modified tracked file and a fresh untracked patch: both count. + write( + &dir.path().join("debian/control"), + "Source: hello\nDepends: x\n", + ); + write(&dir.path().join("debian/patches/new.patch"), "...\n"); + write(&dir.path().join("hello.txt"), "changed upstream\n"); + + let data = LintData::collect(dir.path(), None, None); + let mut findings = Vec::new(); + let mut emitter = Emitter::new(data.source_name.clone(), &mut findings); + PkhGit.run(&data, &mut emitter); + assert_eq!(findings.len(), 1); + let finding = &findings[0]; + assert_eq!(finding.tag_name, "pkh-debian-changes-not-committed"); + assert_eq!(finding.letter, 'W'); + assert!( + finding + .message + .starts_with("2 uncommitted change(s) under debian/") + ); + assert!(finding.message.contains("debian/control")); + assert!(finding.message.contains("debian/patches/new.patch")); + } +} diff --git a/src/lint/collect.rs b/src/lint/collect.rs new file mode 100644 index 0000000..ac60375 --- /dev/null +++ b/src/lint/collect.rs @@ -0,0 +1,98 @@ +//! Collectors: package information gathered once per run and shared by every +//! check, mirroring lintian's collection phase without the on-disk lab. +//! Source trees are small, so everything is computed eagerly except git +//! status, which only exists when the tree is a git worktree. + +use std::path::{Path, PathBuf}; + +use crate::debian::control::ControlInfo; + +/// Git worktree state relevant to pkh's git-centric workflow checks. +pub struct GitStatus { + /// Repo-relative paths under `debian/` with uncommitted content + /// (modified, staged or untracked), sorted. + pub dirty_debian: Vec, +} + +/// Everything the checks and the report renderer know about the linted tree. +pub struct LintData { + /// Root of the source tree being linted. + pub root: PathBuf, + /// Source package name, resolved from `debian/control`, else from the + /// changelog's first line, else the directory name. + pub source_name: String, + /// Target distribution (`--dist`); unresolved when None. + pub dist: Option, + /// Target series (`--series`); unresolved when None. + pub series: Option, + /// Git worktree state; None when the tree is not inside a git repository. + pub git: Option, +} + +impl LintData { + /// Collect information about the source tree at `root`. + pub fn collect(root: &Path, dist: Option<&str>, series: Option<&str>) -> LintData { + LintData { + root: root.to_path_buf(), + source_name: resolve_source_name(root), + dist: dist.map(str::to_string), + series: series.map(str::to_string), + git: collect_git(root), + } + } +} + +/// Source package name from `debian/control`, falling back to the changelog +/// header and then the directory name; the report needs a display name even +/// for broken trees. +fn resolve_source_name(root: &Path) -> String { + if let Ok(control) = ControlInfo::parse(&root.join("debian/control")) { + return control.source_name().to_string(); + } + if let Ok(changelog) = std::fs::read_to_string(root.join("debian/changelog")) + && let Some(first) = changelog.lines().next() + && let Some(name) = first.split(" (").next() + && !name.trim().is_empty() + { + return name.trim().to_string(); + } + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "package".to_string()) +} + +/// Dirty paths under `debian/` when the tree lives in a git worktree. +/// Untracked files count: a fresh patch nobody committed is exactly the +/// mistake the workflow checks exist to catch. None when there is no +/// repository (or git is somehow unusable here) — never a finding, since +/// archive-pulled trees legitimately have none. +fn collect_git(root: &Path) -> Option { + let repo = git2::Repository::discover(root).ok()?; + let workdir = repo.workdir()?; + // Statuses are workdir-relative; trees nested inside a repository only + // care about their own slice of it. + let prefix = root.strip_prefix(workdir).unwrap_or(Path::new("")); + let debian_dir = prefix.join("debian"); + + let mut options = git2::StatusOptions::new(); + options + .include_untracked(true) + .include_ignored(false) + .recurse_untracked_dirs(true); + let statuses = repo.statuses(Some(&mut options)).ok()?; + + let mut dirty_debian = Vec::new(); + for entry in statuses.iter() { + // CURRENT is the zero flag in libgit2, so any non-empty status is + // some kind of change (worktree or staged). + if entry.status().is_empty() { + continue; + } + let path = entry.path()?; + if Path::new(path).starts_with(&debian_dir) { + dirty_debian.push(path.to_string()); + } + } + dirty_debian.sort(); + Some(GitStatus { dirty_debian }) +} diff --git a/src/lint/emit.rs b/src/lint/emit.rs new file mode 100644 index 0000000..3f2f601 --- /dev/null +++ b/src/lint/emit.rs @@ -0,0 +1,118 @@ +//! Findings, the run report, and the emitter checks report through. +//! +//! The emitter is deliberately thin: checks name a tag and give a message; +//! metadata, output letter and explanations come from the registry, so +//! findings from the native engine and findings parsed from the wrapped +//! lintian share one shape and one rendering path. + +use crate::lint::check; +use crate::lint::tag::Tag; + +/// Where a finding comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + /// Emitted by pkh's native Rust checks. + Native, + /// Parsed from the wrapped lintian's output. + Lintian, +} + +/// One lint finding, whatever produced it. +#[derive(Debug, Clone)] +pub struct Finding { + /// Output letter (`E`, `W`, `I`, `P`, `X`, `O`, `C`): severity for + /// native findings, verbatim from the output line in wrapper mode. + pub letter: char, + /// Stable tag name (`missing-debian-copyright-file`). + pub tag_name: String, + /// Free-form details after the tag name; empty when the tag stands alone. + pub message: String, + /// Package the finding belongs to (`hello`). + pub package: String, + /// Processable type lintian displays after the package name (`source`, + /// `changes`, ...); None for binary findings, which lintian prints + /// without a type. + pub processable_type: Option, + /// Explanation lines shown by `--info`: the `N:` blocks lintian attaches + /// to the finding in wrapper mode, or the native tag's description and + /// references. + pub explanation: Vec, + /// Native or parsed-from-lintian. + pub origin: Origin, +} + +impl Finding { + /// Lowercase severity/classification name for this finding's letter, as + /// used in `--fail-on` values and JSON output. + pub fn severity_name(&self) -> &'static str { + match self.letter { + 'E' => "error", + 'W' => "warning", + 'I' => "info", + 'P' => "pedantic", + 'X' => "experimental", + 'O' => "overridden", + _ => "classification", + } + } +} + +/// The full result of one lint run: every finding, from every source. +pub struct LintReport { + /// All findings in emission order: the wrapper's first, then the native + /// ones (deduplicated by tag name against the wrapper's). + pub findings: Vec, + /// Display name of the linted source package. + pub source_name: String, + /// The wrapper was skipped because lintian is not installed; the + /// renderer prints a notice and the run is native-only. + pub wrapper_unavailable: bool, + /// Run-level notes (`N:` lines): how the linted artifact was obtained, + /// and similar context that is not a finding. + pub notes: Vec, +} + +/// Sink checks report findings through. Resolves tag metadata from the +/// registry so checks only ever name the tag they mean. +pub struct Emitter<'a> { + package: String, + findings: &'a mut Vec, +} + +impl<'a> Emitter<'a> { + /// Emitter for findings of `package` (e.g. the source package name), + /// appending into `findings`. + pub fn new(package: String, findings: &'a mut Vec) -> Emitter<'a> { + Emitter { package, findings } + } + + /// Emit `tag_name` with `message` as its detail line. Native findings + /// always report on the source package; an unknown tag name (a registry + /// bug) still produces a finding rather than panicking — the registry + /// test makes that path unreachable in practice. + pub fn tag(&mut self, tag_name: &str, message: impl Into) { + let (letter, explanation) = match check::find_tag(tag_name) { + Some(tag) => (tag.letter(), explanation_of(tag)), + None => ('E', Vec::new()), + }; + self.findings.push(Finding { + letter, + tag_name: tag_name.to_string(), + message: message.into(), + package: self.package.clone(), + processable_type: Some("source".to_string()), + explanation, + origin: Origin::Native, + }); + } +} + +/// The explanation `--info` shows for a native tag: its description plus any +/// references, prefixed like lintian's "Please refer to" pointers. +fn explanation_of(tag: &Tag) -> Vec { + let mut lines = vec![tag.description.to_string()]; + for reference in tag.references { + lines.push(format!("Please refer to {}", reference)); + } + lines +} diff --git a/src/lint/mod.rs b/src/lint/mod.rs new file mode 100644 index 0000000..75b150b --- /dev/null +++ b/src/lint/mod.rs @@ -0,0 +1,186 @@ +//! `pkh lint`: lint a Debian source tree, with lintian feature parity. +//! +//! The strategy is *wrap first, port second* (see `plans/pkh-lint.md`): the +//! [wrapper](wrapper) runs the installed lintian over an ephemeral source +//! package for day-one parity with every lintian check, while the native +//! engine (check registry, collectors, emitter) hosts pkh-specific workflow +//! checks (`pkh-*` tags) and grows ported lintian checks incrementally. +//! Both findings merge into one report — deduplicated by tag name, which is +//! why native checks mirror lintian's tag names for equivalent checks — +//! rendered as lintian-shaped text or JSON, with lintian's exit-code +//! contract (0 clean, 1 findings at/above `--fail-on`, 2 runtime error). + +pub mod check; +pub mod checks; +pub mod collect; +pub mod emit; +pub mod output; +pub mod tag; +pub mod wrapper; + +use std::collections::HashSet; +use std::path::PathBuf; + +use crate::lint::check::Check; +use crate::lint::collect::LintData; +use crate::lint::emit::{Finding, LintReport}; + +/// Knobs of one `pkh lint` run, built from the CLI in `main.rs`. +pub struct LintOptions { + /// Source tree to lint. + pub path: PathBuf, + /// Run the native engine only; never invoke the lintian wrapper. + pub native: bool, + /// Levels that make the exit code 1 (`--fail-on`, default: error). + pub fail_on: Vec, + /// Show tag explanations under each finding (`--info`, lintian's `-i`). + pub info: bool, + /// Also display info-level (`I:`) findings (lintian's `-I`). + pub display_info: bool, + /// Also display pedantic (`P:`) findings. + pub pedantic: bool, + /// Also display experimental (`X:`) findings. + pub experimental: bool, + /// Also display overridden (`O:`) findings. + pub show_overrides: bool, + /// Tag names to ignore for this run. + pub suppress_tags: Vec, + /// Run only these native checks (`--check`, repeatable). + pub only_checks: Vec, + /// Ignore any existing `pkh build` output and pack the tree fresh. + pub repack: bool, + /// Emit JSON instead of text. + pub json: bool, + /// Text colorization mode (`--color`, default auto: TTY without NO_COLOR). + pub color: output::ColorMode, + /// Target distribution (debian/ubuntu), when known. + pub dist: Option, + /// Target series, when known. + pub series: Option, +} + +/// Run one lint: collect package information, run the native checks, wrap +/// lintian (unless `--native`), and merge everything into one report. +pub fn run(options: &LintOptions) -> Result { + let data = LintData::collect( + &options.path, + options.dist.as_deref(), + options.series.as_deref(), + ); + let mut report = LintReport { + findings: Vec::new(), + source_name: data.source_name.clone(), + wrapper_unavailable: false, + notes: Vec::new(), + }; + + if !options.native { + match wrapper::run(&data.root, options)? { + Some(outcome) => { + report.findings.extend(outcome.findings); + report.notes = outcome.notes; + } + None => report.wrapper_unavailable = true, + } + } + + let selected: Vec<&'static dyn Check> = if options.only_checks.is_empty() { + check::CHECKS.to_vec() + } else { + options + .only_checks + .iter() + .map(|id| check::find_check(id).ok_or_else(|| format!("Unknown --check '{id}'"))) + .collect::>()? + }; + + let mut native = Vec::new(); + { + let mut emitter = emit::Emitter::new(report.source_name.clone(), &mut native); + for check in selected { + check.run(&data, &mut emitter); + } + } + report.findings.extend(merge(&report.findings, native)); + + if !options.suppress_tags.is_empty() { + report + .findings + .retain(|finding| !options.suppress_tags.contains(&finding.tag_name)); + } + Ok(report) +} + +/// Append `native` findings to `lintian`'s, dropping native duplicates by +/// tag name (the wrapper's verdict wins for tags both engines produce). +fn merge(lintian: &[Finding], native: Vec) -> Vec { + let known: HashSet<&str> = lintian.iter().map(|f| f.tag_name.as_str()).collect(); + native + .into_iter() + .filter(|finding| !known.contains(finding.tag_name.as_str())) + .collect() +} + +/// The `--list-tags` catalog: one line per registered native tag. +pub fn list_tags() -> String { + let mut out = String::new(); + for check in check::CHECKS { + for tag in check.tags() { + out.push_str(&format!( + "{} [{}] {}\n {}\n", + tag.letter(), + check.id(), + tag.name, + tag.description + )); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lint::emit::Origin; + + fn finding(tag: &str, origin: Origin) -> Finding { + Finding { + letter: 'W', + tag_name: tag.to_string(), + message: String::new(), + package: "hello".to_string(), + processable_type: Some("source".to_string()), + explanation: Vec::new(), + origin, + } + } + + #[test] + fn merge_drops_native_duplicates_by_tag_name() { + let lintian = vec![finding("no-debian-copyright-in-source", Origin::Lintian)]; + let native = vec![ + finding("no-debian-copyright-in-source", Origin::Native), + finding("pkh-debian-changes-not-committed", Origin::Native), + ]; + let merged = merge(&lintian, native); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].tag_name, "pkh-debian-changes-not-committed"); + assert_eq!(merged[0].origin, Origin::Native); + } + + #[test] + fn native_tags_resolve_from_the_registry() { + let tag = check::find_tag("pkh-debian-changes-not-committed") + .expect("pkh check tags must resolve"); + assert_eq!(tag.letter(), 'W'); + } + + #[test] + fn unknown_native_tags_still_produce_findings() { + let mut sink = Vec::new(); + let mut emitter = emit::Emitter::new("hello".to_string(), &mut sink); + emitter.tag("no-such-tag-anywhere", "boom"); + assert_eq!(sink.len(), 1); + assert_eq!(sink[0].tag_name, "no-such-tag-anywhere"); + } +} diff --git a/src/lint/output.rs b/src/lint/output.rs new file mode 100644 index 0000000..12ee6d9 --- /dev/null +++ b/src/lint/output.rs @@ -0,0 +1,450 @@ +//! Rendering of a lint report (text and JSON) and the exit-code decision. +//! +//! Text output keeps lintian's line shape (`: : +//!
`) so findings read identically in both modes; JSON is the +//! schema-stable form for CI. Display filtering (what is shown) and the +//! `--fail-on` threshold (what makes the exit code 1) are independent, like +//! lintian's. Colorization happens here, at render time — findings are +//! captured plain (`--color never` is passed to lintian) and re-painted by +//! severity, so colors are a renderer concern that survives the switch from +//! wrapped lintian output to native checks. + +use crossterm::style::Stylize; +use serde_json::json; + +use crate::lint::LintOptions; +use crate::lint::emit::{Finding, LintReport, Origin}; + +/// When to colorize the text report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorMode { + /// Colorize when stdout is a terminal and `NO_COLOR` is unset. + #[default] + Auto, + /// Always colorize (piping, CI logs). + Always, + /// Never colorize. + Never, +} + +impl ColorMode { + /// Parse one `--color` keyword. + pub fn parse(word: &str) -> Option { + match word { + "auto" => Some(ColorMode::Auto), + "always" => Some(ColorMode::Always), + "never" => Some(ColorMode::Never), + _ => None, + } + } + + /// Whether the text renderer should emit ANSI colors. + fn should_color(self) -> bool { + match self { + ColorMode::Always => true, + ColorMode::Never => false, + ColorMode::Auto => { + std::io::IsTerminal::is_terminal(&std::io::stdout()) + && std::env::var_os("NO_COLOR").is_none() + } + } + } +} + +/// The output letter, painted with its severity color when `color` is set: +/// errors red (bold), warnings yellow, info cyan, pedantic/experimental +/// magenta, overridden green. Mirrors lintian's tty palette closely enough +/// for muscle memory. +fn paint_letter(letter: char, color: bool) -> String { + if !color { + return letter.to_string(); + } + match letter { + 'E' => "E".red().bold().to_string(), + 'W' => "W".yellow().to_string(), + 'I' => "I".cyan().to_string(), + 'P' => "P".magenta().to_string(), + 'X' => "X".magenta().to_string(), + 'O' => "O".green().to_string(), + _ => letter.to_string(), + } +} + +/// A named severity class, mirroring the values lintian's `--fail-on` +/// accepts. `Experimental` is the `X:` pseudo-level (the letter hides the +/// underlying severity) and `Overridden` lets gates count suppressed tags. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// `E:` findings. + Error, + /// `W:` findings. + Warning, + /// `I:` findings. + Info, + /// `P:` findings. + Pedantic, + /// `X:` findings. + Experimental, + /// `O:` findings (only reported when overrides are shown). + Overridden, +} + +impl Level { + /// The output letter the level stands for. + pub fn letter(self) -> char { + match self { + Level::Error => 'E', + Level::Warning => 'W', + Level::Info => 'I', + Level::Pedantic => 'P', + Level::Experimental => 'X', + Level::Overridden => 'O', + } + } + + /// Parse one comma-separated `--fail-on` keyword. + pub fn parse(word: &str) -> Option { + match word.trim() { + "error" => Some(Level::Error), + "warning" => Some(Level::Warning), + "info" => Some(Level::Info), + "pedantic" => Some(Level::Pedantic), + "experimental" => Some(Level::Experimental), + "override" => Some(Level::Overridden), + _ => None, + } + } +} + +/// Parse the whole `--fail-on` value (comma-separated level names). +pub fn parse_fail_on(spec: &str) -> Result, String> { + let levels: Vec = spec + .split(',') + .filter(|word| !word.trim().is_empty()) + .map(Level::parse) + .collect::>() + .ok_or_else(|| { + format!( + "Invalid --fail-on value '{spec}': expected comma-separated \ + error, warning, info, pedantic, experimental or override" + ) + })?; + if levels.is_empty() { + return Err("Empty --fail-on value: expected at least one level".to_string()); + } + Ok(levels) +} + +/// The letter a finding displays as for display-level purposes: overridden +/// findings carry their `O:` letter, classification tags are JSON-only. +fn is_displayed(finding: &Finding, options: &LintOptions) -> bool { + match finding.letter { + 'E' | 'W' => true, + 'I' => options.display_info, + 'P' => options.pedantic, + 'X' => options.experimental, + 'O' => options.show_overrides, + _ => false, + } +} + +/// Exit code of the run: 1 when any finding (except classification tags) is +/// at a level listed in `--fail-on`, 0 otherwise. Runtime failures never +/// reach this function — the caller exits 2 directly. +pub fn exit_code(report: &LintReport, options: &LintOptions) -> i32 { + let failed = report + .findings + .iter() + .any(|finding| match level_of_letter(finding.letter) { + Some(level) => options.fail_on.contains(&level), + None => false, + }); + if failed { 1 } else { 0 } +} + +/// The level a finding's letter maps to for `--fail-on` purposes; None for +/// classification tags, which lintian also never fails on. +fn level_of_letter(letter: char) -> Option { + match letter { + 'E' => Some(Level::Error), + 'W' => Some(Level::Warning), + 'I' => Some(Level::Info), + 'P' => Some(Level::Pedantic), + 'X' => Some(Level::Experimental), + 'O' => Some(Level::Overridden), + _ => None, + } +} + +/// Render the report as lintian-shaped text: one `: : +///
` line per displayed finding, explanations under `--info`, then +/// an `N:` summary line. +pub fn render_text(report: &LintReport, options: &LintOptions) -> String { + let color = options.color.should_color(); + let mut out = String::new(); + if report.wrapper_unavailable { + out.push_str( + "N: lintian is not installed; showing pkh-native checks only \ + (install lintian for full check coverage)\n", + ); + } + for note in &report.notes { + out.push_str(&format!("N: {note}\n")); + } + + let mut shown_counts = [('E', 0), ('W', 0), ('I', 0), ('P', 0), ('X', 0), ('O', 0)]; + let mut shown = 0; + for finding in &report.findings { + if !is_displayed(finding, options) { + continue; + } + shown += 1; + if let Some((_, count)) = shown_counts + .iter_mut() + .find(|(letter, _)| *letter == finding.letter) + { + *count += 1; + } + out.push_str(&format!( + "{}: {}\n", + paint_letter(finding.letter, color), + line_subject(finding) + )); + if options.info { + for line in &finding.explanation { + if line.is_empty() { + out.push_str("N:\n"); + } else { + out.push_str(&format!("N: {}\n", line.trim_end())); + } + } + } + } + + let mut parts = Vec::new(); + for (letter, count) in shown_counts { + if count > 0 { + parts.push(format!("{} {}", count, paint_letter(letter, color))); + } + } + let hidden = report.findings.len() - shown; + if parts.is_empty() { + out.push_str(&format!("N: no displayed tags; {hidden} hidden\n")); + } else { + out.push_str(&format!( + "N: {shown} tag(s) shown ({}); {hidden} hidden\n", + parts.join(", ") + )); + } + out +} + +/// `hello source: tag details` — the part of a lintian line after the +/// letter, reproduced identically for both origins. +fn line_subject(finding: &Finding) -> String { + let mut line = String::from(&finding.package); + if let Some(ptype) = &finding.processable_type { + line.push(' '); + line.push_str(ptype); + } + line.push_str(": "); + line.push_str(&finding.tag_name); + if !finding.message.is_empty() { + line.push(' '); + line.push_str(&finding.message); + } + line +} + +/// Render the report as pretty-printed JSON: every finding (displayed or +/// not, flagged as such) plus a summary carrying the exit-code verdict. +pub fn render_json(report: &LintReport, options: &LintOptions) -> String { + let findings: Vec = report + .findings + .iter() + .map(|finding| { + json!({ + "letter": finding.letter.to_string(), + "tag": finding.tag_name, + "severity": finding.severity_name(), + "package": finding.package, + "processable_type": finding.processable_type, + "message": finding.message, + "origin": match finding.origin { + Origin::Native => "native", + Origin::Lintian => "lintian", + }, + "overridden": finding.letter == 'O', + "displayed": is_displayed(finding, options), + "explanation": if finding.explanation.is_empty() { + json!(null) + } else { + json!(finding.explanation) + }, + }) + }) + .collect(); + + let document = json!({ + "source": report.source_name, + "wrapper_unavailable": report.wrapper_unavailable, + "notes": report.notes, + "findings": findings, + "summary": { + "failed": exit_code(report, options) == 1, + }, + }); + serde_json::to_string_pretty(&document).expect("lint report JSON is serializable") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(fail_on: &[Level]) -> LintOptions { + LintOptions { + path: std::path::PathBuf::from("."), + native: false, + fail_on: fail_on.to_vec(), + info: false, + display_info: false, + pedantic: false, + experimental: false, + show_overrides: false, + suppress_tags: Vec::new(), + only_checks: Vec::new(), + repack: false, + json: false, + color: ColorMode::Never, + dist: None, + series: None, + } + } + + fn report(findings: &[(&str, char)]) -> LintReport { + LintReport { + findings: findings + .iter() + .map(|(tag, letter)| Finding { + letter: *letter, + tag_name: (*tag).to_string(), + message: String::new(), + package: "hello".to_string(), + processable_type: Some("source".to_string()), + explanation: Vec::new(), + origin: Origin::Lintian, + }) + .collect(), + source_name: "hello".to_string(), + wrapper_unavailable: false, + notes: Vec::new(), + } + } + + #[test] + fn fail_on_parses_and_rejects_unknown_levels() { + assert_eq!( + parse_fail_on("error, warning").unwrap(), + vec![Level::Error, Level::Warning] + ); + assert!(parse_fail_on("bogus").is_err()); + assert!(parse_fail_on(" ").is_err()); + } + + #[test] + fn exit_code_only_counts_displayed_severity_letters() { + let default_opts = options(&[Level::Error]); + // Errors fail, warnings alone do not (the default threshold). + assert_eq!(exit_code(&report(&[("t", 'E')]), &default_opts), 1); + assert_eq!( + exit_code(&report(&[("t", 'W'), ("u", 'I')]), &default_opts), + 0 + ); + // Overridden findings never fail unless explicitly requested. + assert_eq!(exit_code(&report(&[("t", 'O')]), &default_opts), 0); + assert_eq!( + exit_code( + &report(&[("t", 'O')]), + &options(&[Level::Error, Level::Overridden]) + ), + 1 + ); + // Classification tags never fail. + assert_eq!(exit_code(&report(&[("t", 'C')]), &default_opts), 0); + } + + #[test] + fn text_rendering_respects_display_levels() { + let base = report(&[("e", 'E'), ("i", 'I'), ("o", 'O'), ("x", 'X')]); + let default = render_text(&base, &options(&[Level::Error])); + assert!(default.contains("E: hello source: e")); + assert!(!default.contains("I: ")); + assert!(!default.contains("O: ")); + assert!(!default.contains("X: ")); + assert!(default.ends_with("N: 1 tag(s) shown (1 E); 3 hidden\n")); + + let everything = LintOptions { + display_info: true, + experimental: true, + show_overrides: true, + ..options(&[Level::Error]) + }; + let full = render_text(&base, &everything); + for prefix in ["E:", "I:", "O:", "X:"] { + assert!( + full.contains(&format!("{prefix} hello source:")), + "{prefix}" + ); + } + assert!(full.ends_with("N: 4 tag(s) shown (1 E, 1 I, 1 X, 1 O); 0 hidden\n")); + } + + #[test] + fn colorization_is_render_only_and_mode_switchable() { + let run_report = report(&[("e", 'E'), ("w", 'W'), ("o", 'O')]); + + let plain = options(&[Level::Error]); + let mut forced = options(&[Level::Error]); + forced.color = ColorMode::Always; + + let plain = render_text(&run_report, &plain); + let colored = render_text(&run_report, &forced); + assert!( + !plain.contains('\u{1b}'), + "never/auto-on-pipe must stay plain" + ); + assert!(colored.contains('\u{1b}'), "always must colorize"); + // Colors wrap the letters only; the lintian line shape is intact. + assert!(colored.contains("hello source: e")); + // Mode parsing round-trips. + assert_eq!(ColorMode::parse("always"), Some(ColorMode::Always)); + assert_eq!(ColorMode::parse("bogus"), None); + } + + #[test] + fn json_round_trips_with_verdicts() { + let run_report = report(&[("e", 'E'), ("w", 'W')]); + let default_opts = options(&[Level::Error]); + let document: serde_json::Value = + serde_json::from_str(&render_json(&run_report, &default_opts)).unwrap(); + assert_eq!(document["source"], "hello"); + assert_eq!(document["findings"][0]["letter"], "E"); + assert_eq!(document["findings"][0]["severity"], "error"); + assert_eq!(document["findings"][0]["origin"], "lintian"); + assert_eq!(document["summary"]["failed"], true); + + // An error finding fails at the warning threshold too, but not at + // pedantic-only, which nothing in this report reaches. + let warn_only = options(&[Level::Warning]); + assert_eq!(exit_code(&run_report, &warn_only), 1); + let document: serde_json::Value = + serde_json::from_str(&render_json(&run_report, &warn_only)).unwrap(); + assert_eq!(document["summary"]["failed"], true); + + let pedantic_only = options(&[Level::Pedantic]); + assert_eq!(exit_code(&run_report, &pedantic_only), 0); + let document: serde_json::Value = + serde_json::from_str(&render_json(&run_report, &pedantic_only)).unwrap(); + assert_eq!(document["summary"]["failed"], false); + } +} diff --git a/src/lint/tag.rs b/src/lint/tag.rs new file mode 100644 index 0000000..b14a700 --- /dev/null +++ b/src/lint/tag.rs @@ -0,0 +1,85 @@ +//! Lint tag model: severities, certainties and static tag metadata. +//! +//! Tags are the atomic diagnostics of a lint run, named after lintian's +//! model: a stable machine-readable name (`missing-debian-copyright-file`), +//! a severity, a certainty and a description. Checks declare the tags they +//! may emit as static [`Tag`] values; the wrapper's parsed findings carry the +//! letter lintian printed instead. + +/// Severity of a finding, matching lintian's severity ladder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Policy violation or broken package data (`E:`). + Error, + /// Probable bug or policy deviation (`W:`). + Warning, + /// Informational note about packaging choices (`I:`). + Info, + /// Nitpick most packages may legitimately ignore (`P:`). + Pedantic, +} + +impl Severity { + /// The output letter lintian displays this severity as (`E`, `W`, ...). + pub fn letter(self) -> char { + match self { + Severity::Error => 'E', + Severity::Warning => 'W', + Severity::Info => 'I', + Severity::Pedantic => 'P', + } + } + + /// Lowercase name used in `--fail-on` values and JSON output. + pub fn name(self) -> &'static str { + match self { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "info", + Severity::Pedantic => "pedantic", + } + } +} + +/// How sure a check is that a finding is real. Not acted upon yet (a future +/// `--fail-on error,certain` would consume it), but captured from day one so +/// severity tuning is data-driven later. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Certainty { + /// The finding is a fact (e.g. a missing file). + Certain, + /// The finding is likely but has legitimate exceptions. + Possible, + /// The finding is a guess from weak signals. + WildGuess, +} + +/// Static metadata of one tag: what checks declare and the renderer resolves. +#[derive(Debug)] +pub struct Tag { + /// Stable machine-readable name, lintian-compatible kebab-case + /// (`pkh-debian-changes-not-committed`). + pub name: &'static str, + /// Severity the tag reports at. + pub severity: Severity, + /// How sure checks are when emitting this tag. + pub certainty: Certainty, + /// Whether the tag is experimental (`X:` output, hidden by default). + pub experimental: bool, + /// One-paragraph explanation, shown by `--info` and `--list-tags`. + pub description: &'static str, + /// References (policy sections, URLs) shown by `--info`. + pub references: &'static [&'static str], +} + +impl Tag { + /// The output letter for this tag: experimental tags render as `X:` + /// regardless of their severity, like lintian. + pub fn letter(&self) -> char { + if self.experimental { + 'X' + } else { + self.severity.letter() + } + } +} diff --git a/src/lint/wrapper.rs b/src/lint/wrapper.rs new file mode 100644 index 0000000..066c311 --- /dev/null +++ b/src/lint/wrapper.rs @@ -0,0 +1,531 @@ +//! The lintian wrapper: pkh's day-one feature-parity layer. +//! +//! Lintian only accepts built package files, never source trees, so the +//! wrapper needs a source artifact. It lints the `pkh build` output next to +//! the tree when it matches the current changelog entry and nothing in the +//! tree is newer than it (the fast path: no packing at all); otherwise it +//! packs the tree fresh with `dpkg-source -b` inside a temporary directory +//! and lints the resulting `.dsc` (`--repack` forces that path). Nothing is +//! written back into the linted tree; the temp directory is removed on drop. +//! +//! Parsing is load-bearing here (findings are merged with the native ones, +//! rendered uniformly and exported as JSON), so the parser is pinned by +//! golden tests captured from real lintian output. Lintian's own exit code +//! is *not* authoritative: lintian uses 2 both for "fail-on met" and for +//! runtime errors, while pkh derives the verdict from the parsed findings +//! and reserves 2 for actual runtime failures. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use regex::Regex; + +use crate::lint::LintOptions; +use crate::lint::emit::{Finding, Origin}; + +/// What one wrapper run produced: the parsed lintian findings plus `N:` +/// notes about how the artifact being linted was obtained. +pub struct WrapperOutcome { + /// Lintian's findings. + pub findings: Vec, + /// Human-visible notes (artifact reuse, fresh packing) rendered as `N:`. + pub notes: Vec, +} + +/// Run the wrapper over the source tree at `root`: +/// - `Ok(Some(outcome))` — lintian ran; these are its findings and notes, +/// - `Ok(None)` — lintian is not installed; the caller falls back to +/// native-only with a notice, +/// - `Err(message)` — runtime failure (tree unpacked badly, lintian crashed +/// without reportable output); the caller exits 2. +/// +/// The artifact linted is the `pkh build` output next to the tree when it +/// matches the current changelog entry and no tree content is newer; +/// otherwise the tree is packed fresh (with `--repack` forcing that path). +pub fn run(root: &Path, options: &LintOptions) -> Result, String> { + let tmp = TempDir::new()?; + let root = root + .canonicalize() + .map_err(|e| format!("Cannot lint '{}': {e}", root.display()))?; + + let (dsc, notes) = match usable_build_output(&root, options.repack) { + Some(dsc) => { + let note = format!( + "linting pkh build output {}", + crate::report::display_path(&dsc) + ); + (dsc, vec![note]) + } + None => { + let dsc = pack(&root, &tmp)?; + let reason = if options.repack { + "the tree was packed fresh with dpkg-source (--repack ignored \ + the existing pkh build output)" + .to_string() + } else if expected_build_output(&root).is_some() { + "the tree changed since pkh build, so it was packed fresh \ + with dpkg-source (rerun pkh build to lint the build output)" + .to_string() + } else { + "no pkh build output next to the tree, so it was packed fresh \ + with dpkg-source (pkh build produces one)" + .to_string() + }; + (dsc, vec![reason]) + } + }; + + let mut lintian = Command::new("lintian"); + lintian + .env("LC_ALL", "C") + .args(["--no-cfg", "--color", "never"]) + .arg("--info") + .arg(&dsc); + // Per-distro scoping: lintian auto-detects the *host* vendor, but pkh + // knows the *target* distro (-d); make the two agree, which matters on + // cross-distro hosts (linting an Ubuntu package on Debian or back). + if let Some(dist) = &options.dist + && matches!(dist.as_str(), "ubuntu" | "debian") + { + lintian.arg("--profile").arg(dist); + } + if options.display_info { + lintian.arg("--display-info"); + } + if options.pedantic { + lintian.arg("--pedantic"); + } + if options.experimental { + lintian.arg("--display-experimental"); + } + if options.show_overrides { + lintian.arg("--show-overrides"); + } + if !options.suppress_tags.is_empty() { + lintian + .arg("--suppress-tags") + .arg(options.suppress_tags.join(",")); + } + + let output = match lintian.output() { + Ok(output) => output, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("Could not run lintian: {e}")), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let findings = parse(&stdout); + if !output.status.success() && findings.is_empty() { + return Err(format!( + "lintian exited with {} without reporting findings:\n{}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(Some(WrapperOutcome { findings, notes })) +} + +/// Pack the tree into an ephemeral source package and return its `.dsc`. +/// +/// Compression is deliberately weak (`-Zgzip -z1`): the artifact only ever +/// goes to lintian and is deleted with the temp directory, and xz on a +/// large tree dominates the whole run (measured: 9.8 s xz vs 2.7 s gzip on +/// a 111 MB tree). Locale-independent subprocess output: dpkg messages can +/// be localized, and the error sniffing relies on English wording. +fn pack(root: &Path, tmp: &TempDir) -> Result { + // 3.0 (quilt) trees need the orig tarball(s) reachable from the working + // directory, and dpkg-source searches cwd — link them from the tree's + // parent, where pkh build / git ubuntu export-orig leave them. + link_orig_tarballs(root, tmp.path()); + + let output = Command::new("dpkg-source") + .env("LC_ALL", "C") + .args(["-b", "-Zgzip", "-z1"]) + .arg(root) + .current_dir(tmp.path()) + .output() + .map_err(|e| format!("Could not run dpkg-source: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.to_lowercase().contains("uncommitted") { + return Err( + "The source tree has uncommitted changes that dpkg-source refuses \ + to pack. Commit them first, or use --native to lint with pkh's \ + native checks only." + .to_string(), + ); + } + return Err(format!( + "dpkg-source -b failed, the tree may not be a valid source package:\n{}", + stderr.trim() + )); + } + + find_dsc(tmp.path()) +} + +/// The `pkh build` output matching the tree's current changelog entry, when +/// it exists and no tree content is newer than it: linting a stale artifact +/// would report the packaging of the past, so staleness forces a fresh pack. +fn usable_build_output(root: &Path, force_repack: bool) -> Option { + if force_repack { + return None; + } + let dsc = expected_build_output(root)?; + let built = std::fs::metadata(&dsc).ok()?.modified().ok()?; + if tree_newer_than(root, built) { + return None; + } + Some(dsc) +} + +/// The `pkh build` output path matching the tree's current changelog entry +/// (`../_.dsc`, pkh build's own naming), if it exists. +fn expected_build_output(root: &Path) -> Option { + let entry = + crate::debian::changelog::parse_changelog_entry(&root.join("debian/changelog")).ok()?; + let dsc = root + .parent()? + .join(format!("{}_{}.dsc", entry.source, entry.version.no_epoch())); + std::fs::metadata(&dsc).ok()?; + Some(dsc) +} + +/// Whether any tree content is newer than `built`. Skips `.git` and `.pc`: +/// commits and quilt bookkeeping churn their mtimes without touching what +/// the source package contains. +fn tree_newer_than(root: &Path, built: SystemTime) -> bool { + const SKIP: &[&str] = &[".git", ".pc"]; + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if SKIP.iter().any(|skip| name.to_string_lossy() == *skip) { + continue; + } + match entry.file_type() { + Ok(ft) if ft.is_dir() => stack.push(entry.path()), + _ => { + let newer = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .is_some_and(|modified| modified > built); + if newer { + return true; + } + } + } + } + } + false +} + +/// Symlink the orig (and orig component) tarballs for the tree's upstream +/// version from the tree's parent into `dest`, ignoring absence — native +/// trees have none, and missing tarballs surface as a dpkg-source error. +fn link_orig_tarballs(root: &Path, dest: &Path) { + let Some(parent) = root.parent() else { + return; + }; + let Ok(entry) = crate::debian::changelog::parse_changelog_entry(&root.join("debian/changelog")) + else { + return; + }; + let prefixes = [ + format!("{}_{}.orig.tar.", entry.source, entry.version.upstream), + format!("{}_{}.orig-", entry.source, entry.version.upstream), + ]; + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for candidate in entries.flatten() { + let name = candidate.file_name(); + let name = name.to_string_lossy(); + if !candidate.file_type().is_ok_and(|ft| ft.is_file()) + || !prefixes + .iter() + .any(|prefix| name.starts_with(prefix.as_str())) + { + continue; + } + let _ = std::os::unix::fs::symlink(parent.join(name.as_ref()), dest.join(name.as_ref())); + } +} + +/// Parse lintian's output into findings. Tag lines carry the finding; `N:` +/// note lines following a tag line are its explanation (`--info` output) and +/// attach to it. Anything else is ignored. +fn parse(text: &str) -> Vec { + let tag_line = Regex::new(r"^(?P[EWIPXOC]): (?P.*)$").expect("static regex"); + let subject = Regex::new( + r"^(?P\S+?)(?: (?Psource|binary|udeb|changes|buildinfo))?: (?P\S+)(?: (?P
.*))?$", + ) + .expect("static regex"); + let note_line = Regex::new(r"^N:(?: (?P.*))?$").expect("static regex"); + + let mut findings: Vec = Vec::new(); + for line in text.lines() { + if let Some(note) = note_line.captures(line) { + // Attach to the finding above, like lintian lays out --info. + if let (Some(text), Some(last)) = (note.name("text"), findings.last_mut()) + && !text.as_str().trim().is_empty() + { + last.explanation.push(text.as_str().trim().to_string()); + } + continue; + } + let Some(head) = tag_line.captures(line) else { + continue; + }; + let Some(subject) = subject.captures(&head["rest"]) else { + continue; + }; + findings.push(Finding { + letter: head["letter"].chars().next().unwrap_or('E'), + tag_name: subject["tag"].to_string(), + message: subject + .name("details") + .map_or(String::new(), |d| d.as_str().to_string()), + package: subject["pkg"].to_string(), + processable_type: subject.name("ptype").map(|p| p.as_str().to_string()), + explanation: Vec::new(), + origin: Origin::Lintian, + }); + } + findings +} + +/// The single `.dsc` the ephemeral source package produced. +fn find_dsc(dir: &Path) -> Result { + let mut entries: Vec = std::fs::read_dir(dir) + .map_err(|e| format!("Cannot read the temporary build directory: {e}"))? + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "dsc")) + .collect(); + match entries.pop() { + Some(dsc) => Ok(dsc), + None => Err( + "dpkg-source produced no .dsc; the tree may not be a valid source package".to_string(), + ), + } +} + +/// Temporary directory removed on drop; the hand-rolled stand-in for +/// `tempfile`, which is dev-only in this crate. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Result { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("pkh-lint-{}-{unique}", std::process::id())); + std::fs::create_dir(&path) + .map_err(|e| format!("Could not create a temporary directory: {e}"))?; + Ok(TempDir(path)) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Captured from lintian 2.129.0ubuntu2.1 on a broken native source + /// package (default display level). + const SOURCE_OUTPUT: &str = "\ +E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1] +E: hello source: package-uses-debhelper-but-lacks-build-depends [debian/rules] +W: hello source: debhelper-but-no-misc-depends hello +W: hello source: debhelper-compat-file-is-missing +W: hello source: no-debian-copyright-in-source +"; + + /// Captured from lintian 2.129.0ubuntu2.1 on a binary package: binary + /// findings carry no processable type after the package name. + const BINARY_OUTPUT: &str = "\ +E: badpkg: description-too-short test +E: badpkg: extended-description-is-empty +W: badpkg: empty-binary-package +W: badpkg: recommended-field badpkg_1.0-1_all.deb Priority +"; + + /// Captured with --show-overrides: overridden findings print as `O:`. + const OVERRIDDEN_OUTPUT: &str = "\ +E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1] +O: hello source: debhelper-compat-file-is-missing +"; + + /// Captured with --info: each tag line is followed by `N:` explanation + /// lines that belong to it. + const INFO_OUTPUT: &str = "\ +N: +E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1] +N: +N: The version string in the latest changelog entry was not parsed correctly. +N: Usually, that means it does not conform to policy. +N: +N: +E: hello source: package-uses-debhelper-but-lacks-build-depends [debian/rules] +N: +N: If a package uses debhelper, it must declare a Build-Depends on debhelper +N: or on the debhelper-compat virtual package. For example: +N: +"; + + #[test] + fn parses_source_output_with_type() { + let findings = parse(SOURCE_OUTPUT); + assert_eq!(findings.len(), 5); + let first = &findings[0]; + assert_eq!(first.letter, 'E'); + assert_eq!(first.package, "hello"); + assert_eq!(first.processable_type.as_deref(), Some("source")); + assert_eq!(first.tag_name, "malformed-debian-changelog-version"); + assert_eq!(first.message, "0.1-1 (for native) [debian/changelog:1]"); + assert_eq!(findings[2].letter, 'W'); + } + + #[test] + fn parses_binary_output_without_type() { + let findings = parse(BINARY_OUTPUT); + assert_eq!(findings.len(), 4); + let first = &findings[0]; + assert_eq!(first.package, "badpkg"); + assert_eq!(first.processable_type, None); + assert_eq!(first.tag_name, "description-too-short"); + // Details containing a file name with dots survive intact. + assert_eq!(findings[3].message, "badpkg_1.0-1_all.deb Priority"); + } + + #[test] + fn parses_overridden_lines() { + let findings = parse(OVERRIDDEN_OUTPUT); + assert_eq!(findings.len(), 2); + assert_eq!(findings[1].letter, 'O'); + assert_eq!(findings[1].tag_name, "debhelper-compat-file-is-missing"); + assert_eq!(findings[1].message, ""); + } + + #[test] + fn attaches_info_notes_to_the_preceding_finding() { + let findings = parse(INFO_OUTPUT); + assert_eq!(findings.len(), 2); + assert_eq!( + findings[0].explanation, + vec![ + "The version string in the latest changelog entry was not parsed correctly.", + "Usually, that means it does not conform to policy.", + ] + ); + assert_eq!(findings[1].explanation.len(), 2); + assert!(findings[1].explanation[0].starts_with("If a package uses debhelper")); + } + + #[test] + fn ignores_stray_lines() { + let findings = + parse("N: lintian ran\ngarbage line\nC: hello source: some-classification\n"); + // The C: classification line parses (kept for JSON), garbage drops. + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].letter, 'C'); + } + + /// A lintable tree at `/` with a changelog entry + /// `pkg (1.0-1) resolute`; returns the outer dir (the tree's parent, + /// where pkh build would place artifacts). + fn tree() -> (tempfile::TempDir, std::path::PathBuf) { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("pkg-1.0"); + std::fs::create_dir_all(root.join("debian")).unwrap(); + std::fs::write( + root.join("debian/changelog"), + "pkg (1.0-1) resolute; urgency=medium\n\n * x\n\n -- J Sat, 19 Sep 2026 12:00:00 +0000\n", + ) + .unwrap(); + (outer, root) + } + + fn dsc_of(outer: &tempfile::TempDir) -> std::path::PathBuf { + outer.path().join("pkg_1.0-1.dsc") + } + + #[test] + fn current_build_output_is_reused() { + let (outer, root) = tree(); + std::fs::write(dsc_of(&outer), "dummy dsc").unwrap(); + // The dsc was written after every tree file: current. + assert_eq!(usable_build_output(&root, false), Some(dsc_of(&outer))); + // Forcing repack skips it. + assert_eq!(usable_build_output(&root, true), None); + } + + #[test] + fn stale_or_mismatched_build_output_is_rejected() { + let (outer, root) = tree(); + // A tree file written after the dsc makes the artifact stale. The + // sleep crosses the coarse clock tick mtimes are stamped with, so + // the control file is strictly newer than the dsc. + std::fs::write(dsc_of(&outer), "dummy dsc").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(root.join("debian/control"), "Source: pkg\n").unwrap(); + assert_eq!(usable_build_output(&root, false), None); + + // A dsc of a different version does not represent this tree. + let old_dsc = outer.path().join("pkg_0.9-1.dsc"); + std::fs::write(&old_dsc, "dummy dsc").unwrap(); + assert_eq!(usable_build_output(&root, false), None); + } + + #[test] + fn missing_build_output_is_rejected() { + let (outer, root) = tree(); + assert_eq!(usable_build_output(&root, false), None); + assert!(!dsc_of(&outer).exists()); + } + + #[test] + fn orig_tarballs_are_linked_for_packing() { + let (outer, root) = tree(); + std::fs::write(outer.path().join("pkg_1.0.orig.tar.xz"), "orig").unwrap(); + std::fs::write(outer.path().join("pkg_1.0.orig-data.tar.gz"), "comp").unwrap(); + std::fs::write(outer.path().join("unrelated_1.0.orig.tar.xz"), "no").unwrap(); + std::fs::write(outer.path().join("pkg_1.0-1.dsc"), "no").unwrap(); + + let dest = tempfile::tempdir().unwrap(); + link_orig_tarballs(&root, dest.path()); + + assert!( + dest.path() + .join("pkg_1.0.orig.tar.xz") + .symlink_metadata() + .is_ok() + ); + assert!( + dest.path() + .join("pkg_1.0.orig-data.tar.gz") + .symlink_metadata() + .is_ok() + ); + assert!(!dest.path().join("unrelated_1.0.orig.tar.xz").exists()); + // The dsc is not an orig tarball and must not be linked. + assert!(!dest.path().join("pkg_1.0-1.dsc").exists()); + } +} diff --git a/src/main.rs b/src/main.rs index 3f1aeaa..df28395 100644 --- a/src/main.rs +++ b/src/main.rs @@ -217,6 +217,63 @@ fn main() { .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false) .long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")), ) + .subcommand( + Command::new("lint") + .about("Lint the package (lintian wrapper + pkh-native checks)") + .arg(arg!([path] "Source tree to lint (default: the current directory)").required(false)) + .arg(arg!(-d --dist "Target distribution (debian, ubuntu)").required(false)) + .arg(arg!(-s --series "Target distribution series").required(false)) + .arg(arg!(--native "Run pkh-native checks only, without the lintian wrapper").required(false)) + .arg(arg!(--info "Show tag explanations under each finding").required(false)) + .arg( + clap::Arg::new("display_info") + .long("display-info") + .action(clap::ArgAction::SetTrue) + .help("Also display info-level tags (I:)"), + ) + .arg(arg!(--pedantic "Also display pedantic tags (P:)").required(false)) + .arg(arg!(--experimental "Also display experimental tags (X:)").required(false)) + .arg( + clap::Arg::new("show_overrides") + .long("show-overrides") + .action(clap::ArgAction::SetTrue) + .help("Also display overridden tags (O:)"), + ) + .arg( + clap::Arg::new("fail_on") + .long("fail-on") + .value_name("LEVELS") + .help("Comma-separated severities failing the run: error, warning, info, pedantic, experimental, override (default: error)"), + ) + .arg( + clap::Arg::new("suppress_tags") + .long("suppress-tags") + .value_name("LIST") + .help("Comma-separated tag names to ignore for this run"), + ) + .arg( + clap::Arg::new("check") + .long("check") + .value_name("NAME") + .action(clap::ArgAction::Append) + .help("Run only this pkh-native check (can be specified multiple times)"), + ) + .arg(arg!(--repack "Ignore existing pkh build output and pack the tree fresh for linting").required(false)) + .arg(arg!(--json "Emit the report as JSON").required(false)) + .arg( + clap::Arg::new("color") + .long("color") + .value_name("WHEN") + .value_parser(["auto", "always", "never"]) + .help("Colorize the report: auto, always or never (default: auto)"), + ) + .arg( + clap::Arg::new("list_tags") + .long("list-tags") + .action(clap::ArgAction::SetTrue) + .help("Print the pkh-native tag catalog and exit"), + ), + ) .subcommand( Command::new("context") .about("Manage contexts") @@ -785,6 +842,77 @@ fn main() { } } } + Some(("lint", sub_matches)) => { + if sub_matches.get_flag("list_tags") { + print!("{}", pkh::lint::list_tags()); + std::process::exit(0); + } + + let path = sub_matches + .get_one::("path") + .map(std::path::PathBuf::from) + .unwrap_or_else(current_dir_or_exit); + let fail_on = match pkh::lint::output::parse_fail_on( + sub_matches + .get_one::("fail_on") + .map(String::as_str) + .unwrap_or("error"), + ) { + Ok(levels) => levels, + Err(e) => { + error!("{}", e); + std::process::exit(2); + } + }; + let options = pkh::lint::LintOptions { + path, + native: sub_matches.get_flag("native"), + fail_on, + info: sub_matches.get_flag("info"), + display_info: sub_matches.get_flag("display_info"), + pedantic: sub_matches.get_flag("pedantic"), + experimental: sub_matches.get_flag("experimental"), + show_overrides: sub_matches.get_flag("show_overrides"), + suppress_tags: sub_matches + .get_one::("suppress_tags") + .map(|list| { + list.split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + only_checks: sub_matches + .get_many::("check") + .map(|values| values.cloned().collect()) + .unwrap_or_default(), + repack: sub_matches.get_flag("repack"), + json: sub_matches.get_flag("json"), + color: match sub_matches.get_one::("color").map(String::as_str) { + Some("always") => pkh::lint::output::ColorMode::Always, + Some("never") => pkh::lint::output::ColorMode::Never, + _ => pkh::lint::output::ColorMode::Auto, + }, + dist: sub_matches.get_one::("dist").cloned(), + series: sub_matches.get_one::("series").cloned(), + }; + + match pkh::lint::run(&options) { + Ok(report) => { + if options.json { + println!("{}", pkh::lint::output::render_json(&report, &options)); + } else { + print!("{}", pkh::lint::output::render_text(&report, &options)); + } + std::process::exit(pkh::lint::output::exit_code(&report, &options)); + } + Err(e) => { + error!("{}", e); + std::process::exit(2); + } + } + } _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), } }