diff --git a/plans/native-build.md b/plans/native-build.md new file mode 100644 index 0000000..7f43671 --- /dev/null +++ b/plans/native-build.md @@ -0,0 +1,309 @@ +# Native build pipeline — replacing the `dpkg-buildpackage` shell-out + +Status: **Phase 0 + Phase 1 (source builds) implemented** — see §11 +Reference codebase: https://salsa.debian.org/dpkg-team/dpkg (`main` branch, analyzed 2026-08) + +--- + +## 0. TL;DR — honest take + +| Scope | Verdict | Effort | Risk | +|---|---|---|---| +| **A.** Re-implement the *orchestrator* ([`dpkg-buildpackage.pl`](https://salsa.debian.org/dpkg-team/dpkg/-/blob/main/scripts/dpkg-buildpackage.pl), 1209 lines of Perl) natively in Rust, still invoking `dpkg-source`, `debian/rules`, etc. as subprocesses | **Doable, worth it** | ~2–4 weeks | Low–medium | +| **B.** Additionally replace the cheap satellite tools natively (`dpkg-parsechangelog`, `dpkg-checkbuilddeps`, `dpkg-architecture`, `dpkg-genchanges`, `dpkg-genbuildinfo`) | Doable incrementally | +1–2 weeks each | Medium | +| **C.** Replace `dpkg-source` (tarball/diff generation, quilt integration, `.dsc` assembly) | **Not recommended** | 2–3+ months, then endless edge cases | High | + +The key insight: `dpkg-buildpackage` itself is a thin (~1200-line) Perl *sequencer*. The real complexity lives in `dpkg-source` (~4000 lines of Perl across `Dpkg/Source/*`). A source build fundamentally requires `dpkg-source`-class functionality (orig tarballs, debian diffs, `.dsc` assembly), so "no dpkg tools at all" is not a realistic goal — but "no `dpkg-buildpackage`, full control of the pipeline" absolutely is, and it unlocks things pkh currently cannot do (live UI on source builds, `.changes`/`.buildinfo` generation on binary builds, unified error classification). + +--- + +## 1. Where pkh stands today + +Two divergent build paths: + +### Source builds — [`build_source_package()`](../src/build/mod.rs) +Shelled out on the host: +``` +dpkg-buildpackage -S -I -i -nc -d [--sign-keyid= | --no-sign] +``` +Problems: +- Requires `dpkg-dev` on the host. +- Output bypasses the live UI entirely ([`DebUi`](../src/ui/deb.rs) / [`LineSink`](../src/context/mod.rs)) — no phase tracking, no log classification, unlike binary builds. +- No control over failure semantics beyond exit status. + +### Binary builds — [`deb::local::build()`](../src/deb/local.rs) +Already does **not** use `dpkg-buildpackage`. Hand-rolled sequence: ephemeral unshare chroot → `apt-get update` → essentials → manual `quilt push -a` → `apt-get build-dep` → `debian/rules build` → `fakeroot debian/rules binary` → retrieve `.deb`s. +What it silently skips compared to a real build: +- `debian/rules clean` before building, +- `dpkg-source --before-build/--after-build` lifecycle (it re-implements patch application crudely), +- `.buildinfo` and `.changes` generation, +- build-conflicts checking, `Rules-Requires-Root` semantics, +- `SOURCE_DATE_EPOCH` export (reproducibility). + +So pkh already pays part of the "native orchestrator" cost without getting its benefits. A native implementation would unify both paths. + +### Existing native assets to reuse +- Changelog header/footer parsing — [`changelog.rs`](../src/changelog.rs) (needs extension to full metadata: timestamp, urgency, trailer key/values). +- GPG key discovery — [`utils/gpg.rs`](../src/utils/gpg.rs) via `gpgme`. +- Hashing crates already present: `sha2`, `md-5` (need to add `sha1`); compression: `flate2`, `xz2`; archives: `tar`. +- Command execution through remote-capable contexts — [`ContextCommand`](../src/context/mod.rs) (local/ssh/schroot/unshare/capture). +- Live UI phases — [`ui/deb.rs`](../src/ui/deb.rs). + +--- + +## 2. What `dpkg-buildpackage` actually is (measured) + +It is a Perl script (`use v5.36`). Measured sizes on `main`: + +| Component | Lines | Role | +|---|---|---| +| `scripts/dpkg-buildpackage.pl` | 1209 | Orchestrator | +| `scripts/dpkg-source.pl` | 806 | Driver; work in `Dpkg/Source/*` | +| `scripts/dpkg-genbuildinfo.pl` | 637 | `.buildinfo` generation | +| `scripts/dpkg-genchanges.pl` | 626 | `.changes` generation | +| `scripts/dpkg-architecture.pl` | 498 | Arch name/triplet tables | +| `scripts/dpkg-gensymbols.pl` | 398 | (called by rules, not by us) | +| `scripts/dpkg-checkbuilddeps.pl` | 270 | Dep checking vs status file | +| `scripts/dpkg-parsechangelog.pl` | 195 | Changelog CLI wrapper | +| `scripts/dpkg-distaddfile.pl` | 99 | Registers files in `debian/files` | +| `Dpkg/*.pm` + `Dpkg/Source/*.pm` (subset examined) | ~11,600 | Shared library code | + +Total relevant Perl surface ≈ **16k LOC**, of which the orchestrator is only ~8%. + +--- + +## 3. The exact pipeline (verified against source) + +What `dpkg-buildpackage` does, in order: + +1. **Config**: load `buildpackage.conf` (Dpkg::Conf), inject as leading argv. +2. **Option parsing** (~200 lines): build types `-F/-g/-G/-b/-B/-A/-S` (+ `--build=full,source,binary,any,all`), signing (`-us/-uc/-ui/-k/-p/--no-sign/--force-sign`), `-j/-J/--jobs-force`, `-r`, `-R`, `-T`, `-a/-t/--target-arch`, `-P`, `-d/-D`, `-nc/-tc`, hooks `--hook-=`, passthrough buckets for `dpkg-source` / `dpkg-genchanges` / `dpkg-genbuildinfo`. +3. **Build type → rules targets**: `binary`→`build`+`binary`; arch-dep only→`build-arch`+`binary-arch`; indep only→`build-indep`+`binary-indep`. +4. **Implied flags**: `-nc` alone implies `-b`; `-nc -S` disables build-dep checks. +5. **Environment prep**: + - `parallel=auto` default → `DEB_BUILD_OPTIONS=parallel=N` exported; + - forced jobs additionally appended to `MAKEFLAGS`; + - `DEB_BUILD_PROFILES` exported if `-P`; + - optional `.dsc` input → `dpkg-source --extract` first; + - `SOURCE_DATE_EPOCH ||= changelog timestamp || time()` (reproducible-builds.org spec); + - full env dump of `dpkg-architecture -f [-a…][-t…]` imported into the environment (all `DEB_BUILD_*`, `DEB_HOST_*`, `DEB_TARGET_*` incl. `*_OS/CPU/MULTIARCH/GNU_TYPE/ARCH_BITS/ENDIAN`); + - OpenPGP key resolution: `--sign-keyfile` > `--sign-keyid` > maintainer userid; secrets probed up-front; **UNRELEASED distribution ⇒ signing disabled** unless `--force-sign`. +6. **Pre-flight**: `Dpkg::BuildDriver->pre_check()` (rules file exists/executable); `dpkg-source --before-build .` (applies patches for quilt formats); `dpkg-checkbuilddeps [-A|-B|-I]` unless `-d` (exit 3 on unmet). +7. **Hooks** at 12 points: `preinit init preclean source build binary buildinfo changes postclean check sign done`, with `%p/%v/%s/%u/%a` substitution. +8. **Preclean**: `debian/rules clean` via BuildDriver (gain-root per `Rules-Requires-Root`). +9. **Source build** (if any SOURCE component): `dpkg-source -b .` → `.dsc` + tarballs in `..`. +10. **Binary build** (if any BINARY component): `run_build_task(build-target)` then `binary-target` through BuildDriver (RRR-aware root command; skips separate non-root `build` pass when running rootless). +11. **Metadata generation**: + - `dpkg-genbuildinfo` → `../__.buildinfo` (Format 1.0; records `Installed-Build-Depends` snapshot from the dpkg status DB, build environment, checksums); + - `dpkg-genchanges` → `../__.changes` (Format 1.8; aggregates `debian/files` + changelog + control). +12. **Post**: optional `-tc` clean; `dpkg-source --after-build .` (unapplies patches it applied); human summary ("full upload (original source is included)" etc. derived from the `Files` field); optional lintian-style check command. +13. **Signing cascade** (inline/clearsig via OpenPGP backend gpg|sequoia|sop): + - sign `_.dsc` → recompute its checksums **inside `.buildinfo`**; + - sign `.buildinfo` → recompute dsc+buildinfo checksums **inside `.changes`** (rewriting both `Checksums-*` and legacy `Files` entries); + - sign `.changes`. + +Exit codes matter: e.g. unsatisfied build-deps ⇒ exit 3. + +--- + +## 4. Sub-tool inventory and replacement strategy + +| Tool | Used for | Complexity to replace natively | Strategy | +|---|---|---|---| +| `dpkg-parsechangelog` | source/version/maintainer/distribution/timestamp | **Low** — documented format; crates exist (`debian-changelog`, `deb822-parser` ecosystem) | Replaced (see §11, [`metadata.rs`](../src/build/metadata.rs)) | +| `dpkg-version` compare | epoch/upstream/revision ordering | **Low** — small well-specified algorithm; crate `debversion` | Splitting/validation replaced; ordering still Phase 2 | +| `dpkg-architecture` | arch ↔ triplet tables, multiarch tuple, env dump | **Low-medium** — embed cputable/ostable/tupletable/abitable data (stable for years) | **Replaced** (§11, [`debian/arch.rs`](../src/debian/arch.rs)) | +| `dpkg-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `` restrictions, versioned Provides subtleties, Multi-Arch facts) + status-file scan | Phase 2; keep `apt-get build-dep`/subprocess until then | +| `dpkg-genbuildinfo` | `.buildinfo` | **Medium** — deb822 emit + status snapshot + checksums | Native (see §11, [`buildinfo.rs`](../src/build/buildinfo.rs)) | +| `dpkg-genchanges` | `.changes` | **Medium** — deb822 emit + `debian/files` consumption + `.deb` control extraction (ar+tar, trivial with crates) | Native for source uploads (§11, [`changes.rs`](../src/build/changes.rs)); binary aggregation next | +| `dpkg-distaddfile`/`debian/files` protocol | build outputs registry | **Trivial** — one append-only line format | Native ([`files.rs`](../src/build/files.rs)) | +| OpenPGP signing | inline clearsign of dsc/buildinfo/changes | **Low** — `gpgme` (already a dependency) supports clearsigning | Native ([`sign.rs`](../src/build/sign.rs)) | +| `dpkg-source` | orig tarball, debian diff, patches, `.dsc` | **Very high** — V1/V2/quilt/native formats, byte-exact tar normalization, quilt bookkeeping, `--include-binaries`, hundreds of validation warnings | **Keep as subprocess** (see §6) | +| `debian/rules` execution | the actual build | N/A (foreign code) | Keep, via `ContextCommand` | + +--- + +## 5. Feasibility detail per scope + +### Scope A — native orchestrator (recommended) + +The 1209-line script decomposes into clean Rust pieces: + +``` +src/build/ +├── mod.rs // pipeline driver (the equivalent of main()) +├── buildtype.rs // BUILD_SOURCE|ARCH_DEP|ARCH_INDEP bitflags + target mapping +├── metadata.rs // changelog + control resolution, version splitting +├── env.rs // SOURCE_DATE_EPOCH, DEB_BUILD_OPTIONS, arch env dump +├── buildinfo.rs // native .buildinfo writer +├── changes.rs // native .changes writer +├── files.rs // debian/files registry emulation +├── checksums.rs // md5/sha1/sha256 registry +├── control.rs // deb822 parser/writer +└── sign.rs // gpgme clearsign + post-sign checksum cascade +``` + +Estimated ~1.5–3 kLOC. Everything is deterministic file munging + subprocess sequencing — no daemons, no parsing of arbitrary upstream code (that's `dpkg-source`'s job, which we keep). + +**pkh-specific simplifications** (legitimate because pkh controls the environment): +- Builds run as **real root** inside ephemeral unshare chroots ⇒ the entire gain-root/fakeroot matrix collapses: run `debian/rules` directly when the context is root (keep `fakeroot` fallback for host-side source builds). +- pkh's option surface is a fraction of dpkg's: `-S/-b/-B/-A/-g/-G`, `-us/-uc`, `-k`, `-j`, `-a`, `-P`, `-d/-D`, `-nc/-tc`, `-R`, `-T` cover everything pkh passes today plus obvious headroom. Hooks and `--hook-*` can be dropped initially. +- Vendor hooks (`run_vendor_hook`) are rarely used in the build path; Ubuntu/Debian differences pkh cares about are already handled via [`distro_info.yml`](../distro_info.yml). + +**What this buys pkh concretely:** +1. Live UI + log classification for source builds (today's `-S` path is a black box). +2. `.changes`/`.buildinfo` for binary builds — currently missing entirely; needed for uploads/PPA submissions and lintian checks. +3. Correct `dpkg-source --before-build/--after-build` lifecycle replacing the manual `quilt push -a` hack in [`local.rs`](../src/deb/local.rs) (handles `3.0 (quilt)` properly, incl. unapply-on-exit and format detection instead of sniffing `debian/patches/series`). +4. `SOURCE_DATE_EPOCH` reproducibility for free. +5. No host `dpkg-dev` requirement for orchestration decisions; `dpkg-source` still needed inside build environments where pkh already installs packages anyway. + +### Scope B — satellite tools + +Incremental, each independently testable against the real tool (differential testing). Crates from the `rust-debian-*` ecosystem (maintained by Jelmer Vernooij) cover most parsing: `deb822-parser`, `debian-control`, `debian-changelog`, `debversion`. Priority order if pursued: version compare → changelog → architecture tables → checkbuilddeps. + +### Scope C — `dpkg-source` + +The honest numbers: `Dpkg/Source/Package/V2.pm` alone is 847 lines, V1 is 599, plus Archive/Quilt/Functions/BinaryFiles modules, GNU diff generation, and — critically — **byte-level tar normalization** (mtime/uid/gid/mode canonicalization, pax header handling) that reproducible builds depend on. Parity means being bug-compatible with dpkg against ~40k archive source packages. This is a multi-month project with a long tail, and it buys pkh almost nothing since `dpkg-source` is guaranteed present inside the very chroots pkh creates. **Do not do this.** + +--- + +## 6. Proposed phasing + +### Phase 0 — quick win (days) +Route the existing `dpkg-buildpackage -S` call through the UI capture machinery (like [`cap()`](../src/deb/local.rs) does for binary builds): phase display + `LineSink` classification. No behavior change otherwise. + +### Phase 1 — native orchestrator (2–4 weeks) +Implement §5 scope A. Both entry points converge: +- `pkh build -S` → native pipeline, `dpkg-source -b` subprocess, native signing. +- `pkh build` (binary) → same pipeline skeleton inside the ephemeral context: preclean → before-build → dep check → `rules build` → `rules binary[-arch|-indep]` → native buildinfo/changes → after-build. Deletes the manual quilt logic. + +Deliverables: +- `src/build/*` module tree (§5). +- Native `.buildinfo` (Format 1.0) and `.changes` (Format 1.8) writers emitting exactly the field sets observed in `dpkg-genbuildinfo.pl` / `dpkg-genchanges.pl`, in dpkg's canonical field order: + - buildinfo: `Format, Source, Binary, Architecture, Version, Binary-Only-Changes, Checksums-Md5/Shа1/Sha256, Build-Origin, Build-Architecture, Build-Kernel-Version(opt), Build-Date, Build-Path(opt), Build-Tainted-By(opt), Installed-Build-Depends, Environment` + - changes: `Format, Date, Source, Binary, Built-For-Profiles, Architecture, Version, Distribution, Urgency, Maintainer, Changed-By, Description, Changes, Checksums-Sha1/Sha256, Files` +- Post-signature checksum cascade implemented exactly as dpkg does (sign dsc → patch buildinfo checksums → sign buildinfo → patch changes `Files`+`Checksums-*` → sign changes). +- Differential test harness (§8). + +### Phase 2 — satellite replacement (optional, incremental) +Swap subprocesses for native implementations, one tool at a time, gated by differential tests. Start with version-compare + changelog (already half-present in pkh). + +### Explicitly out of scope +`dpkg-source` internals, `dpkg-deb` packing, apt resolution (pkh correctly delegates to `apt-get build-dep` + dose3 for explanations already). + +--- + +## 7. Tricky details to get right (gotchas list) + +1. **Version splitting**: `upstream<-revision>`; `.dsc`/tarball names use *upstream* portion without epoch; `.changes` name uses version **without epoch** but **with revision** (`$sversion` in the script). +2. **`-nc` implication chain**: `-nc` ⇒ binary build implied; `-nc -S` ⇒ no dep check. +3. **UNRELEASED** ⇒ auto-disable all signing (warn), `--force-sign` overrides. +4. **Signing invalidates checksums transitively** (dsc → buildinfo → changes); get the cascade order right or archive tools reject the upload. +5. **`debian/files`** is the contract between `debian/rules` (via `dh_builddeb`/`dpkg-gencontrol`/`dpkg-distaddfile`) and the changes generator: lines of `filename section priority [key=value...]`. +6. **Arch selection for names**: `arch` suffix in artifact filenames is `host-arch` for arch-dep builds, `all` for indep-only, `source` for source-only. +7. **`dpkg-source --before-build` must run even for binary-only builds** (patch application), and `--after-build` at the end — this replaces pkh's current manual quilt step and fixes `3.0 (quilt)` correctness. +8. **Environment parity**: `dpkg-architecture -f` dump must be imported wholesale (not cherry-picked) — packages test `DEB_HOST_GNU_TYPE`, `DEB_BUILD_MULTIARCH`, `DEB_BUILD_ARCH_ENDIAN`, etc. If embedding tables later, mirror the full variable set. +9. **Exit codes**: preserve dpkg conventions (3 = unmet build-deps) so wrappers/scripts behave identically. +10. **Locale**: dpkg sets `LANG=C`-ish determinism for subprocesses — pkh already does this in [`local.rs`](../src/deb/local.rs); keep for all pipeline steps. +11. **Multiline field rendering**: values starting with `\n` render as `Field:` + indented continuation lines (no inline first line, no trailing space) — this is how dpkg emits `Changes`, `Files`, `Installed-Build-Depends`, `Environment`. +12. **Artifact ordering** in `Checksums-*`/`Files` is insertion order (dsc → tarballs in dsc-field order → debs → buildinfo), not alphabetical. + +--- + +## 8. Testing strategy + +- **Differential harness**: run real `dpkg-buildpackage` and the native pipeline over a corpus (packages covering: `1.0` non-native, `3.0 (quilt)` with/without patches, native, binaries-only, indep-only, cross, RRR variants, UNRELEASED) and diff artifacts modulo timestamps/signatures. +- **Port unit cases** from dpkg's own `t/` tests for version compare, deps parsing, changelog parsing. +- **Golden-file tests** for `.changes`/`.buildinfo` writers. +- Pin the reference dpkg version in CI commentary (behavior drift across dpkg releases is the main maintenance cost). + +--- + +## 9. Risks + +| Risk | Mitigation | +|---|---| +| Behavior drift vs future dpkg releases (new fields, format bumps) | Differential tests pinned to a reference version; changes/buildinfo formats are extremely stable (1.8 / 1.0 for years) | +| Archive/upload tooling rejects our `.changes`/`.buildinfo` | Validate with `lintian` + a real PPA upload early in Phase 1 | +| License contamination | dpkg is GPL-2+. Write from documented behavior/format specs and observation, **do not transliterate Perl**; alternatively accept GPL for pkh (currently no license field in [`Cargo.toml`](../Cargo.toml) — decision needed) | +| Scope creep toward Scope C | Hard rule: `dpkg-source` stays a subprocess | +| Remote-context divergence | All tree-touching steps go through [`ContextCommand`](../src/context/mod.rs); metadata steps operate on locally-synced copies like [`changelog.rs`](../src/changelog.rs) already does | + +--- + +## 10. Effort summary + +| Item | Estimate | +|---|---| +| Phase 0 (UI capture for `-S`) | 1–2 days | +| Phase 1 (native orchestrator + buildinfo/changes/signing) | 2–4 weeks | +| Phase 2 per satellite tool | 3 days – 2 weeks each | +| Scope C (`dpkg-source`) | 2–3+ months — rejected | + +Bottom line: **yes, it's doable — for the orchestrator.** Treat `dpkg-source` as a permanent subprocess dependency, and the task becomes a well-bounded, high-value refactor that also fixes real gaps in pkh's binary path. + +--- + +## 11. Implementation status (Phase 1 — source builds) + +Landed in `src/build/` (~2.8 kLOC incl. tests), wired behind the historical +entry point [`build_source_package()`](../src/build/mod.rs) so `pkh build` +now runs the native pipeline: + +| Module | Role | +|---|---| +| [`control.rs`](../src/build/control.rs) | deb822 paragraph parser/writer (dpkg-compatible multiline rendering) | +| [`checksums.rs`](../src/build/checksums.rs) | md5/sha1/sha256 registry, insertion-ordered like dpkg's artifact accumulation | +| [`metadata.rs`](../src/build/metadata.rs) | version splitting/validation, full changelog entry parse (incl. binNMU `binary-only`), control info | +| [`buildtype.rs`](../src/build/buildtype.rs) | build-type bitflags + rules-target/artifact-suffix mapping | +| [`env.rs`](../src/build/env.rs) | `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, `dpkg-architecture` env dump, vendor/profiles, sanitized `Environment` field | +| [`files.rs`](../src/build/files.rs) | `debian/files` registry (parse/atomic save) | +| [`buildinfo.rs`](../src/build/buildinfo.rs) | `.buildinfo` writer + `Installed-Build-Depends` closure over the dpkg status DB | +| [`changes.rs`](../src/build/changes.rs) | `.changes` writer (canonical field order, legacy `Files` + `Checksums-Sha1/256`) | +| [`sign.rs`](../src/build/sign.rs) | gpgme clearsigning + key-id validation | + +Still delegated to subprocesses: `dpkg-source -b/--before-build/--after-build` +(by design, see §5 Scope C). + +**Differential validation** — automated in +[`build/mod.rs`](../src/build/mod.rs) (`mod differential_tests`, runs by +default with `cargo test --lib`): + +- a corpus of 12 synthetic fixtures covering: native/quilt/1.0 formats, + epochs, `~` pre-releases, Ubuntu/Debian series (focal, noble, jammy, + trixie, unstable), high urgency, multiple binary stanzas, one/two quilt + patches, binNMU (`binary-only=yes` + previous-entry metadata), + `(Closes: #…)` extraction, UNRELEASED no-sign, extra source-stanza fields; +- each case builds the tree twice (`cp -a` copies) — once with real + `dpkg-buildpackage -S -I -i -nc -d --no-sign`, once with the native + pipeline — then compares: + - `.dsc` payload byte-for-byte, + - `.changes` field-by-field (checksum lines of the `.buildinfo` itself + excluded), + - `.buildinfo` structure (machine-dependent fields excluded); +- real **archive packages** are pulled with pkh's own + [`pull`](../src/pull.rs) (archive download mode) and compared the same + way, via [`differential_real_archive_package()`](../src/build/mod.rs) + which takes `(package, dist, series)`: + - always-on CI tests: `hello` @ ubuntu/noble, `dosfstools` @ + debian/trixie, `sl` @ ubuntu/focal; + - an additional `#[ignore]`-gated test for ad-hoc broad runs: + ```text + PKH_DIFF_PACKAGES="bash coreutils curl" PKH_DIFF_DIST=ubuntu \ + PKH_DIFF_SERIES=noble cargo test --lib \ + differential_real_archive_packages -- --ignored + ``` + (requires network access). + +Manual validation additionally confirmed: signed builds verify with +`gpg --verify` on all three artifacts; the patch apply/unapply lifecycle is +correct for quilt formats. + +**Known divergences** (documented, all informational fields): no +`Build-Tainted-By` (vendor hook), no `dpkg-buildflags` origin tracking in the +`Environment` field, vendor default profiles approximated +(`derivative.ubuntu noudeb` for Ubuntu). + +**Next steps**: binary-build adoption of the same pipeline inside ephemeral +contexts (`.changes`/`.buildinfo` generation for `pkh deb`, replacing the +manual quilt step), then Phase 2 satellite replacement. diff --git a/src/build/env.rs b/src/build/env.rs index ef6c3ae..24ef5cd 100644 --- a/src/build/env.rs +++ b/src/build/env.rs @@ -1,10 +1,9 @@ //! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, -//! architecture variables (via `dpkg-architecture`) and the sanitized -//! environment recorded in `.buildinfo` files. +//! architecture variables (native `dpkg-architecture` equivalent) and the +//! sanitized environment recorded in `.buildinfo` files. use std::collections::BTreeMap; use std::path::Path; -use std::process::Command; /// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`. pub fn num_parallel() -> usize { @@ -41,41 +40,15 @@ pub fn build_env( env } -/// Import the full architecture variable set by running -/// `dpkg-architecture -f [-a ]` and parsing its `KEY=VALUE` dump. +/// Import the full architecture variable set, computed natively by +/// [`crate::debian::arch`] (the equivalent of `dpkg-architecture -f +/// [-a ]`). /// /// This exports all `DEB_BUILD_*`, `DEB_HOST_*` and `DEB_TARGET_*` variables /// (`*_ARCH`, `*_OS`, `*_CPU`, `*_MULTIARCH`, `*_GNU_TYPE`, ...), exactly as /// `dpkg-buildpackage` does. pub fn arch_env(host_arch: Option<&str>) -> Result, String> { - let mut cmd = Command::new("dpkg-architecture"); - cmd.arg("-f"); - if let Some(arch) = host_arch { - cmd.args(["--host-arch", arch]); - } - - let output = cmd.output().map_err(|e| { - format!( - "failed to run 'dpkg-architecture': {}. Is 'dpkg-dev' installed?", - e - ) - })?; - - if !output.status.success() { - return Err(format!( - "dpkg-architecture failed with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - - let mut env = BTreeMap::new(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - if let Some((key, value)) = line.split_once('=') { - env.insert(key.to_string(), value.to_string()); - } - } - Ok(env) + crate::debian::arch::arch_env(host_arch) } /// Read the current vendor name from `/etc/dpkg/origins/default` diff --git a/src/build/mod.rs b/src/build/mod.rs index 5f37996..f08003d 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -859,6 +859,56 @@ mod differential_tests { differential_on_tree(&tree); } + /// Differential check of [`crate::debian::arch::arch_env`] against real + /// `dpkg-architecture -f -a ` for one architecture. + fn diff_arch_env_one(arch: Option<&str>) { + let mut cmd = Command::new("dpkg-architecture"); + cmd.arg("-f"); + if let Some(a) = arch { + cmd.args(["-a", a]); + } + let output = cmd.output().expect("run dpkg-architecture (is dpkg-dev installed?)"); + assert!( + output.status.success(), + "dpkg-architecture -f {arch:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let mut expected = BTreeMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((key, value)) = line.split_once('=') { + expected.insert(key.to_string(), value.to_string()); + } + } + + let ours = crate::debian::arch::arch_env(arch) + .unwrap_or_else(|e| panic!("native arch_env({arch:?}) failed: {e}")); + assert_eq!(ours, expected, "arch_env({arch:?}) differs from dpkg-architecture"); + } + + /// Every architecture known to the local dpkg must produce an identical + /// environment dump (`dpkg-architecture -L`). + #[test] + fn diff_arch_env_all_known_arches() { + let output = Command::new("dpkg-architecture") + .arg("-L") + .output() + .expect("run dpkg-architecture -L (is dpkg-dev installed?)"); + assert!(output.status.success()); + for arch in String::from_utf8_lossy(&output.stdout).lines() { + let arch = arch.trim(); + if arch.is_empty() { + continue; + } + diff_arch_env_one(Some(arch)); + } + } + + /// Native (no explicit host architecture) must match too. + #[test] + fn diff_arch_env_native() { + diff_arch_env_one(None); + } + #[test] fn diff_native_minimal() { differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable")); diff --git a/src/debian/arch.rs b/src/debian/arch.rs new file mode 100644 index 0000000..2e7ab9c --- /dev/null +++ b/src/debian/arch.rs @@ -0,0 +1,733 @@ +//! Native Debian architecture tables and lookups. +//! +//! Replaces the `dpkg-architecture` satellite tool with pure-Rust lookups +//! over the factual architecture data published by dpkg. The embedded data +//! tables are factual lists (Debian/GNU name mappings, pointer sizes, +//! endianness); attribution comments point at the corresponding upstream +//! files in the dpkg repository +//! (, files `data/cputable`, +//! `data/ostable`, `data/tupletable`, `data/abitable`). +//! +//! The variable dump produced by [`arch_env`] mirrors the full +//! `dpkg-architecture -f` output: `DEB_BUILD_*`, `DEB_HOST_*` and +//! `DEB_TARGET_*` × `{ARCH, ARCH_ABI, ARCH_LIBC, ARCH_OS, ARCH_CPU, +//! ARCH_BITS, ARCH_ENDIAN, MULTIARCH, GNU_CPU, GNU_SYSTEM, GNU_TYPE}`. + +use std::collections::BTreeMap; +use std::process::Command; +use std::sync::OnceLock; + +use regex::Regex; + +/// Byte order of a CPU. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Endian { + /// Least-significant byte first. + Little, + /// Most-significant byte first. + Big, +} + +impl std::fmt::Display for Endian { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Endian::Little => write!(f, "little"), + Endian::Big => write!(f, "big"), + } + } +} + +/// A Debian architecture tuple `(abi, libc, os, cpu)`, the normalized +/// internal representation of an architecture name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DebTuple { + /// ABI attribute (e.g. `base`, `x32`, `gnueabihf`). + pub abi: String, + /// C library (e.g. `gnu`, `musl`, `uclibc`). + pub libc: String, + /// Operating system kernel (e.g. `linux`, `hurd`, `freebsd`). + pub os: String, + /// CPU (e.g. `amd64`, `arm`, `riscv64`). + pub cpu: String, +} + +impl DebTuple { + /// Render the canonical `abi-libc-os-cpu` form. + pub fn to_key(&self) -> String { + format!("{}-{}-{}-{}", self.abi, self.libc, self.os, self.cpu) + } + + /// Parse a canonical `abi-libc-os-cpu` key back into a tuple. + fn from_key(key: &str) -> Option { + let parts: Vec<&str> = key.split('-').collect(); + if parts.len() != 4 { + return None; + } + Some(DebTuple { + abi: parts[0].to_string(), + libc: parts[1].to_string(), + os: parts[2].to_string(), + cpu: parts[3].to_string(), + }) + } +} + +/// One row of the CPU table (upstream: `data/cputable`). +struct CpuEntry { + /// Debian CPU name. + name: &'static str, + /// GNU config CPU name. + gnu: &'static str, + /// Anchored regex matching the CPU part of a GNU config.guess triplet. + guess: &'static str, + /// Pointer size in bits. + bits: u32, + /// Byte order. + endian: Endian, +} + +/// One row of the operating-system table (upstream: `data/ostable`). +struct OsEntry { + /// Debian system name as `abi-libc-os`. + tuple: &'static str, + /// GNU config system name. + gnu: &'static str, + /// Anchored regex matching the system part of a GNU config.guess triplet. + guess: &'static str, +} + +// Factual data from dpkg `data/cputable` (columns: debian name, GNU name, +// config.guess regex, bits, endianness). +static CPU_TABLE: &[CpuEntry] = &[ + CpuEntry { name: "alpha", gnu: "alpha", guess: "alpha.*", bits: 64, endian: Endian::Little }, + CpuEntry { name: "amd64", gnu: "x86_64", guess: "(amd64|x86_64)", bits: 64, endian: Endian::Little }, + CpuEntry { name: "arc", gnu: "arc", guess: "arc", bits: 32, endian: Endian::Little }, + CpuEntry { name: "armeb", gnu: "armeb", guess: "arm.*b", bits: 32, endian: Endian::Big }, + CpuEntry { name: "arm", gnu: "arm", guess: "arm.*", bits: 32, endian: Endian::Little }, + CpuEntry { name: "arm64", gnu: "aarch64", guess: "aarch64", bits: 64, endian: Endian::Little }, + CpuEntry { name: "hppa", gnu: "hppa", guess: "hppa.*", bits: 32, endian: Endian::Big }, + CpuEntry { name: "loong64", gnu: "loongarch64", guess: "loongarch64", bits: 64, endian: Endian::Little }, + CpuEntry { name: "i386", gnu: "i686", guess: "(i[34567]86|pentium)", bits: 32, endian: Endian::Little }, + CpuEntry { name: "ia64", gnu: "ia64", guess: "ia64", bits: 64, endian: Endian::Little }, + CpuEntry { name: "m68k", gnu: "m68k", guess: "m68k", bits: 32, endian: Endian::Big }, + CpuEntry { name: "mips", gnu: "mips", guess: "mips(eb)?", bits: 32, endian: Endian::Big }, + CpuEntry { name: "mipsel", gnu: "mipsel", guess: "mipsel", bits: 32, endian: Endian::Little }, + CpuEntry { name: "mipsr6", gnu: "mipsisa32r6", guess: "mipsisa32r6", bits: 32, endian: Endian::Big }, + CpuEntry { name: "mipsr6el", gnu: "mipsisa32r6el", guess: "mipsisa32r6el", bits: 32, endian: Endian::Little }, + CpuEntry { name: "mips64", gnu: "mips64", guess: "mips64", bits: 64, endian: Endian::Big }, + CpuEntry { name: "mips64el", gnu: "mips64el", guess: "mips64el", bits: 64, endian: Endian::Little }, + CpuEntry { name: "mips64r6", gnu: "mipsisa64r6", guess: "mipsisa64r6", bits: 64, endian: Endian::Big }, + CpuEntry { name: "mips64r6el", gnu: "mipsisa64r6el", guess: "mipsisa64r6el", bits: 64, endian: Endian::Little }, + CpuEntry { name: "nios2", gnu: "nios2", guess: "nios2", bits: 32, endian: Endian::Little }, + CpuEntry { name: "or1k", gnu: "or1k", guess: "or1k", bits: 32, endian: Endian::Big }, + CpuEntry { name: "powerpc", gnu: "powerpc", guess: "(powerpc|ppc)", bits: 32, endian: Endian::Big }, + CpuEntry { name: "powerpcel", gnu: "powerpcle", guess: "powerpcle", bits: 32, endian: Endian::Little }, + CpuEntry { name: "ppc64", gnu: "powerpc64", guess: "(powerpc|ppc)64", bits: 64, endian: Endian::Big }, + CpuEntry { name: "ppc64el", gnu: "powerpc64le", guess: "powerpc64le", bits: 64, endian: Endian::Little }, + CpuEntry { name: "riscv64", gnu: "riscv64", guess: "riscv64", bits: 64, endian: Endian::Little }, + CpuEntry { name: "s390", gnu: "s390", guess: "s390", bits: 32, endian: Endian::Big }, + CpuEntry { name: "s390x", gnu: "s390x", guess: "s390x", bits: 64, endian: Endian::Big }, + CpuEntry { name: "sh3", gnu: "sh3", guess: "sh3", bits: 32, endian: Endian::Little }, + CpuEntry { name: "sh3eb", gnu: "sh3eb", guess: "sh3eb", bits: 32, endian: Endian::Big }, + CpuEntry { name: "sh4", gnu: "sh4", guess: "sh4", bits: 32, endian: Endian::Little }, + CpuEntry { name: "sh4eb", gnu: "sh4eb", guess: "sh4eb", bits: 32, endian: Endian::Big }, + CpuEntry { name: "sparc", gnu: "sparc", guess: "sparc", bits: 32, endian: Endian::Big }, + CpuEntry { name: "sparc64", gnu: "sparc64", guess: "sparc(64|v9)", bits: 64, endian: Endian::Big }, +]; + +// Factual data from dpkg `data/ostable` (columns: debian `abi-libc-os`, +// GNU system name, config.guess regex). +static OS_TABLE: &[OsEntry] = &[ + OsEntry { tuple: "eabi-uclibc-linux", gnu: "linux-uclibceabi", guess: "linux[^-]*-uclibceabi" }, + OsEntry { tuple: "base-uclibc-linux", gnu: "linux-uclibc", guess: "linux[^-]*-uclibc" }, + OsEntry { tuple: "eabihf-musl-linux", gnu: "linux-musleabihf", guess: "linux[^-]*-musleabihf" }, + OsEntry { tuple: "base-musl-linux", gnu: "linux-musl", guess: "linux[^-]*-musl" }, + OsEntry { tuple: "eabihf-gnu-linux", gnu: "linux-gnueabihf", guess: "linux[^-]*-gnueabihf" }, + OsEntry { tuple: "eabi-gnu-linux", gnu: "linux-gnueabi", guess: "linux[^-]*-gnueabi" }, + OsEntry { tuple: "abin32-gnu-linux", gnu: "linux-gnuabin32", guess: "linux[^-]*-gnuabin32" }, + OsEntry { tuple: "abi64-gnu-linux", gnu: "linux-gnuabi64", guess: "linux[^-]*-gnuabi64" }, + OsEntry { tuple: "spe-gnu-linux", gnu: "linux-gnuspe", guess: "linux[^-]*-gnuspe" }, + OsEntry { tuple: "x32-gnu-linux", gnu: "linux-gnux32", guess: "linux[^-]*-gnux32" }, + OsEntry { tuple: "base-gnu-linux", gnu: "linux-gnu", guess: "linux[^-]*(-gnu.*)?" }, + OsEntry { tuple: "base-gnu-hurd", gnu: "gnu", guess: "gnu[^-]*" }, + OsEntry { tuple: "base-bsd-darwin", gnu: "darwin", guess: "darwin[^-]*" }, + OsEntry { tuple: "base-bsd-dragonflybsd", gnu: "dragonflybsd", guess: "dragonfly[^-]*" }, + OsEntry { tuple: "base-bsd-freebsd", gnu: "freebsd", guess: "freebsd[^-]*" }, + OsEntry { tuple: "base-bsd-netbsd", gnu: "netbsd", guess: "netbsd[^-]*" }, + OsEntry { tuple: "base-bsd-openbsd", gnu: "openbsd", guess: "openbsd[^-]*" }, + OsEntry { tuple: "base-sysv-aix", gnu: "aix", guess: "aix[^-]*" }, + OsEntry { tuple: "base-sysv-solaris", gnu: "solaris", guess: "solaris[^-]*" }, + OsEntry { tuple: "base-tos-mint", gnu: "mint", guess: "mint[^-]*" }, +]; + +// Factual data from dpkg `data/tupletable`: bidirectional mapping between a +// Debian arch tuple and a Debian arch name. `` expands over every CPU +// in [`CPU_TABLE`]; earlier rows take precedence (first-match wins). +static TUPLE_TABLE: &[(&str, &str)] = &[ + ("eabi-uclibc-linux-arm", "uclibc-linux-armel"), + ("base-uclibc-linux-", "uclibc-linux-"), + ("eabihf-musl-linux-arm", "musl-linux-armhf"), + ("base-musl-linux-", "musl-linux-"), + ("eabihf-gnu-linux-arm", "armhf"), + ("eabi-gnu-linux-arm", "armel"), + ("abin32-gnu-linux-mips64r6el", "mipsn32r6el"), + ("abin32-gnu-linux-mips64r6", "mipsn32r6"), + ("abin32-gnu-linux-mips64el", "mipsn32el"), + ("abin32-gnu-linux-mips64", "mipsn32"), + ("abi64-gnu-linux-mips64r6el", "mips64r6el"), + ("abi64-gnu-linux-mips64r6", "mips64r6"), + ("abi64-gnu-linux-mips64el", "mips64el"), + ("abi64-gnu-linux-mips64", "mips64"), + ("x32-gnu-linux-amd64", "x32"), + ("base-gnu-linux-", ""), + ("base-gnu-hurd-amd64", "hurd-amd64"), + ("base-gnu-hurd-i386", "hurd-i386"), + ("base-bsd-dragonflybsd-amd64", "dragonflybsd-amd64"), + ("base-bsd-freebsd-amd64", "freebsd-amd64"), + ("base-bsd-freebsd-arm", "freebsd-arm"), + ("base-bsd-freebsd-arm64", "freebsd-arm64"), + ("base-bsd-freebsd-i386", "freebsd-i386"), + ("base-bsd-freebsd-powerpc", "freebsd-powerpc"), + ("base-bsd-freebsd-ppc64", "freebsd-ppc64"), + ("base-bsd-freebsd-riscv", "freebsd-riscv"), + ("base-bsd-openbsd-", "openbsd-"), + ("base-bsd-netbsd-", "netbsd-"), + ("base-bsd-darwin-amd64", "darwin-amd64"), + ("base-bsd-darwin-arm", "darwin-arm"), + ("base-bsd-darwin-arm64", "darwin-arm64"), + ("base-bsd-darwin-i386", "darwin-i386"), + ("base-bsd-darwin-powerpc", "darwin-powerpc"), + ("base-bsd-darwin-ppc64", "darwin-ppc64"), + ("base-sysv-aix-powerpc", "aix-powerpc"), + ("base-sysv-aix-ppc64", "aix-ppc64"), + ("base-sysv-solaris-amd64", "solaris-amd64"), + ("base-sysv-solaris-i386", "solaris-i386"), + ("base-sysv-solaris-sparc", "solaris-sparc"), + ("base-sysv-solaris-sparc64", "solaris-sparc64"), + ("base-tos-mint-m68k", "mint-m68k"), +]; + +// Factual data from dpkg `data/abitable`: ABI pointer-size overrides. +static ABI_BITS: &[(&str, u32)] = &[("abin32", 32), ("x32", 32)]; + +fn cpu_by_name(name: &str) -> Option<&'static CpuEntry> { + CPU_TABLE.iter().find(|c| c.name == name) +} + +fn os_by_key(key: &str) -> Option<&'static OsEntry> { + OS_TABLE.iter().find(|o| o.tuple == key) +} + +/// Map a Debian architecture tuple to its Debian architecture name, using +/// the tupletable with `` expansion and first-match precedence. +pub fn debtuple_to_debarch(tuple: &DebTuple) -> Option { + let key = tuple.to_key(); + for (tuple_pattern, arch_pattern) in TUPLE_TABLE { + if tuple_pattern.contains("") { + for cpu in CPU_TABLE { + if tuple_pattern.replace("", cpu.name) == key { + return Some(arch_pattern.replace("", cpu.name)); + } + } + } else if *tuple_pattern == key { + return Some((*arch_pattern).to_string()); + } + } + None +} + +/// Map a Debian architecture name to its normalized Debian tuple. +/// +/// Handles the legacy `linux-` spelling by stripping the prefix, like +/// dpkg does for historical names that might still circulate. +pub fn debarch_to_debtuple(arch: &str) -> Option { + // Legacy `linux-` spelling: only the part up to the next dash is + // taken, mirroring the historical `/^linux-([^-]*)/` substitution. + let legacy; + let arch = if let Some(rest) = arch.strip_prefix("linux-") { + legacy = rest.split('-').next().unwrap_or("").to_string(); + legacy.as_str() + } else { + arch + }; + + for (tuple_pattern, arch_pattern) in TUPLE_TABLE { + if arch_pattern.contains("") { + for cpu in CPU_TABLE { + if arch_pattern.replace("", cpu.name) == arch { + let expanded = tuple_pattern.replace("", cpu.name); + return DebTuple::from_key(&expanded); + } + } + } else if *arch_pattern == arch { + return DebTuple::from_key(tuple_pattern); + } + } + None +} + +/// Map a Debian architecture to its GNU triplet (`cpu-system`). +pub fn debarch_to_gnutriplet(arch: &str) -> Option { + let tuple = debarch_to_debtuple(arch)?; + let cpu = cpu_by_name(&tuple.cpu)?; + let os = os_by_key(&format!("{}-{}-{}", tuple.abi, tuple.libc, tuple.os))?; + Some(format!("{}-{}", cpu.gnu, os.gnu)) +} + +/// Map a Debian architecture to its Debian multiarch triplet. +/// +/// Identical to the GNU triplet except for the i386 family, whose GNU CPU +/// names (`i486`...) are normalized to `i386`. +pub fn multiarch(arch: &str) -> Option { + let gnu = debarch_to_gnutriplet(arch)?; + let (gnu_cpu, rest) = gnu.split_once('-')?; + let mut chars = gnu_cpu.chars(); + let is_i386_family = matches!(chars.next(), Some('i')) + && matches!(chars.next(), Some(c) if ('4'..='7').contains(&c)) + && chars.as_str() == "86"; + if is_i386_family { + Some(format!("i386-{rest}")) + } else { + Some(gnu) + } +} + +/// Pointer size (bits) and endianness of a Debian architecture. +/// +/// The ABI table overrides the CPU pointer size when the architecture tuple +/// carries a size-changing ABI (e.g. `x32` is 32-bit pointers on a 64-bit +/// CPU). +pub fn abi_attrs(arch: &str) -> Option<(u32, Endian)> { + let tuple = debarch_to_debtuple(arch)?; + let cpu = cpu_by_name(&tuple.cpu)?; + let bits = ABI_BITS + .iter() + .find(|(abi, _)| *abi == tuple.abi) + .map(|(_, bits)| *bits) + .unwrap_or(cpu.bits); + Some((bits, cpu.endian)) +} + +/// Evaluate the equality of two Debian architectures, comparing their +/// normalized tuples. No wildcard matching is performed. +pub fn eq(a: &str, b: &str) -> bool { + if a == b { + return true; + } + match (debarch_to_debtuple(a), debarch_to_debtuple(b)) { + (Some(ta), Some(tb)) => ta == tb, + _ => false, + } +} + +/// Expand an architecture wildcard into a tuple, filling missing leading +/// components with `any`. Returns `None` for names that are neither a valid +/// wildcard nor a valid architecture. +fn wildcard_to_debtuple(wildcard: &str) -> Option { + let parts: Vec<&str> = wildcard.split('-').collect(); + if parts.contains(&"any") { + match parts.len() { + 4 => DebTuple::from_key(wildcard), + 3 => DebTuple::from_key(&format!("any-{wildcard}")), + 2 => DebTuple::from_key(&format!("any-any-{wildcard}")), + 1 => DebTuple::from_key(&format!("any-any-any-{wildcard}")), + _ => None, + } + } else { + debarch_to_debtuple(wildcard) + } +} + +/// Evaluate the identity of a Debian architecture against an architecture +/// wildcard (`any`, `linux-any`, `amd64`, ...). +pub fn is(real: &str, alias: &str) -> bool { + if alias == real || alias == "any" { + return true; + } + let (Some(r), Some(a)) = (debarch_to_debtuple(real), wildcard_to_debtuple(alias)) else { + return false; + }; + [a.abi.as_str(), a.libc.as_str(), a.os.as_str(), a.cpu.as_str()] + .iter() + .zip([ + r.abi.as_str(), + r.libc.as_str(), + r.os.as_str(), + r.cpu.as_str(), + ]) + .all(|(alias_part, real_part)| *alias_part == "any" || *alias_part == real_part) +} + +/// Evaluate whether a Debian architecture name is an architecture wildcard. +pub fn is_wildcard(arch: &str) -> bool { + if arch == "all" { + return false; + } + wildcard_to_debtuple(arch).is_some_and(|t| { + [ + t.abi.as_str(), + t.libc.as_str(), + t.os.as_str(), + t.cpu.as_str(), + ] + .contains(&"any") + }) +} + +/// Validate an architecture name syntax. +/// +/// With `positive`, negated names (leading `!`) are rejected; otherwise they +/// are allowed (as found in bracketed dependency restrictions). +pub fn is_invalid(arch: &str, positive: bool) -> bool { + let body = if positive { + arch + } else { + arch.strip_prefix('!').unwrap_or(arch) + }; + let mut chars = body.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphanumeric() => { + !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') + } + _ => true, + } +} + +/// Parse a whitespace-separated architecture list, validating every entry. +pub fn list_parse(list: &str) -> Result, String> { + let arches: Vec = list.split_whitespace().map(str::to_string).collect(); + for arch in &arches { + if is_invalid(arch, false) { + return Err(format!( + "'{arch}' is not a valid architecture in list '{list}'" + )); + } + } + Ok(arches) +} + +/// Evaluate whether `host_arch` applies to a bracketed architecture +/// restriction list (negations with `!`), as found in dependencies. +pub fn is_concerned(host_arch: &str, arches: &[&str]) -> bool { + let mut seen_arch = false; + for arch in arches { + let arch = arch.to_lowercase(); + if let Some(negated) = arch.strip_prefix('!') { + if is(host_arch, negated) { + seen_arch = false; + break; + } + // «!arch» includes by default all other arches unless they also + // appear in a «!otherarch». + seen_arch = true; + } else if is(host_arch, &arch) { + seen_arch = true; + break; + } + } + seen_arch +} + +/// All currently known Debian architecture names, in table order +/// (the equivalent of `dpkg-architecture -L`). +pub fn valid_arches() -> Vec { + let mut arches = Vec::new(); + for os in OS_TABLE { + for cpu in CPU_TABLE { + let tuple = DebTuple { + abi: os.tuple.split('-').next().unwrap_or("").to_string(), + libc: os.tuple.split('-').nth(1).unwrap_or("").to_string(), + os: os.tuple.split('-').nth(2).unwrap_or("").to_string(), + cpu: cpu.name.to_string(), + }; + if let Some(arch) = debtuple_to_debarch(&tuple) { + arches.push(arch); + } + } + } + arches +} + +/// Match a GNU config.guess CPU string against the CPU table, in table +/// order (first match wins), returning the Debian CPU name. +fn cpu_from_config(value: &str) -> Option<&'static str> { + static REGEXES: OnceLock> = OnceLock::new(); + let regexes = REGEXES.get_or_init(|| { + CPU_TABLE + .iter() + .map(|c| { + ( + c.name, + Regex::new(&format!("^(?:{})$", c.guess)).expect("valid cpu regex"), + ) + }) + .collect() + }); + regexes + .iter() + .find(|(_, re)| re.is_match(value)) + .map(|(name, _)| *name) +} + +/// Match a GNU config.guess system string against the OS table, in table +/// order, returning the Debian `abi-libc-os` key. +fn os_from_config(value: &str) -> Option<&'static str> { + static REGEXES: OnceLock> = OnceLock::new(); + let regexes = REGEXES.get_or_init(|| { + OS_TABLE + .iter() + .map(|o| { + ( + o.tuple, + Regex::new(&format!("^(?:.*-)?(?:{})$", o.guess)).expect("valid os regex"), + ) + }) + .collect() + }); + regexes + .iter() + .find(|(_, re)| re.is_match(value)) + .map(|(key, _)| *key) +} + +/// Determine the current machine's Debian architecture from `uname`, +/// without requiring dpkg. Used as a fallback when the `dpkg` frontend is +/// unavailable. +fn from_uname() -> Option { + let output = Command::new("uname").arg("-m").output().ok()?; + if !output.status.success() { + return None; + } + let machine = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + let cpu = cpu_from_config(&machine)?; + let system = std::env::consts::OS; + let os_key = os_from_config(system)?; + + DebTuple::from_key(&format!("{os_key}-{cpu}")).and_then(|t| debtuple_to_debarch(&t)) +} + +/// Determine the native (build) Debian architecture. +/// +/// Mirrors `dpkg --print-architecture` (what `dpkg-architecture` uses for +/// the `DEB_BUILD_*` variables): the authoritative answer comes from the +/// dpkg database itself; if the `dpkg` frontend cannot be executed, the +/// architecture is derived from `uname` through the same tables. +pub fn native() -> Result { + if let Ok(output) = Command::new("dpkg").arg("--print-architecture").output() + && output.status.success() + { + let arch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !arch.is_empty() && debarch_to_debtuple(&arch).is_some() { + return Ok(arch); + } + } + from_uname().ok_or_else(|| "cannot determine native Debian architecture".to_string()) +} + +/// Compute the complete architecture environment, the equivalent of +/// `dpkg-architecture -f [-a ]`: all `DEB_BUILD_*`, `DEB_HOST_*` +/// and `DEB_TARGET_*` variables, recomputed from scratch (force mode). +/// +/// The target architecture defaults to the host architecture, and the host +/// architecture defaults to the native build architecture, exactly like +/// dpkg-architecture. +pub fn arch_env(host_arch: Option<&str>) -> Result, String> { + let build_arch = native()?; + let host_arch = host_arch.unwrap_or(&build_arch).to_string(); + let target_arch = host_arch.clone(); + + let mut env = BTreeMap::new(); + for (role, arch) in [ + ("BUILD", build_arch), + ("HOST", host_arch), + ("TARGET", target_arch), + ] { + let tuple = debarch_to_debtuple(&arch) + .ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + + env.insert(format!("DEB_{role}_ARCH"), arch.clone()); + env.insert(format!("DEB_{role}_ARCH_ABI"), tuple.abi.clone()); + env.insert(format!("DEB_{role}_ARCH_LIBC"), tuple.libc.clone()); + env.insert(format!("DEB_{role}_ARCH_OS"), tuple.os.clone()); + env.insert(format!("DEB_{role}_ARCH_CPU"), tuple.cpu.clone()); + + let (bits, endian) = + abi_attrs(&arch).ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + env.insert(format!("DEB_{role}_ARCH_BITS"), bits.to_string()); + env.insert(format!("DEB_{role}_ARCH_ENDIAN"), endian.to_string()); + + let multi = multiarch(&arch) + .ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + env.insert(format!("DEB_{role}_MULTIARCH"), multi); + + let gnu_type = debarch_to_gnutriplet(&arch) + .ok_or_else(|| format!("unknown Debian architecture '{arch}'"))?; + let (gnu_cpu, gnu_system) = gnu_type + .split_once('-') + .ok_or_else(|| format!("invalid GNU triplet '{gnu_type}'"))?; + env.insert(format!("DEB_{role}_GNU_CPU"), gnu_cpu.to_string()); + env.insert(format!("DEB_{role}_GNU_SYSTEM"), gnu_system.to_string()); + env.insert(format!("DEB_{role}_GNU_TYPE"), gnu_type); + } + Ok(env) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tuple_mapping() { + let t = debarch_to_debtuple("amd64").unwrap(); + assert_eq!( + t, + DebTuple { + abi: "base".into(), + libc: "gnu".into(), + os: "linux".into(), + cpu: "amd64".into() + } + ); + + let t = debarch_to_debtuple("armhf").unwrap(); + assert_eq!(t.abi, "eabihf"); + assert_eq!(t.cpu, "arm"); + + assert!(debarch_to_debtuple("not-an-arch").is_none()); + // Legacy linux- prefix handling. + assert_eq!( + debarch_to_debtuple("linux-amd64").map(|t| t.cpu), + Some("amd64".to_string()) + ); + } + + #[test] + fn gnu_triplets_and_multiarch() { + assert_eq!( + debarch_to_gnutriplet("amd64").as_deref(), + Some("x86_64-linux-gnu") + ); + assert_eq!( + debarch_to_gnutriplet("armhf").as_deref(), + Some("arm-linux-gnueabihf") + ); + assert_eq!( + debarch_to_gnutriplet("i386").as_deref(), + Some("i686-linux-gnu") + ); + assert_eq!(multiarch("i386").as_deref(), Some("i386-linux-gnu")); + assert_eq!( + multiarch("amd64").as_deref(), + Some("x86_64-linux-gnu") + ); + assert_eq!( + multiarch("arm64").as_deref(), + Some("aarch64-linux-gnu") + ); + } + + #[test] + fn bits_and_endian() { + assert_eq!(abi_attrs("amd64"), Some((64, Endian::Little))); + assert_eq!(abi_attrs("s390x"), Some((64, Endian::Big))); + assert_eq!(abi_attrs("armhf"), Some((32, Endian::Little))); + // x32: 32-bit pointers on a 64-bit CPU (abitable override). + assert_eq!(abi_attrs("x32"), Some((32, Endian::Little))); + assert_eq!(abi_attrs("mipsn32"), Some((32, Endian::Big))); + } + + #[test] + fn equality_and_wildcards() { + assert!(eq("amd64", "amd64")); + assert!(eq("linux-amd64", "amd64")); + assert!(!eq("amd64", "i386")); + + assert!(is("amd64", "amd64")); + assert!(is("amd64", "any")); + assert!(is("amd64", "linux-any")); + // A plain `linux-arm` wildcard pins the default ABI, so it does not + // match armhf (whose tuple carries the eabihf ABI). + assert!(!is("armhf", "linux-arm")); + assert!(!is("amd64", "linux-arm")); + assert!(is("hurd-i386", "any-i386")); + + assert!(is_wildcard("any")); + assert!(is_wildcard("linux-any")); + assert!(is_wildcard("gnu-any-amd64")); + assert!(!is_wildcard("amd64")); + assert!(!is_wildcard("all")); + } + + #[test] + fn restriction_lists() { + assert!(!is_invalid("amd64", true)); + assert!(!is_invalid("!amd64", false)); + assert!(is_invalid("!amd64", true)); + assert!(is_invalid("-bad", false)); + assert!(is_invalid("", false)); + + assert_eq!( + list_parse("amd64 arm64 !i386").unwrap(), + vec![ + "amd64".to_string(), + "arm64".to_string(), + "!i386".to_string() + ] + ); + assert!(list_parse("amd64 bad$").is_err()); + + assert!(is_concerned("amd64", &["!i386"])); + // Order matters: a positive match short-circuits before a later + // negation (verified against Dpkg::Arch). + assert!(is_concerned("amd64", &["amd64", "!amd64"])); + assert!(!is_concerned("amd64", &["!amd64", "amd64"])); + assert!(!is_concerned("i386", &["!i386"])); + assert!(is_concerned("amd64", &["any"])); + assert!(is_concerned("armhf", &["linux-any"])); + } + + #[test] + fn known_arches() { + let arches = valid_arches(); + for expected in ["amd64", "armhf", "armel", "i386", "riscv64", "x32", "hurd-i386"] { + assert!(arches.iter().any(|a| a == expected), "missing {expected}"); + } + } + + #[test] + fn env_dump_amd64_native() { + let env = arch_env(Some("amd64")).unwrap(); + assert_eq!(env.get("DEB_BUILD_ARCH").unwrap(), "amd64"); + assert_eq!(env.get("DEB_HOST_ARCH").unwrap(), "amd64"); + assert_eq!(env.get("DEB_TARGET_ARCH").unwrap(), "amd64"); + assert_eq!(env.get("DEB_HOST_GNU_TYPE").unwrap(), "x86_64-linux-gnu"); + assert_eq!(env.get("DEB_HOST_MULTIARCH").unwrap(), "x86_64-linux-gnu"); + assert_eq!(env.get("DEB_HOST_ARCH_BITS").unwrap(), "64"); + assert_eq!(env.get("DEB_HOST_ARCH_ENDIAN").unwrap(), "little"); + assert_eq!(env.get("DEB_HOST_ARCH_OS").unwrap(), "linux"); + assert_eq!(env.get("DEB_HOST_ARCH_CPU").unwrap(), "amd64"); + assert_eq!(env.get("DEB_HOST_ARCH_ABI").unwrap(), "base"); + assert_eq!(env.get("DEB_HOST_ARCH_LIBC").unwrap(), "gnu"); + assert_eq!(env.get("DEB_HOST_GNU_CPU").unwrap(), "x86_64"); + assert_eq!(env.get("DEB_HOST_GNU_SYSTEM").unwrap(), "linux-gnu"); + // Exactly 11 variables per role. + assert_eq!(env.len(), 33); + } + + #[test] + fn env_dump_cross_armhf() { + let env = arch_env(Some("armhf")).unwrap(); + // Build stays native while host/target follow the requested arch. + assert_ne!(env.get("DEB_BUILD_ARCH").unwrap(), "armhf"); + assert_eq!(env.get("DEB_HOST_ARCH").unwrap(), "armhf"); + assert_eq!(env.get("DEB_HOST_GNU_TYPE").unwrap(), "arm-linux-gnueabihf"); + assert_eq!(env.get("DEB_HOST_MULTIARCH").unwrap(), "arm-linux-gnueabihf"); + assert_eq!(env.get("DEB_TARGET_ARCH").unwrap(), "armhf"); + } + + #[test] + fn env_dump_unknown_arch() { + assert!(arch_env(Some("definitely-not-an-arch")).is_err()); + } +} diff --git a/src/debian/mod.rs b/src/debian/mod.rs index dee66a5..8b61192 100644 --- a/src/debian/mod.rs +++ b/src/debian/mod.rs @@ -3,12 +3,15 @@ //! These components are independent from any build orchestration and can be //! used by any pkh submodule (or external consumers of the library): //! +//! - [`arch`]: Debian architecture tables and lookups (dpkg-architecture) //! - [`control`]: deb822 paragraph parsing/writing and `debian/control` //! - [`checksums`]: file checksum registry (`Dpkg::Checksums` equivalent) +//! - [`deps`]: dependency grammar and evaluation (dpkg-checkbuilddeps) //! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`) -//! - [`version`]: Debian version splitting/validation +//! - [`version`]: Debian version splitting/validation/comparison //! - [`changelog`]: `debian/changelog` entry parsing +pub mod arch; pub mod changelog; pub mod checksums; pub mod control;