Compare commits
179
Commits
f54be8ad8c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46407375d0 | ||
|
|
ce19c5648b | ||
|
|
99c6f63cd4 | ||
|
|
4b52f8e9e9 | ||
|
|
6df490cf9a | ||
|
|
f5b7704647 | ||
|
|
4c1edc7dcd
|
||
|
|
9a66f8f7df | ||
|
|
7a6337e1cb | ||
|
|
9186bbbe51 | ||
|
|
681fa3d687 | ||
|
|
cc5bbd2297 | ||
|
|
5a1c1672cd | ||
|
|
adde0ee977 | ||
|
|
84405a6762 | ||
|
|
8250a0e3b1 | ||
|
|
edfd7ed5ed | ||
|
|
6545d327e4
|
||
|
|
b118a54bff | ||
|
|
43f8a9e275
|
||
|
|
ff41edbd47 | ||
|
|
9e0b6a37a6 | ||
|
|
ac19fd9d65 | ||
|
|
02e1f739c3 | ||
|
|
8c6f6f4028 | ||
|
|
37e0b5c978 | ||
|
|
6c0b200241 | ||
|
|
4edf331444 | ||
|
|
fa121f08ec | ||
|
|
ac83a939e3 | ||
|
|
1145ca55eb | ||
|
|
b99f945b98 | ||
|
|
ae5b0042e4 | ||
|
|
5b0cc08d8f | ||
|
|
ff1f8c7ccd | ||
|
|
31fe6dc524 | ||
|
|
5592d5a6e4 | ||
|
|
bb719e7c80 | ||
|
|
af9845c480 | ||
|
|
02cb5306f3 | ||
|
|
5c026a7050 | ||
|
|
9238aa961f | ||
|
|
d1056fbbbf | ||
|
|
a0e74073bf
|
||
|
|
4f5246ccd3 | ||
|
|
dd2438a72c | ||
|
|
012df20961 | ||
|
|
47bb7c608e | ||
|
|
6caedce61a | ||
|
|
bd8f814a53 | ||
|
|
4fae02bc85 | ||
|
|
c2dae4f3f9 | ||
|
|
0421a91e01 | ||
|
|
54cb04ba27 | ||
|
|
bb76e41908 | ||
|
|
d64e472845 | ||
|
|
052c02cdc3 | ||
|
|
27b1083b15 | ||
|
|
a7d2cfdc6e | ||
|
|
ff635b305b | ||
|
|
781ed204c2 | ||
|
|
0235ec6457 | ||
|
|
fa399f64b2 | ||
|
|
e6f2012835 | ||
|
|
ffac4d6b57 | ||
|
|
04a572cd77 | ||
|
|
fc0d2f247e | ||
|
|
ccd2e37385 | ||
|
|
52d5ad064f | ||
|
|
b8b2be5acf | ||
|
|
dd1c70c91a | ||
|
|
12407c8eac | ||
|
|
7601524b7c | ||
|
|
7af767898e | ||
|
|
ead97e1213 | ||
|
|
6e5ecd2f45 | ||
|
|
69f3c3954e | ||
|
|
c106ebe315 | ||
|
|
941f91decf | ||
|
|
b489db2728 | ||
|
|
c1f8893576 | ||
|
|
e640b153bd | ||
|
|
231c478d0b | ||
|
|
1aa0ca3d2f | ||
|
|
b20acf3199 | ||
|
|
3feca504fc | ||
|
|
85f0d7d92f | ||
|
|
c45edcee76 | ||
|
|
1fc1d1aa77 | ||
|
|
af870cb7cb | ||
|
|
2b017dcf43 | ||
|
|
4ab41e691a | ||
|
|
c18f1fe9c2 | ||
|
|
e7b35f5c37 | ||
|
|
54274e9079 | ||
|
|
93296c26c1 | ||
|
|
d6bad9fbbe | ||
|
|
607711a6b5 | ||
|
|
47b462ad61 | ||
|
|
fa39851783 | ||
|
|
22e43741f3 | ||
|
|
36875513ee | ||
|
|
06e591c665 | ||
|
|
12828f8498 | ||
|
|
eaf1b40369 | ||
|
|
27ab4cb9ad | ||
|
|
3501096107 | ||
|
|
174a13df39 | ||
|
|
dd006f7b80 | ||
|
|
d5b76ec8d8 | ||
|
|
70e375a34d | ||
|
|
8ad50aaf83 | ||
|
|
efb18bfa37 | ||
|
|
57db98d776 | ||
|
|
775e3d3b8a | ||
|
|
a7cd4244b2 | ||
|
|
d2bb311f74 | ||
|
|
4c26122357 | ||
|
|
3ed95725e4 | ||
|
|
afedde1f2b | ||
|
|
bc3d07abaa | ||
|
|
bac82f0afe | ||
|
|
05e7c55d32 | ||
|
|
f055b70281 | ||
|
|
77420e723a | ||
|
|
8e06b2074d | ||
|
|
84824f61c6 | ||
|
|
9cb3e29a3e | ||
|
|
c0b7e341fc | ||
|
|
e8d4b98f52 | ||
|
|
0c2cf0ac5e | ||
|
|
f27d27ea99 | ||
|
|
9228ff448b | ||
|
|
9b98f5c7c3 | ||
|
|
d044f757e9 | ||
|
|
9c3394750d | ||
|
|
60976d3feb | ||
|
|
ae420989f9 | ||
|
|
4af8dbddb0 | ||
|
|
3a454b0811 | ||
|
|
50ae12cafe | ||
|
|
38562abe2c | ||
|
|
dc6a019a13 | ||
|
|
f72b35acfa | ||
|
|
6a5c5a7106 | ||
|
|
592e98c1e9 | ||
|
|
512a1cb778 | ||
|
|
93176aa479 | ||
|
|
60ee99adc8 | ||
|
|
f6fed7328b | ||
|
|
213668fa82 | ||
|
|
f508f20846 | ||
|
|
685538e637 | ||
|
|
ea70ddc10d | ||
|
|
48f6e6ce4e | ||
|
|
b34e86dcfe | ||
|
|
5500f98586 | ||
|
|
3b99ece39a
|
||
|
|
c7af3bc9b1 | ||
|
|
182a06ffbe | ||
|
|
348abf61b9
|
||
|
|
86612efb00
|
||
|
|
9f47e7dae8
|
||
|
|
c9b48d4573 | ||
|
|
429429e414 | ||
|
|
42fcfc2dfa | ||
|
|
dfaab0606a | ||
|
|
489b2aa29b | ||
|
|
4c52336000 | ||
|
|
0ad020ae19
|
||
|
|
4a8ff9ac0d
|
||
|
|
c2e1288bc5
|
||
|
|
9d2519ed7b
|
||
|
|
e5adf600c3
|
||
|
|
e2d201d815 | ||
|
|
c1079d43be | ||
|
|
18be62d4a0 | ||
|
|
24faad6e11 | ||
|
|
cc7cbaaa8f |
Binary file not shown.
|
After Width: | Height: | Size: 799 KiB |
+105
-8
@@ -3,6 +3,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "ci-test" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
@@ -13,7 +14,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
image: ubuntu:26.04
|
||||
options: --privileged --cap-add SYS_ADMIN --security-opt apparmor:unconfined
|
||||
steps:
|
||||
- name: Set up container image
|
||||
@@ -30,6 +31,16 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
- name: Restore cargo/target cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ci-build-${{ github.sha }}
|
||||
restore-keys: |
|
||||
ci-build-
|
||||
- name: Build
|
||||
run: cargo build
|
||||
env:
|
||||
@@ -38,6 +49,37 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
test:
|
||||
# Disabled: test suite is too heavy for current CI infra (CPU/RAM exhaustion).
|
||||
# Re-enable by removing the `if: false` line below.
|
||||
if: false
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:26.04
|
||||
options: --privileged --cap-add SYS_ADMIN --security-opt apparmor:unconfined
|
||||
steps:
|
||||
- name: Set up container image
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y nodejs sudo curl wget ca-certificates build-essential
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Restore cargo/target cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ci-build-${{ github.sha }}
|
||||
restore-keys: |
|
||||
ci-build-
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
- name: Install runtime system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -53,13 +95,68 @@ jobs:
|
||||
snap:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
snap-file: ${{ steps.build-snap.outputs.snap }}
|
||||
container:
|
||||
# Official snapcraft image: an Ubuntu userland matching the snap base,
|
||||
# with snapcraft preinstalled (no snapd/systemd/LXD required).
|
||||
image: ghcr.io/canonical/snapcraft:8_core24
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: snapcore/action-build@v1
|
||||
id: build-snap
|
||||
- uses: actions/upload-artifact@v3
|
||||
- name: Install build prerequisites
|
||||
run: |
|
||||
apt-get update -q
|
||||
apt-get install -y -q --no-install-recommends git curl nodejs
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build snap
|
||||
run: |
|
||||
# GitHub Actions overrides the image entrypoint (pebble), which is
|
||||
# what normally puts craftctl on PATH; restore it, allow git to run
|
||||
# in the checked-out tree, and provide rustup for the rust plugin.
|
||||
git config --global --add safe.directory '*'
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
export PATH="/usr/libexec/snapcraft:$HOME/.cargo/bin:$PATH"
|
||||
snapcraft pack --destructive-mode
|
||||
- name: Upload snap artifact
|
||||
# v4 refuses to run outside github.com (GHESNotSupportedError); Gitea
|
||||
# implements the artifact API used by v3, so v3 is the supported choice.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: snap
|
||||
path: ${{ steps.build-snap.outputs.snap }}
|
||||
path: ./*.snap
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
# Publishes the crate to crates.io on a v* tag. Trusted publishing
|
||||
# (OIDC) is GitHub-Actions-only, so authentication goes through a
|
||||
# crates.io API token stored as the CARGO_REGISTRY_TOKEN secret,
|
||||
# scoped to the pkh crate.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:26.04
|
||||
options: --privileged --cap-add SYS_ADMIN --security-opt apparmor:unconfined
|
||||
steps:
|
||||
- name: Set up container image
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y nodejs sudo curl wget ca-certificates build-essential
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
- name: Check the tag matches the crate version
|
||||
# cargo publish ships whatever version Cargo.toml declares,
|
||||
# regardless of the tag: a mismatch must fail loudly instead of
|
||||
# publishing the wrong version under the release tag.
|
||||
run: |
|
||||
crate_version="$(awk -F'"' '/^version =/{print $2; exit}' Cargo.toml)"
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
if [ "$crate_version" != "$tag_version" ]; then
|
||||
echo "tag $GITHUB_REF_NAME does not match crate version $crate_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Publish
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
+7
-1
@@ -1,2 +1,8 @@
|
||||
*.lock
|
||||
target
|
||||
|
||||
# Local snapcraft builds
|
||||
.craft
|
||||
parts
|
||||
prim
|
||||
stage
|
||||
*.snap
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# AGENTS.md
|
||||
|
||||
Conventions for working in this tree. They apply to every commit; the
|
||||
whole history follows them.
|
||||
|
||||
## Before every commit
|
||||
|
||||
Run, in order, and make sure they are clean before committing:
|
||||
|
||||
```sh
|
||||
cargo fmt --all
|
||||
cargo clippy --all-targets --all-features # zero warnings
|
||||
```
|
||||
|
||||
CI builds and lints with `RUSTFLAGS: -Dwarnings`, so a `cargo build`
|
||||
warning fails the gate too. `cargo fmt` may amend files you did not
|
||||
touch — include those changes in the commit (or in a separate `fmt:`
|
||||
commit) rather than leaving the tree dirty.
|
||||
|
||||
The test suite is heavy (chroots, ssh, network): always run the
|
||||
`#[cfg(test)]` modules of what you touch, and the full suite when
|
||||
changing shared plumbing (`report`, `logfmt`, `test_support`,
|
||||
`debian/`). Tests marked `#[ignore]` shell out or hit the network and
|
||||
are for deliberate ad-hoc runs (`cargo test -- --ignored`), not for the
|
||||
pre-commit pass. Building needs the gpgme/openssl system packages
|
||||
(`pkg-config libssl-dev libgpg-error-dev libgpgme-dev`).
|
||||
|
||||
## Commit messages
|
||||
|
||||
There are no conventional-commit types; the format is a component scope
|
||||
and a summary:
|
||||
|
||||
```
|
||||
<scope>: <short summary>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The scope is the component touched — the module under `src/` (file or
|
||||
directory), named after the user-facing subcommand when that differs:
|
||||
- `pull` — source package download (`src/pull.rs`)
|
||||
- `chlog` — changelog entry generation (`src/changelog.rs`)
|
||||
- `build` — source package builds, .dsc (`src/build/`)
|
||||
- `deb` — binary package builds, .deb (`src/deb/`)
|
||||
- `put` — PPA/archive upload (`src/put/`)
|
||||
- `new` — package scaffolding (`src/new/`)
|
||||
- `lint` — tree linting (`src/lint/`)
|
||||
- `prune`, `package_info` — remaining subcommand modules
|
||||
- `context` — build contexts: local, ssh, chroot/schroot, unshare
|
||||
(`src/context/`)
|
||||
- `interrupt` — Ctrl+C interception and interrupt-time cleanup
|
||||
(`src/interrupt.rs`)
|
||||
- `debian` — Debian format primitives: control, versions, checksums,
|
||||
arch (`src/debian/`)
|
||||
- `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro
|
||||
integration
|
||||
- `report` — BuildView/Prompter ports and the views implementing them
|
||||
- `ui`, `logfmt` — terminal rendering and output classification
|
||||
- `data` — the `data/*.yml` embed convention itself; content changes
|
||||
to a data file belong to the commit of the module consuming it
|
||||
- `cli` — the binary, argument wiring (`src/main.rs`)
|
||||
- `test` — test-only changes (shared plumbing: `src/test_support.rs`)
|
||||
- `deps` — dependency additions/bumps (manifests, lockfile)
|
||||
- `fmt`, `clippy` — rustfmt/clippy fixups
|
||||
- `ci`, `snap`, `docs` — workflows, snap packaging, README
|
||||
- Use the submodule path when the change is confined to one
|
||||
(`apt/keyring`, `debian/version`).
|
||||
- A commit touching several components should be split into one commit
|
||||
per component when practical; otherwise comma-join the scopes without
|
||||
spaces (`pull,deb`).
|
||||
- Summary: imperative mood, lowercase first letter (proper nouns keep
|
||||
theirs: Ubuntu, SRU, lintian), no trailing period, max ~72 characters.
|
||||
- Body (expected for anything nontrivial): separated by a blank line,
|
||||
wrapped at 72 columns; explain why, and the design when the approach
|
||||
was a choice among alternatives. Reference issues as `#123`.
|
||||
- Reverts use git's default `Revert "<original subject>"`.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
chlog: fall back to the changelog history when no version tag exists
|
||||
ui: ellipsize fake-terminal pane lines wider than the terminal
|
||||
lint: add pkh lint, wrapping lintian for parity plus pkh-native checks
|
||||
chlog: number Ubuntu backports with the per-release SRU scheme
|
||||
deb: resolve cross pkg-config against the target multiarch
|
||||
pull,deb: add top-level --pocket option
|
||||
debian/version: dpkg-compatible version comparison
|
||||
deps: bump git2 to 0.21
|
||||
fmt: apply rustfmt
|
||||
docs: refresh the README roadmap for 1.0
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
- The crate denies missing docs (`#![deny(missing_docs)]` in
|
||||
`src/lib.rs`): every public item carries a doc comment, and the module
|
||||
list there is the layout map — keep it in sync when adding a module.
|
||||
- Subcommand business logic lives in the library and reports through the
|
||||
`report` ports (`BuildView`, `Prompter`) instead of printing;
|
||||
`src/main.rs` is argument wiring only. Subprocess output
|
||||
classification is pure logic in `logfmt`, testable without a pty.
|
||||
- Static reference data (series tables, keyserver URLs, licenses,
|
||||
forges, templates) lives in `data/*.yml`, embedded with the
|
||||
`embed_data!` macro — not in hardcoded tables.
|
||||
- Comments state constraints the code cannot show; no narration.
|
||||
- Anything user-facing (subcommands, flags, option defaults) is
|
||||
reflected in `README.md` — including its roadmap checklists — before
|
||||
commit.
|
||||
Generated
+3068
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ name = "pkh"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["vhaudiquet"]
|
||||
description = "pkh is a packaging helper for Debian/Ubuntu packages"
|
||||
license = "MIT OR GPL-2.0-only"
|
||||
repository = "https://git.vhaudiquet.fr/vhaudiquet/pkh"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.51", features = ["cargo"] }
|
||||
@@ -18,6 +22,7 @@ regex = "1"
|
||||
chrono = "0.4"
|
||||
tokio = { version = "1.41.1", features = ["full"] }
|
||||
sha2 = "0.10.8"
|
||||
sha1 = "0.10"
|
||||
md-5 = "0.10"
|
||||
hex = "0.4.3"
|
||||
log = "0.4.28"
|
||||
@@ -33,6 +38,9 @@ ssh2 = "0.9.5"
|
||||
gpgme = "0.11"
|
||||
serde_yaml = "0.9"
|
||||
lazy_static = "1.4.0"
|
||||
unicode-width = "0.2"
|
||||
parking_lot = "0.12"
|
||||
suppaftp = "12"
|
||||
|
||||
[dev-dependencies]
|
||||
test-log = "0.2.19"
|
||||
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-2026 Valentin Haudiquet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
`pkh` is a packaging helper for Debian/Ubuntu packages.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
From crates.io:
|
||||
|
||||
```
|
||||
cargo install pkh
|
||||
```
|
||||
|
||||
Or build from source (the same system packages are needed either way):
|
||||
|
||||
```
|
||||
sudo apt install pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
git clone https://git.vhaudiquet.fr/vhaudiquet/pkh.git
|
||||
cd pkh
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
At runtime pkh shells out to the Debian packaging toolchain (git,
|
||||
dpkg-dev, quilt, mmdebstrap, lintian, pristine-tar, ...): install the
|
||||
ones your workflows use, or build the classic snap from
|
||||
`snap/snapcraft.yaml` (`snapcraft pack`), which carries them.
|
||||
|
||||
## Usage and features
|
||||
|
||||
### Basic concepts
|
||||
@@ -25,11 +49,19 @@ Options:
|
||||
Commands and workflows include:
|
||||
```
|
||||
Commands:
|
||||
new Scaffold a new Debian source package (buildable right away)
|
||||
pull Pull a source package from the archive or git
|
||||
chlog Auto-generate changelog entry, editing it, committing it afterwards
|
||||
build Build the source package (into a .dsc)
|
||||
put Upload the built source package to a PPA
|
||||
deb Build the source package into binary package (.deb)
|
||||
lint Lint the package (lintian wrapper + pkh-native checks)
|
||||
prune Prune residual pkh build artifacts and caches
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
### Examples
|
||||
@@ -65,68 +97,25 @@ That is a lot of different tools and operations. With pkh, the same workflow:
|
||||
pkh pull hello # needs -d ubuntu if you are not running Ubuntu
|
||||
# Apply the patch to the package
|
||||
...
|
||||
pkh commit -m "Applied patch xxx"
|
||||
git add debian/patches/xxx.patch
|
||||
git commit -m "Applied patch xxx"
|
||||
pkh chlog
|
||||
git add debian/changelog
|
||||
git commit -m "d/changelog"
|
||||
# Test that the package builds
|
||||
pkh build
|
||||
pkh deb
|
||||
# Upload the package to a ppa
|
||||
pkh put --ppa user/hello_xxx
|
||||
# Push previously commited changes
|
||||
# Push the commits to your fork
|
||||
git push xxx user-fork
|
||||
```
|
||||
|
||||
## Roadmap: features needed for 1.0
|
||||
|
||||
Basically, wrapping the basic debian workflows.
|
||||
Missing features:
|
||||
- [ ] `pkh pull`
|
||||
- [x] Obtain package sources from git
|
||||
- [x] Obtain package sources from the archive (fallback)
|
||||
- [x] Obtain package source from PPA (--ppa)
|
||||
- [ ] Obtain a specific version of the package
|
||||
- [x] Fetch the correct git branch for series on Ubuntu
|
||||
- [ ] Try to fetch the correct git branch for series on Debian, or fallback to the archive
|
||||
- [ ] `pkh chlog`
|
||||
- [x] Auto-generate changelog entry
|
||||
- [ ] Extra flags: backport, non-maintainer upload, no change rebuild, ...
|
||||
- [ ] Commit changelog entry
|
||||
- [ ] `pkh build`
|
||||
- [x] Build the source package
|
||||
- [ ] `pkh deb`
|
||||
- [x] Build the binary package
|
||||
- [x] Build for a specific architecture
|
||||
- [ ] Three build modes:
|
||||
- [ ] Build locally (discouraged)
|
||||
- [x] Build using unshare chroot, with binary emulation (default)
|
||||
- [x] Cross-compilation
|
||||
- [ ] Async build
|
||||
- [ ] `pkh status`
|
||||
- [ ] Show build status
|
||||
- [ ] `pkh put`
|
||||
- [ ] Upload the source package to a PPA
|
||||
- [ ] Upload the source package to the archive
|
||||
- [ ] `pkh commit`
|
||||
- [ ] Commit the changes to git
|
||||
- [ ] `pkh lint`
|
||||
- [ ] Lint the package
|
||||
- [ ] `pkh test`
|
||||
- [ ] Run autopkgtest
|
||||
- [ ] Provide options: local (discouraged), chroot, VM?, ppa
|
||||
- [ ] Async test
|
||||
|
||||
## Nice-to-have features
|
||||
|
||||
- [ ] 'pkh pull'
|
||||
- [ ] Cache the Sources.gz files, to improve speed
|
||||
- [ ] Work in an already downloaded package, to git pull and re-fetch orig tar gz
|
||||
- [ ] 'pkh context'
|
||||
- [x] Select, add, remove, list contexts
|
||||
- [x] Context-scoped command execution
|
||||
- [ ] Context-scoped deb and test commands
|
||||
- [ ] Per-architecture contexts
|
||||
- [ ] Per-series contexts
|
||||
- [x] ssh contexts
|
||||
- [ ] docker, lxc contexts?
|
||||
- [ ] context push, context pop: context stack
|
||||
## Future improvement ideas
|
||||
|
||||
- pull: try to fetch the correct git branch for series on Debian
|
||||
- deb: asynchronous build, detachable and monitorable
|
||||
- put: allow uploads to Debian or Ubuntu archives
|
||||
- test: add 'pkh test' to run autopkgtests
|
||||
- pull: cache Sources.gz files to improve speed
|
||||
- pull: 'pkh pull' in a package tree should git pull and re-fetch orig tgz
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
## Static data needed for pkh operations
|
||||
## Instead of hardcoding the data in code, data files allow to quickly
|
||||
## update and maintain such data in one unique place
|
||||
## The goal is to have the minimal possible set of data necessary
|
||||
## to grab the actual data. For example we don't want to store every Ubuntu
|
||||
## or Debian series, but rather pointers to where that data lives: each dist
|
||||
## entry below carries its series sources (the local distro-info CSV, with
|
||||
## the network URL as fallback).
|
||||
##
|
||||
## Per-dist keys beyond the series pointers:
|
||||
## mirrors: the archive mirrors, each a URL serving a set of
|
||||
## architectures: `primary` (the main archive, whose url
|
||||
## doubles as the dist's base URL) and, where they exist,
|
||||
## the others (`ports`). `security_url` is the sibling
|
||||
## host serving the -security pocket for the same arches
|
||||
## (ports mirrors serve their own security); `archs` is
|
||||
## an explicit list, or the `all` sentinel when one
|
||||
## mirror serves every architecture (Debian's case — an
|
||||
## exhaustive list would rot each time an arch is added).
|
||||
## Host matching treats a URI as official when its host
|
||||
## equals a mirror host or is a subdomain of it, so the
|
||||
## country mirrors (fr.archive.ubuntu.com) count too.
|
||||
## components: the archive components (main, universe, contrib, ...)
|
||||
## a cross-build environment enables on official sources.
|
||||
## Live archive operations keep resolving components from
|
||||
## Release files; this is the offline default.
|
||||
## cross_pockets: the pockets a cross-build environment enables for a
|
||||
## series (`<series>-updates`, ...). Deliberately not the
|
||||
## `pockets` key: that one is the *search order* of pull,
|
||||
## where backports must not fold in.
|
||||
## suite_aliases: the changelog suite names that alias a series
|
||||
## codename: Debian packages conventionally target
|
||||
## 'unstable' where the series data carries 'sid'.
|
||||
## Mapped suite name -> series codename; the two
|
||||
## names identify the same series, and the selector
|
||||
## offers the aliased entry as '<suite> (<series>)'.
|
||||
## build_profiles: the vendor's default DEB_BUILD_PROFILES (Ubuntu
|
||||
## activates derivative.ubuntu noudeb, Debian none),
|
||||
## mirroring what Dpkg::BuildProfiles resolves when the
|
||||
## variable is unset.
|
||||
dist:
|
||||
debian:
|
||||
mirrors:
|
||||
primary:
|
||||
url: https://deb.debian.org/debian
|
||||
# One mirror serves every architecture.
|
||||
archs: all
|
||||
components: [main, contrib, non-free, non-free-firmware]
|
||||
cross_pockets: [updates, backports, security]
|
||||
build_profiles: []
|
||||
archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc
|
||||
pockets:
|
||||
- updates
|
||||
- security
|
||||
- proposed-updates
|
||||
# Debian changelogs conventionally target 'unstable'; the series data
|
||||
# knows the same series as 'sid'.
|
||||
suite_aliases:
|
||||
unstable: sid
|
||||
sections:
|
||||
# Valid Section values for debian/control: the Debian policy section
|
||||
# list unioned with the sections observed in the live Ubuntu archive.
|
||||
# Archives reject uploads carrying an unknown section; only the part
|
||||
# before a '/' (the subsection) is validated.
|
||||
- admin
|
||||
- cli-mono
|
||||
- comm
|
||||
- database
|
||||
- debian-installer
|
||||
- debug
|
||||
- devel
|
||||
- doc
|
||||
- editors
|
||||
- education
|
||||
- electronics
|
||||
- embedded
|
||||
- fonts
|
||||
- games
|
||||
- gnome
|
||||
- gnu-r
|
||||
- golang
|
||||
- graphics
|
||||
- hamradio
|
||||
- haskell
|
||||
- httpd
|
||||
- interpreters
|
||||
- introspection
|
||||
- java
|
||||
- javascript
|
||||
- kde
|
||||
- kernel
|
||||
- libdevel
|
||||
- libs
|
||||
- lisp
|
||||
- localization
|
||||
- mail
|
||||
- math
|
||||
- metapackages
|
||||
- misc
|
||||
- net
|
||||
- news
|
||||
- ocaml
|
||||
- oldlibs
|
||||
- otherosfs
|
||||
- perl
|
||||
- php
|
||||
- python
|
||||
- ruby
|
||||
- rust
|
||||
- science
|
||||
- shells
|
||||
- sound
|
||||
- tasks
|
||||
- tex
|
||||
- text
|
||||
- translations
|
||||
- utils
|
||||
- vcs
|
||||
- video
|
||||
- web
|
||||
- x11
|
||||
- xfce
|
||||
- zope
|
||||
series:
|
||||
local: /usr/share/distro-info/debian.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv
|
||||
ubuntu:
|
||||
mirrors:
|
||||
primary:
|
||||
url: https://archive.ubuntu.com/ubuntu
|
||||
# Sibling host serving the -security pocket for the same arches.
|
||||
security_url: http://security.ubuntu.com/ubuntu
|
||||
archs: [amd64, i386]
|
||||
ports:
|
||||
# Everything else lives on the ports archive, which also serves
|
||||
# its own -security pocket (no security_url needed).
|
||||
url: http://ports.ubuntu.com/ubuntu-ports
|
||||
archs: [armhf, arm64, ppc64el, riscv64, s390x]
|
||||
components: [main, restricted, universe, multiverse]
|
||||
cross_pockets: [updates, backports, security]
|
||||
build_profiles: [derivative.ubuntu, noudeb]
|
||||
archive_keyring: https://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg
|
||||
pockets:
|
||||
- updates
|
||||
- security
|
||||
- proposed
|
||||
sections:
|
||||
# Same list as debian (see the comment there)
|
||||
- admin
|
||||
- cli-mono
|
||||
- comm
|
||||
- database
|
||||
- debian-installer
|
||||
- debug
|
||||
- devel
|
||||
- doc
|
||||
- editors
|
||||
- education
|
||||
- electronics
|
||||
- embedded
|
||||
- fonts
|
||||
- games
|
||||
- gnome
|
||||
- gnu-r
|
||||
- golang
|
||||
- graphics
|
||||
- hamradio
|
||||
- haskell
|
||||
- httpd
|
||||
- interpreters
|
||||
- introspection
|
||||
- java
|
||||
- javascript
|
||||
- kde
|
||||
- kernel
|
||||
- libdevel
|
||||
- libs
|
||||
- lisp
|
||||
- localization
|
||||
- mail
|
||||
- math
|
||||
- metapackages
|
||||
- misc
|
||||
- net
|
||||
- news
|
||||
- ocaml
|
||||
- oldlibs
|
||||
- otherosfs
|
||||
- perl
|
||||
- php
|
||||
- python
|
||||
- ruby
|
||||
- rust
|
||||
- science
|
||||
- shells
|
||||
- sound
|
||||
- tasks
|
||||
- tex
|
||||
- text
|
||||
- translations
|
||||
- utils
|
||||
- vcs
|
||||
- video
|
||||
- web
|
||||
- x11
|
||||
- xfce
|
||||
- zope
|
||||
series:
|
||||
local: /usr/share/distro-info/ubuntu.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv
|
||||
@@ -0,0 +1,31 @@
|
||||
## Forge hosts recognized by `pkh new` origin detection, with the
|
||||
## release-tarball URL templates of each: `Forge::parse`
|
||||
## (src/new/origin.rs) matches a git remote's host against the map keys,
|
||||
## and the tarball download substitutes {owner}, {repo} and {tag} into the
|
||||
## templates. Like host_keys.yml, this file exists so that static
|
||||
## endpoints are data: adding a forge is a YAML entry, not a code change
|
||||
## (self-hosted instances are deliberately absent — the download URL
|
||||
## shapes differ per instance).
|
||||
##
|
||||
## tarball_templates are tried sequentially in file order, best candidate
|
||||
## first (GitHub prefers the codeload direct link: no redirect).
|
||||
## `kind` documents the forge family the URL shapes belong to; the
|
||||
## templates fully describe the URLs, so nothing branches on it (yet) —
|
||||
## but it must be one of the known kinds, enforced at load time.
|
||||
##
|
||||
## Where the values come from: each forge's release-archive download URL
|
||||
## shapes, verified against the live forges —
|
||||
## github: codeload.github.com/<owner>/<repo>/tar.gz/refs/tags/<tag>
|
||||
## and github.com/<owner>/<repo>/archive/refs/tags/<tag>.tar.gz
|
||||
## gitlab: gitlab.com/<owner>/<repo>/-/archive/<tag>/<repo>-<tag>.tar.gz
|
||||
|
||||
forges:
|
||||
github.com:
|
||||
kind: github
|
||||
tarball_templates:
|
||||
- https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}
|
||||
- https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz
|
||||
gitlab.com:
|
||||
kind: gitlab
|
||||
tarball_templates:
|
||||
- https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz
|
||||
@@ -0,0 +1,19 @@
|
||||
## SSH host key fingerprints of known upload targets (`pkh put`).
|
||||
## Like distro_info.yml, this file exists so that trust anchors are data,
|
||||
## quickly updatable in one place, instead of hardcoded in the source.
|
||||
##
|
||||
## A server presenting a key whose fingerprint is listed for its host is
|
||||
## verified without prompting. Fingerprints are the `SHA256:<base64>` values
|
||||
## as displayed by ssh-keygen / pkh; an optional key type prefix (e.g.
|
||||
## `ssh-rsa`) is tolerated as the first word of an entry.
|
||||
##
|
||||
## Source of the Launchpad fingerprints (published "as a stopgap measure
|
||||
## until we have signed DNS records"):
|
||||
## https://ubuntu.com/docs/launchpad/user/reference/ssh-fingerprints/
|
||||
## (formerly https://help.launchpad.net/SSHFingerprints)
|
||||
|
||||
fingerprints:
|
||||
ppa.launchpad.net:
|
||||
- ssh-rsa SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg
|
||||
upload.ubuntu.com:
|
||||
- ssh-rsa SHA256:FN8sNU/MMmyvw/xtY5sAzkLGmkVQt2QpGZcwsHoBzjc
|
||||
@@ -0,0 +1,17 @@
|
||||
## Keyserver lookup endpoint used to fetch PPA signing keys.
|
||||
## Like host_keys.yml, this file exists so that a static endpoint is data,
|
||||
## updatable in one reviewable place, instead of hardcoded in the source —
|
||||
## the URL was previously duplicated in the apt keyring and release
|
||||
## modules. Sparse on purpose: it grows if keyserver pools or alternates
|
||||
## ever need to be tried.
|
||||
##
|
||||
## The template carries its variable part as a {fingerprint} placeholder,
|
||||
## substituted by the accessor of src/apt/keyring.rs with plain string
|
||||
## replacement.
|
||||
##
|
||||
## Where the value comes from: keyserver.ubuntu.com, Ubuntu's OpenPKS
|
||||
## (formerly SKS) keyserver; op=get with search=0x<fingerprint> is the
|
||||
## documented machine interface fetching one key by fingerprint
|
||||
## (https://keyserver.ubuntu.com).
|
||||
|
||||
lookup_template: "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{fingerprint}"
|
||||
@@ -0,0 +1,33 @@
|
||||
## Launchpad service endpoints: the REST API, the PPA SFTP upload queue,
|
||||
## the PPA package-content host and the Ubuntu source-package git web UI.
|
||||
## Like host_keys.yml, this file exists so that static endpoints are data,
|
||||
## updatable in one reviewable place, instead of hardcoded in the source —
|
||||
## the API and content URLs were previously triplicated across modules.
|
||||
##
|
||||
## Templates carry their variable parts as {name} placeholders ({owner},
|
||||
## {ppa}, {package}), substituted by the accessors of src/launchpad.rs
|
||||
## with plain string replacement.
|
||||
##
|
||||
## Where the values come from:
|
||||
## api_base: the Launchpad REST API root (https://launchpad.net/docs/api/)
|
||||
## ssh_*: the PPA upload queue, as expanded by dput-ng's
|
||||
## ppa:user/ppa profile (ppa.launchpad.net:22, incoming
|
||||
## ~<user>/<ppa>)
|
||||
## ftp_*: the same upload queue over anonymous FTP, dput-ng's
|
||||
## plain ppa: profile: the transport pkh degrades to
|
||||
## when the SSH connection itself never comes up
|
||||
## content_host_template: ppa.launchpadcontent.net serves PPA apt
|
||||
## repositories since the 2022 move off ppa.launchpad.net
|
||||
## git_web_template: Launchpad's CGit mirrors of Ubuntu source packages
|
||||
## (git.launchpad.net/ubuntu/+source/<package>)
|
||||
|
||||
api_base: https://api.launchpad.net/1.0
|
||||
ssh_host: ppa.launchpad.net
|
||||
ssh_port: 22
|
||||
## The anonymous FTP upload queue dput-ng's plain ppa: profile uses:
|
||||
## pkh degrades to it when the SSH connection itself never comes up.
|
||||
ftp_host: ppa.launchpad.net
|
||||
ftp_port: 21
|
||||
incoming_template: "~{owner}/{ppa}"
|
||||
content_host_template: https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu
|
||||
git_web_template: https://git.launchpad.net/ubuntu/+source/{package}
|
||||
@@ -0,0 +1,139 @@
|
||||
## License knowledge of `pkh new`, in one place: the wizard menu labels
|
||||
## (src/new/questions.rs), the spellings accepted by License::parse and the
|
||||
## SPDX URL template (src/new/options.rs), and the license-file sniffing
|
||||
## inputs (src/new/detect.rs) all read this table, so the three lists —
|
||||
## previously kept in sync by comments only — cannot drift apart anymore.
|
||||
## Adding or changing a curated license is one entry below.
|
||||
##
|
||||
## Keys:
|
||||
## id: SPDX identifier: written to debian/copyright, returned
|
||||
## by the license sniff and substituted into
|
||||
## license_url_template (minus a trailing '+' of the
|
||||
## "or later" spellings)
|
||||
## menu: label offered by the wizard license question (the
|
||||
## free-text "Other (enter a SPDX identifier)" entry
|
||||
## stays in Rust — it is UX, not data)
|
||||
## spellings: inputs accepted by License::parse, matched
|
||||
## case-insensitively; each entry must accept its own id
|
||||
## (a consistency test in options.rs locks ids, spellings
|
||||
## and the License enum together)
|
||||
## detect_markers: marker sets driving the LICENSE/COPYING text sniff of
|
||||
## detect.rs. A set matches when every marker of `all`
|
||||
## occurs in the lowercased license text and none of
|
||||
## `unless` does; an entry matches when any of its sets
|
||||
## does. The sets are written to be mutually exclusive:
|
||||
## the `unless` markers keep multi-license texts on the
|
||||
## entry carrying the stronger reference (a MIT-named
|
||||
## file also quoting the GPL or the Apache license is a
|
||||
## GPL/Apache file) and keep GPL sets off LGPL texts,
|
||||
## whose name contains theirs. The entry order below
|
||||
## (menu order) therefore only breaks ties.
|
||||
##
|
||||
## detect_files (top level): the candidate license file names the sniff
|
||||
## reads, in preference order, shared by every license (the case-variant
|
||||
## directory scan around them stays in Rust).
|
||||
##
|
||||
## license_url_template: the SPDX license page URL, with {id} substituted
|
||||
## for the debian/copyright reference paragraph.
|
||||
##
|
||||
## The behavioral lock for the markers is the LICENSE_TEXTS test table in
|
||||
## src/new/detect.rs: a bad marker edit fails those tests, not packages.
|
||||
|
||||
license_url_template: https://spdx.org/licenses/{id}.html
|
||||
|
||||
detect_files:
|
||||
- LICENSE
|
||||
- LICENSE.md
|
||||
- LICENSE.txt
|
||||
- COPYING
|
||||
- COPYING.txt
|
||||
|
||||
# Entries in wizard-menu order.
|
||||
licenses:
|
||||
- id: MIT
|
||||
menu: MIT
|
||||
spellings: [MIT]
|
||||
detect_markers:
|
||||
- all:
|
||||
- mit license
|
||||
unless:
|
||||
- apache license
|
||||
- general public license
|
||||
- all:
|
||||
- permission is hereby granted, free of charge
|
||||
unless:
|
||||
- apache license
|
||||
- general public license
|
||||
- id: Apache-2.0
|
||||
menu: Apache-2.0
|
||||
spellings: [Apache-2.0]
|
||||
detect_markers:
|
||||
- all:
|
||||
- apache license
|
||||
- version 2
|
||||
- id: GPL-2.0+
|
||||
menu: GPL-2.0+
|
||||
spellings: [GPL-2.0+]
|
||||
detect_markers:
|
||||
- all:
|
||||
- general public license
|
||||
unless:
|
||||
- version 3
|
||||
- lesser general public license
|
||||
- id: GPL-3.0+
|
||||
menu: GPL-3.0+
|
||||
spellings: [GPL-3.0+]
|
||||
detect_markers:
|
||||
- all:
|
||||
- general public license
|
||||
- version 3
|
||||
unless:
|
||||
- lesser general public license
|
||||
- id: LGPL-2.1+
|
||||
menu: LGPL-2.1+
|
||||
spellings: [LGPL-2.1+]
|
||||
detect_markers:
|
||||
- all:
|
||||
- lesser general public license
|
||||
unless:
|
||||
- version 3
|
||||
- all:
|
||||
- lesser general public license
|
||||
- version 2.1
|
||||
- id: LGPL-3.0+
|
||||
menu: LGPL-3.0+
|
||||
spellings: [LGPL-3.0+]
|
||||
detect_markers:
|
||||
- all:
|
||||
- lesser general public license
|
||||
- version 3
|
||||
unless:
|
||||
- version 2.1
|
||||
- id: BSD-2-Clause
|
||||
menu: BSD-2-Clause
|
||||
spellings: [BSD-2-Clause]
|
||||
detect_markers:
|
||||
- all:
|
||||
- redistribution and use in source and binary forms
|
||||
unless:
|
||||
- endorse or promote
|
||||
- isc license
|
||||
- permission to use, copy, modify, and/or distribute this software
|
||||
- id: BSD-3-Clause
|
||||
menu: BSD-3-Clause
|
||||
spellings: [BSD-3-Clause]
|
||||
detect_markers:
|
||||
- all:
|
||||
- redistribution and use in source and binary forms
|
||||
- endorse or promote
|
||||
unless:
|
||||
- isc license
|
||||
- permission to use, copy, modify, and/or distribute this software
|
||||
- id: ISC
|
||||
menu: ISC
|
||||
spellings: [ISC]
|
||||
detect_markers:
|
||||
- all:
|
||||
- isc license
|
||||
- all:
|
||||
- permission to use, copy, modify, and/or distribute this software
|
||||
@@ -0,0 +1,67 @@
|
||||
# Quirks configuration for package-specific workarounds
|
||||
# This file defines package-specific quirks that are applied during pull and deb operations
|
||||
#
|
||||
# `pull` and `deb` hold one entry per scope: several entries can carry
|
||||
# different `series` lists, and every matching entry applies in file
|
||||
# order. Entries can be scoped with `series`: an empty list applies to
|
||||
# every series, otherwise only the listed ones. Packaging workarounds
|
||||
# should carry the series they were verified against so they can be
|
||||
# dropped once the upstream packaging catches up.
|
||||
|
||||
quirks:
|
||||
|
||||
# The resolute kernels declare `llvm-21-dev` unqualified while their
|
||||
# other llvm pieces are `:native`; the dpkg cross rules then resolve it
|
||||
# against the host architecture, whose dependency closure conflicts with
|
||||
# the `:native` python3. Resolve it against the build architecture
|
||||
# until the control is fixed upstream.
|
||||
# The resolute and stonking kernels declare the host-tool libraries
|
||||
# unqualified (libelf-dev for resolve_btfids, libdw-dev for
|
||||
# gendwarfksyms, libssl-dev for sign-file), unlike the Debian control,
|
||||
# which carries the same names qualified `:native`. The dpkg cross
|
||||
# rules resolve an unqualified Multi-Arch: same name against the host
|
||||
# architecture only, so a cross build installs no build-architecture
|
||||
# variants and the kernel's host-side tools cannot link. Inject the
|
||||
# build-architecture variants until the control is fixed upstream.
|
||||
linux:
|
||||
deb:
|
||||
- series: [resolute]
|
||||
dependencies:
|
||||
replace:
|
||||
llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||
- series: [resolute, stonking]
|
||||
dependencies:
|
||||
inject:
|
||||
- libelf-dev:native
|
||||
- libdw-dev:native
|
||||
- libssl-dev:native
|
||||
linux-riscv:
|
||||
deb:
|
||||
- series: [resolute]
|
||||
dependencies:
|
||||
replace:
|
||||
llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||
- series: [resolute, stonking]
|
||||
dependencies:
|
||||
inject:
|
||||
- libelf-dev:native
|
||||
- libdw-dev:native
|
||||
- libssl-dev:native
|
||||
|
||||
# Add more packages and their quirks as needed
|
||||
# example-package:
|
||||
# pull:
|
||||
# - series: [noble]
|
||||
# package_directory:
|
||||
# - linux-main
|
||||
# deb:
|
||||
# - series: [resolute]
|
||||
# dependencies:
|
||||
# replace:
|
||||
# llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||
# - series: [stonking]
|
||||
# dependencies:
|
||||
# replace:
|
||||
# llvm-22-dev: llvm-22-dev:native <!stage1>
|
||||
# parameters:
|
||||
# key: value
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: pkh
|
||||
description: 'Drive pkh, a Debian/Ubuntu packaging helper: pull source packages, generate changelog entries, build .dsc/.deb, lint, and upload to a PPA. Use it whenever the task touches Debian or Ubuntu packaging: patching an existing package, preparing an SRU, backport or NMU, scaffolding a new .deb, rebuilding for a PPA, or uploading a source package. Trigger on "update the changelog", "package this", or a bare package name, even when the user never mentions Debian.'
|
||||
---
|
||||
|
||||
# pkh
|
||||
|
||||
`pkh` wraps the Debian packaging toolchain (`dch`, `dpkg-buildpackage`,
|
||||
`sbuild`, `dpkg-source`, `quilt`, `lintian`, PPA uploads) in one CLI.
|
||||
The subcommands share one set of option names, so `-s` always targets
|
||||
the series and `--ppa` always names the PPA. Each step also does more
|
||||
than the raw tool it replaces: `pull` fetches the orig tarball with the
|
||||
source, `chlog` commits the entry it writes, `deb` sets up a chroot and
|
||||
installs the build dependencies.
|
||||
|
||||
Check the install with `pkh --version`. Each command lists its flags
|
||||
with `pkh <command> --help`, so check there instead of guessing. pkh
|
||||
shells out to host tools (git, dpkg-dev, quilt, mmdebstrap, lintian,
|
||||
pristine-tar, schroot, ...). Install the ones your workflow uses, or
|
||||
use the classic snap, which carries them.
|
||||
|
||||
## Shared options
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
| `-d, --dist <dist>` | Target distribution, `debian` or `ubuntu` |
|
||||
| `-s, --series <series>` | Target series, for example `resolute` or `noble` |
|
||||
| `-v, --version <version>` | Target package version |
|
||||
| `-a, --arch <arch>` | Target architecture, for example `amd64` or `riscv64` |
|
||||
| `-p, --pocket <pocket>` | Distribution pocket: `updates`, `security`, `proposed` |
|
||||
| `--ppa <user/ppa>` | Act on the named PPA |
|
||||
|
||||
Defaults come from the host vendor, its development series, and its
|
||||
architecture. When the target differs, pass the flags: packaging for
|
||||
Ubuntu on a Debian host needs `-d ubuntu`, and a series or architecture
|
||||
that differs from the host needs `-s` or `-a`.
|
||||
|
||||
## Patch an Ubuntu package
|
||||
|
||||
```
|
||||
pkh pull hello # source and orig tarball; add -d ubuntu off an Ubuntu host
|
||||
# edit the package, committing each patch to git
|
||||
pkh chlog # generates the entry, opens it for editing, commits it
|
||||
git add debian/changelog && git commit -m "d/changelog"
|
||||
pkh build # source package, written next to the tree
|
||||
pkh deb # binary build in a chroot with build deps installed
|
||||
pkh lint # lintian plus pkh-native checks
|
||||
pkh put --ppa user/hello_xxx # uploads the .changes file from the build
|
||||
git push xxx user-fork # push the branch to your fork
|
||||
```
|
||||
|
||||
Run `pkh chlog` and `pkh build` from the root of the source tree; they
|
||||
act on the package in the current directory.
|
||||
|
||||
## Command reference
|
||||
|
||||
- `pkh new [name]` scaffolds a buildable source package. `--lang`
|
||||
picks the build system (`rust`, `python`, `meson`, `cmake`,
|
||||
`autotools`, `go`, `shell`, `makefile`); `--source <PATH>` packages
|
||||
existing sources instead. `--upstream-version` and `--revision` set
|
||||
the version. `--description`, `--homepage`, `--license <SPDX>`,
|
||||
`--command`, `--maintainer "Name <email>"`, and `--depends` fill in
|
||||
the package metadata, with the maintainer defaulting to
|
||||
`DEBFULLNAME`/`DEBEMAIL` and then git config. `--quilt` and
|
||||
`--native` choose the source format; `--orig-from
|
||||
release|git|path|snapshot` and `--orig-path` control the orig
|
||||
tarball. The changelog starts as `UNRELEASED`; `--release` targets
|
||||
`--series` instead. `--defaults` answers every remaining question
|
||||
with its default, which keeps the run non-interactive.
|
||||
|
||||
- `pkh pull <package>` fetches a source package from the archive or
|
||||
git. `--archive` skips git. `--ppa user/ppa` and `--repository
|
||||
<suite-url>` pull from a PPA or an external flat repository instead.
|
||||
`-d`, `-s`, `-v`, and `-p` target an exact source.
|
||||
|
||||
- `pkh chlog` generates the changelog entry from the commits since the
|
||||
last version tag, opens it for editing, and commits it. `--backport`,
|
||||
`--nmu`, and `--rebuild` apply the matching numbering scheme
|
||||
(`3.1-1ubuntu2~24.04.1`, `1.0-1.1`, `1.0-1build1`); `-v` sets an
|
||||
explicit version instead.
|
||||
|
||||
- `pkh build` produces the .dsc. `--orig auto|always|never` controls
|
||||
whether the upload includes the orig tarball; the default, `auto`,
|
||||
includes it only when the upstream version changed.
|
||||
|
||||
- `pkh deb` builds the binary packages in an isolated context with the
|
||||
build dependencies installed. `--ppa` (repeatable) adds dependency
|
||||
sources, `--inject <package|.deb>` preinstalls a package, and `-j`
|
||||
caps parallel jobs. `--cross` cross-compiles instead of using
|
||||
qemu-binfmt, but most packages cannot cross-compile, so prefer qemu.
|
||||
Leave `--mode` unset unless you need a specific build context.
|
||||
|
||||
- `pkh lint [path]` runs lintian plus the pkh-native checks. `--json`
|
||||
emits a machine-readable report and `--list-tags` prints the native
|
||||
tag catalog. `--fail-on` sets the severities that fail the run
|
||||
(errors by default), `--suppress-tags` ignores tags, `--check` runs a
|
||||
single native check, and `--info`, `--pedantic`, and
|
||||
`--experimental` add detail. `--repack` packs the tree fresh instead
|
||||
of reusing the existing build output.
|
||||
|
||||
- `pkh put [changes]` uploads a .changes file to `--ppa user/ppa`.
|
||||
With no argument it uploads the .changes from this package's last
|
||||
build, found next to the source tree. `--force` re-uploads a file
|
||||
that was already uploaded.
|
||||
|
||||
- `pkh prune` removes build artifacts and caches. Run it with
|
||||
`--dry-run` first to list them. `--all` also deletes the cached
|
||||
chroot tarballs, which take long to download again, so use it when
|
||||
you need the disk space.
|
||||
|
||||
## Notes for agent runs
|
||||
|
||||
- Pass `-d`, `-s`, and `-a` whenever the target differs from the host,
|
||||
so runs are reproducible.
|
||||
- Keep runs non-interactive. Pass explicit flags, use `pkh new
|
||||
--defaults`, and set `EDITOR` before `pkh chlog` (`EDITOR=true` keeps
|
||||
the generated text). Commands may ask short questions on the
|
||||
terminal; flags avoid most prompts.
|
||||
- Pass `RUST_LOG=debug` for pkh's own logs. `--verbose` on `pkh build`
|
||||
and `pkh deb` prints raw tool output instead of the live view.
|
||||
- pkh writes the build artifacts (.dsc, .changes, logs) next to the
|
||||
source tree. `pkh put` finds them without arguments, and `pkh prune`
|
||||
removes them again.
|
||||
- Run `pkh lint` before `pkh put`. It exits nonzero when findings reach
|
||||
the `--fail-on` level, which defaults to errors.
|
||||
- pkh intercepts Ctrl+C, runs its cleanup hooks, and exits with status
|
||||
130. `pkh prune` removes anything left over.
|
||||
|
||||
The upstream repository is https://git.vhaudiquet.fr/vhaudiquet/pkh.
|
||||
Its README has longer workflow examples.
|
||||
@@ -0,0 +1,2 @@
|
||||
bin_PROGRAMS = {command}
|
||||
{command}_SOURCES = hello.c
|
||||
@@ -0,0 +1,5 @@
|
||||
AC_INIT([{name}], [{upstream_version}])
|
||||
AM_INIT_AUTOMAKE([foreign])
|
||||
AC_PROG_CC
|
||||
AC_CONFIG_FILES([Makefile])
|
||||
AC_OUTPUT
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
|
||||
/* Placeholder for {name}, generated by `pkh new`. */
|
||||
int main(void)
|
||||
{
|
||||
printf("Hello from {command}!\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
## The `autotools` template: a C project with a configure.ac built through
|
||||
## debhelper's auto-detection (dh runs autoreconf itself when it finds
|
||||
## configure.ac, debhelper >= 10 — no override needed). The skeleton bodies
|
||||
## below are static data (the first source build runs `autoreconf`,
|
||||
## integrated in the dh sequence, so no generated configure script is
|
||||
## committed); the logic half — the AC_INIT probe and the GNU-gettext
|
||||
## detection (appended to Build-Depends) — lives in
|
||||
## src/new/templates/autotools.rs. hello.c.tpl duplicates the shared C
|
||||
## skeleton of the other C/C++ template directories (see
|
||||
## meson/manifest.yml for why).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: autotools
|
||||
label: C/C++ (Autotools)
|
||||
detect:
|
||||
files: [configure.ac]
|
||||
build_depends:
|
||||
- autoconf
|
||||
- automake
|
||||
- libtool
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@"
|
||||
files:
|
||||
- path: configure.ac
|
||||
template: configure.ac.tpl
|
||||
- path: Makefile.am
|
||||
template: Makefile.am.tpl
|
||||
- path: hello.c
|
||||
template: hello.c.tpl
|
||||
@@ -0,0 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project({name} VERSION {upstream_version})
|
||||
|
||||
add_executable({command} hello.c)
|
||||
install(TARGETS {command} RUNTIME DESTINATION bin)
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
|
||||
/* Placeholder for {name}, generated by `pkh new`. */
|
||||
int main(void)
|
||||
{
|
||||
printf("Hello from {command}!\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
## The `cmake` template: a C/C++ project built with CMake through the
|
||||
## debhelper cmake buildsystem. The skeleton bodies below are static data;
|
||||
## the logic half — the project() probe and the wizard's pkg-config
|
||||
## opt-in (appended to Build-Depends) — lives in src/new/templates/cmake.rs.
|
||||
## hello.c.tpl duplicates the shared C skeleton of the other C/C++
|
||||
## template directories (see meson/manifest.yml for why).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: cmake
|
||||
label: C/C++ (CMake)
|
||||
detect:
|
||||
files: [CMakeLists.txt]
|
||||
build_depends:
|
||||
- cmake
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@ --buildsystem=cmake"
|
||||
files:
|
||||
- path: CMakeLists.txt
|
||||
template: CMakeLists.txt.tpl
|
||||
- path: hello.c
|
||||
template: hello.c.tpl
|
||||
@@ -0,0 +1 @@
|
||||
{name} - empty base tree scaffolded by `pkh new`; there is intentionally no upstream build system here.
|
||||
@@ -0,0 +1,19 @@
|
||||
## The `empty` template: a metapackage (non-empty Depends list) or an
|
||||
## empty base package with no build system at all — pure `dh $@` plumbing
|
||||
## as a starting point for hand-written rules. Pure data: no hooks, the
|
||||
## metapackage Depends payload travels in the wizard answers, and the
|
||||
## only upstream file is the stub README marking the tree as
|
||||
## intentionally empty.
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: empty
|
||||
label: Metapackage / empty base (no build system)
|
||||
detect:
|
||||
files: []
|
||||
build_depends: []
|
||||
architecture: all
|
||||
rules_dh_line: "dh $@"
|
||||
files:
|
||||
- path: README
|
||||
template: README.tpl
|
||||
@@ -0,0 +1,3 @@
|
||||
module {name}
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,8 @@
|
||||
// Placeholder for {name}, generated by `pkh new`.
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello from {command}!")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
## The `go` template: a Go module built through dh-golang. The logic half
|
||||
## — the go.mod module-line probe and the `{go_import_path}` value below —
|
||||
## lives in src/new/templates/go.rs; the skeleton bodies are static data
|
||||
## (the `go` directive of go.mod stays a literal: nothing about it is
|
||||
## answer-derived, so it has no {placeholder}).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: go
|
||||
label: Go module
|
||||
detect:
|
||||
files: [go.mod]
|
||||
build_depends:
|
||||
- golang-any
|
||||
- dh-golang
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@ --buildsystem=golang"
|
||||
source_fields:
|
||||
XS-Go-Import-Path: "{go_import_path}"
|
||||
files:
|
||||
- path: go.mod
|
||||
template: go.mod.tpl
|
||||
- path: main.go
|
||||
template: main.go.tpl
|
||||
@@ -0,0 +1,16 @@
|
||||
CC ?= cc
|
||||
CFLAGS ?= -O2 -Wall -Wextra
|
||||
PREFIX ?= /usr
|
||||
|
||||
all: {command}
|
||||
|
||||
{command}: hello.c
|
||||
$(CC) $(CFLAGS) -o $@ hello.c
|
||||
|
||||
install: {command}
|
||||
install -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}
|
||||
|
||||
clean:
|
||||
rm -f {command}
|
||||
|
||||
.PHONY: all install clean
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
|
||||
/* Placeholder for {name}, generated by `pkh new`. */
|
||||
int main(void)
|
||||
{
|
||||
printf("Hello from {command}!\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{command} usr/bin/{command}
|
||||
@@ -0,0 +1,28 @@
|
||||
## The `makefile` template: a generic project driven by a plain Makefile.
|
||||
## debhelper's makefile buildsystem runs `make` for the build and
|
||||
## `make install DESTDIR=...` when the Makefile carries an `install:`
|
||||
## target (missing targets are skipped gracefully), so plain `dh $@`
|
||||
## plumbing is enough here. The phony-install hint of
|
||||
## src/new/templates/makefile.rs (whether dh_auto_install will run
|
||||
## `make install` for an existing tree) is the only logic; the skeleton
|
||||
## bodies below are static data (the install mapping is rendered for
|
||||
## skeletons only, whose phony install target is known by construction).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: makefile
|
||||
label: Generic (Makefile)
|
||||
detect:
|
||||
files: [Makefile]
|
||||
build_depends:
|
||||
- build-essential
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@"
|
||||
files:
|
||||
- path: hello.c
|
||||
template: hello.c.tpl
|
||||
- path: Makefile
|
||||
template: Makefile.tpl
|
||||
- path: debian/install
|
||||
template: install.tpl
|
||||
skeleton_only: true
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
|
||||
/* Placeholder for {name}, generated by `pkh new`. */
|
||||
int main(void)
|
||||
{
|
||||
printf("Hello from {command}!\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
## The `meson` template: a C/C++ project built with Meson through the
|
||||
## debhelper meson buildsystem. The skeleton bodies below are static data;
|
||||
## the logic half — the project() probe and the wizard's pkg-config
|
||||
## opt-in (appended to Build-Depends) — lives in src/new/templates/meson.rs.
|
||||
##
|
||||
## hello.c.tpl is deliberately duplicated (byte-identical) across the
|
||||
## makefile, cmake and autotools template directories: every template
|
||||
## directory is self-contained — the registry embeds each directory's
|
||||
## bodies under its own entry — so a shared body would need
|
||||
## cross-directory references the manifest schema has no machinery for.
|
||||
## The duplication replaces the Rust hello_c() helper meson.rs used to
|
||||
## lend cmake.rs and autotools.rs.
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: meson
|
||||
label: C/C++ (Meson)
|
||||
detect:
|
||||
files: [meson.build]
|
||||
build_depends:
|
||||
- meson
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@ --buildsystem=meson"
|
||||
files:
|
||||
- path: meson.build
|
||||
template: meson.build.tpl
|
||||
- path: hello.c
|
||||
template: hello.c.tpl
|
||||
@@ -0,0 +1,3 @@
|
||||
project('{name}', version: '{upstream_version}', license: '{license}', default_options: ['c_std=c11'])
|
||||
|
||||
executable('{command}', 'hello.c', install: true)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Placeholder for {name}, generated by `pkh new`."""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Hello from {command}!")
|
||||
@@ -0,0 +1,29 @@
|
||||
## The `python` template: a PEP 517 project built with pybuild. The
|
||||
## skeleton bodies below are static data on the fresh-skeleton baseline
|
||||
## (the setuptools backend): the module directory and the console-script
|
||||
## entry point are named by the `{module_name}` placeholder python.rs
|
||||
## derives from the package name — dpkg names may carry `+`/`.` and may
|
||||
## start with a digit, none of which a Python module name may. The logic
|
||||
## half — the pyproject.toml/setup.py probe and the Build-Depends /
|
||||
## architecture resolution for existing projects (backend package,
|
||||
## pyproject presence, C-extension hints) — lives in
|
||||
## src/new/templates/python.rs.
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: python
|
||||
label: Python (pyproject.toml / setup.py)
|
||||
detect:
|
||||
files: [pyproject.toml, setup.py, setup.cfg]
|
||||
build_depends:
|
||||
- dh-python
|
||||
- python3-all
|
||||
- pybuild-plugin-pyproject
|
||||
- python3-setuptools
|
||||
architecture: all
|
||||
rules_dh_line: "dh $@ --with python3 --buildsystem=pybuild"
|
||||
files:
|
||||
- path: pyproject.toml
|
||||
template: pyproject.toml.tpl
|
||||
- path: "{module_name}/__init__.py"
|
||||
template: __init__.py.tpl
|
||||
@@ -0,0 +1,12 @@
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "{name}"
|
||||
version = "{upstream_version}"
|
||||
description = "{summary}"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[project.scripts]
|
||||
{command} = "{module_name}:main"
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "{crate_name}"
|
||||
version = "{upstream_version}"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,4 @@
|
||||
// Placeholder for {name}, generated by `pkh new`.
|
||||
fn main() {
|
||||
println!("Hello from {command}!");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
## The `rust` template: a vendored Cargo build (see the module docs of
|
||||
## src/new/templates/rust.rs for the vendoring strategy). The skeleton
|
||||
## bodies and the vendored-build rules overrides below are static data; the
|
||||
## logic half — the cargo vendor post-write hook, the project probe, and
|
||||
## the `{crate_name}` / `{locked}` / `{artifact}` values of the bodies —
|
||||
## lives in that module (dpkg package names may carry `+`/`.`, which cargo
|
||||
## rejects in crate names, so the skeleton crate name is a derived
|
||||
## placeholder, not the raw `{name}`).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: rust
|
||||
label: Rust (Cargo.toml)
|
||||
detect:
|
||||
files: [Cargo.toml]
|
||||
build_depends:
|
||||
- cargo:native
|
||||
- rustc:native
|
||||
architecture: any
|
||||
rules_dh_line: "dh $@"
|
||||
# The vendored-build overrides appended to debian/rules; `--locked` is only
|
||||
# used when the packaged tree already carries a Cargo.lock (the vendoring
|
||||
# hook patches it in once it creates the lockfile), and the built artifact
|
||||
# of a fresh skeleton is named after its crate.
|
||||
rules_extra_file: rules.extra.tpl
|
||||
gitignore_entries:
|
||||
- vendor/
|
||||
- .cargo/config.toml
|
||||
files:
|
||||
- path: Cargo.toml
|
||||
template: Cargo.toml.tpl
|
||||
- path: src/main.rs
|
||||
template: main.rs.tpl
|
||||
@@ -0,0 +1,19 @@
|
||||
override_dh_auto_build:
|
||||
cargo build --release --offline{locked}
|
||||
|
||||
override_dh_auto_install:
|
||||
install -Dm755 target/release/{artifact} debian/{name}/usr/bin/{command}
|
||||
|
||||
override_dh_auto_test:
|
||||
cargo test --release --offline{locked}
|
||||
|
||||
override_dh_update_autotools_config:
|
||||
|
||||
override_dh_clean:
|
||||
# dh_clean unlinks `*.orig` patch backups, but vendored crates
|
||||
# ship files like `Cargo.toml.orig` that cargo's per-file
|
||||
# checksums require on cold builds (chroots, Launchpad).
|
||||
dh_clean -X .orig
|
||||
|
||||
override_dh_auto_clean:
|
||||
cargo clean
|
||||
@@ -0,0 +1 @@
|
||||
{command}.sh usr/bin/{command}
|
||||
@@ -0,0 +1,25 @@
|
||||
## The `shell` template: a single interpreted script installed to
|
||||
## /usr/bin with plain `dh $@` plumbing. Detection is not marker-based: the
|
||||
## single-script heuristic of src/new/detect.rs (a lone *.sh or shebang
|
||||
## file) maps here. The probe pre-filling the wizard answers from the
|
||||
## script file name lives in src/new/templates/shell.rs; everything else
|
||||
## is the data below (the skeleton script is executable, the install
|
||||
## mapping exists for skeletons only — packaging an existing tree leaves
|
||||
## the mapping to the user).
|
||||
##
|
||||
## Schema: see src/new/templates/mod.rs.
|
||||
|
||||
id: shell
|
||||
label: Shell script / single interpreted file
|
||||
detect:
|
||||
files: []
|
||||
build_depends: []
|
||||
architecture: all
|
||||
rules_dh_line: "dh $@"
|
||||
files:
|
||||
- path: "{command}.sh"
|
||||
template: script.tpl
|
||||
executable: true
|
||||
- path: debian/install
|
||||
template: install.tpl
|
||||
skeleton_only: true
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# Placeholder for {name}, generated by `pkh new`.
|
||||
echo "Hello from {command}!"
|
||||
@@ -1,28 +0,0 @@
|
||||
## Static data needed for pkh operations
|
||||
## Instead of hardcoding the data in code, data files allow to quickly
|
||||
## update and maintain such data in one unique place
|
||||
## The goal is to have the minimal possible set of data necessary
|
||||
## to grab the actual data. For example we don't want to store every Ubuntu
|
||||
## or Debian series, but rather an URL where we can properly access that data.
|
||||
dist_info:
|
||||
local: /usr/share/distro-info/{dist}
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/
|
||||
dist:
|
||||
debian:
|
||||
base_url: http://deb.debian.org/debian
|
||||
archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc
|
||||
pockets:
|
||||
- proposed-updates
|
||||
- updates
|
||||
series:
|
||||
local: /usr/share/distro-info/debian.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv
|
||||
ubuntu:
|
||||
base_url: http://archive.ubuntu.com/ubuntu
|
||||
archive_keyring: http://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg
|
||||
pockets:
|
||||
- proposed
|
||||
- updates
|
||||
series:
|
||||
local: /usr/share/distro-info/ubuntu.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# Quirks configuration for package-specific workarounds
|
||||
# This file defines package-specific quirks that are applied during pull and deb operations
|
||||
|
||||
quirks:
|
||||
|
||||
# Add more packages and their quirks as needed
|
||||
# example-package:
|
||||
# pull:
|
||||
# method: archive
|
||||
# deb:
|
||||
# extra_dependencies:
|
||||
# - another-dependency
|
||||
# parameters:
|
||||
# key: value
|
||||
|
||||
+84
-10
@@ -2,12 +2,17 @@ name: pkh
|
||||
base: core24
|
||||
summary: pkh is a packaging helper for Debian/Ubuntu packages
|
||||
description: |
|
||||
pkh aims at wrapping the different debian tools and workflows
|
||||
into one tool, that would have the same interface for everything,
|
||||
pkh aims at wrapping the different debian tools and workflows
|
||||
into one tool, that would have the same interface for everything,
|
||||
while being smarter at integrating all workflows.
|
||||
|
||||
This snap uses classic confinement and carries the packaging
|
||||
toolchain it drives (dpkg-dev, git, mmdebstrap, lintian, quilt, ...)
|
||||
so it behaves the same on any Debian/Ubuntu host.
|
||||
license: MIT OR GPL-2.0-only
|
||||
adopt-info: pkh-part
|
||||
|
||||
confinement: devmode
|
||||
confinement: classic
|
||||
|
||||
apps:
|
||||
pkh:
|
||||
@@ -19,23 +24,92 @@ parts:
|
||||
source: .
|
||||
override-pull: |
|
||||
craftctl default
|
||||
craftctl set version=$(git rev-parse --short=11 HEAD)
|
||||
craftctl set grade="devel"
|
||||
# Release metadata comes from the crate, not the git state: a build
|
||||
# of any commit must produce the version the crate declares.
|
||||
craftctl set version="$(awk -F'"' '/^version =/{print $2; exit}' Cargo.toml)"
|
||||
craftctl set grade="stable"
|
||||
build-packages:
|
||||
- build-essential
|
||||
- file
|
||||
- patchelf
|
||||
- pkg-config
|
||||
- libssl-dev
|
||||
- libgpg-error-dev
|
||||
- libgpgme-dev
|
||||
# Host-side tools pkh execs directly. Tools that only run *inside*
|
||||
# the build chroot (dose-builddebcheck, dpkg-cross) are provisioned
|
||||
# there by pkh itself and must not be staged; likewise qemu-user-static
|
||||
# is host binfmt configuration, not a bundled file.
|
||||
#
|
||||
# The apt and dpkg state-owning tools are deliberately excluded below:
|
||||
# they must be the host's (classic mode makes them visible), since a
|
||||
# core24 apt/dpkg managing a newer host's package database is exactly
|
||||
# the version skew classic snaps must avoid. The source-package tools
|
||||
# (dpkg-buildpackage, dpkg-source, ...) are bundled instead.
|
||||
stage-packages:
|
||||
- libgpgme11t64
|
||||
- git
|
||||
- curl
|
||||
- gnupg
|
||||
- gpgv
|
||||
- dpkg-dev
|
||||
- quilt
|
||||
- pristine-tar
|
||||
- mmdebstrap
|
||||
- lintian
|
||||
- fakeroot
|
||||
- util-linux
|
||||
- dpkg-dev
|
||||
# mount/umount moved to their own package (split from util-linux)
|
||||
- mount
|
||||
- schroot
|
||||
- openssh-client
|
||||
- tar
|
||||
- xz-utils
|
||||
- bzip2
|
||||
stage:
|
||||
- -usr/lib/x86_64-linux-gnu/libicuio.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicutest.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicutu.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicui18n.so.74.2
|
||||
- -usr/bin/apt
|
||||
- -usr/bin/apt-cache
|
||||
- -usr/bin/apt-cdrom
|
||||
- -usr/bin/apt-config
|
||||
- -usr/bin/apt-get
|
||||
- -usr/bin/apt-key
|
||||
- -usr/bin/apt-mark
|
||||
- -usr/lib/*/libapt-*
|
||||
- -usr/lib/*/libicuio*
|
||||
- -usr/lib/*/libicutest*
|
||||
- -usr/lib/*/libicutu*
|
||||
- -usr/lib/*/libicui18n*
|
||||
# update-alternatives does not run at staging time: expose the sysv
|
||||
# fakeroot under the plain name dpkg-buildpackage and pkh exec.
|
||||
override-prime: |
|
||||
craftctl default
|
||||
ln -sfn fakeroot-sysv "${CRAFT_PRIME}/usr/bin/fakeroot"
|
||||
# Ship the license texts with the binary: the MIT grant requires
|
||||
# the notice to accompany copies, and GPL-2 requires the license
|
||||
# text alongside distribution.
|
||||
mkdir -p "${CRAFT_PRIME}/usr/share/doc/pkh"
|
||||
cp "${CRAFT_PART_SRC}/LICENSE-MIT" "${CRAFT_PART_SRC}/LICENSE-GPL" \
|
||||
"${CRAFT_PRIME}/usr/share/doc/pkh/"
|
||||
# Classic-confined ELFs default to the host loader, which pins the
|
||||
# snap to hosts shipping at least the build environment's glibc,
|
||||
# and cannot see the libraries deduplicated against the base.
|
||||
# Point every bundled ELF at the core24 loader and give it an
|
||||
# rpath resolving base libraries from the mounted base and
|
||||
# snap-local libraries from $ORIGIN — the classic linter's
|
||||
# guidance, and what Canonical's own classic snaps do. DT_RPATH
|
||||
# (--force-rpath) is required over the default DT_RUNPATH: the
|
||||
# host ld.so.cache would otherwise resolve sonames to host
|
||||
# libraries first, mixing host libm/libresolv with base libc.
|
||||
# DT_RPATH also propagates transitively, covering dependencies of
|
||||
# dependencies (libgpgme -> libassuan). Host tools spawned later
|
||||
# (host apt-get, ...) run with a pristine environment since no
|
||||
# LD_LIBRARY_PATH is exported.
|
||||
find "${CRAFT_PRIME}" -type f -exec sh -c '
|
||||
for f do
|
||||
[ "$(od -An -N4 -tx1 "$f" | tr -d " \n")" = "7f454c46" ] || continue
|
||||
patchelf --set-interpreter \
|
||||
/snap/core24/current/lib64/ld-linux-x86-64.so.2 "$f" 2>/dev/null || true
|
||||
patchelf --force-rpath --set-rpath \
|
||||
"/snap/core24/current/lib/x86_64-linux-gnu:/snap/core24/current/usr/lib/x86_64-linux-gnu:\$ORIGIN:\$ORIGIN/../lib/x86_64-linux-gnu:\$ORIGIN/../usr/lib/x86_64-linux-gnu" \
|
||||
"$f" 2>/dev/null || true
|
||||
done' sh {} +
|
||||
|
||||
+173
-39
@@ -4,12 +4,34 @@
|
||||
//! for mmdebstrap operations and for PPA packages by downloading them.
|
||||
|
||||
use crate::context;
|
||||
use crate::data::embed_data;
|
||||
use crate::distro_info;
|
||||
use serde::Deserialize;
|
||||
use std::error::Error;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Keyserver endpoint, loaded from the bundled `keyserver.yml` data file
|
||||
/// (same pattern as `distro_info.yml`): the lookup URL is a static
|
||||
/// endpoint that was previously hardcoded in two modules.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KeyserverData {
|
||||
/// OpenPGP key lookup URL template (`{fingerprint}`)
|
||||
lookup_template: String,
|
||||
}
|
||||
|
||||
embed_data! {
|
||||
static ref KEYSERVER_DATA: KeyserverData = "../../data/keyserver.yml"
|
||||
}
|
||||
|
||||
/// URL fetching the OpenPGP key of `fingerprint` from the keyserver
|
||||
pub(crate) fn keyserver_lookup_url(fingerprint: &str) -> String {
|
||||
KEYSERVER_DATA
|
||||
.lookup_template
|
||||
.replace("{fingerprint}", fingerprint)
|
||||
}
|
||||
|
||||
/// Launchpad API response structure for PPA information
|
||||
#[derive(Deserialize)]
|
||||
struct LaunchpadPpaResponse {
|
||||
@@ -47,19 +69,51 @@ pub async fn download_cache_keyrings(
|
||||
// Use system temp directory for keyrings since it's accessible from unshare mode
|
||||
// The home directory may not be accessible from mmdebstrap's unshare namespace
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let keyring_dir = temp_dir.join("pkh-keyrings");
|
||||
// Name the cache directory per-uid: a single shared /tmp directory would
|
||||
// be writable by any local user, and the skip-if-exists logic below
|
||||
// trusts pre-existing keyrings, so it must never be shared.
|
||||
let euid = current_euid();
|
||||
let keyring_dir = temp_dir.join(format!("pkh-keyrings-{euid}"));
|
||||
|
||||
// Create keyring directory if it doesn't exist
|
||||
if !ctx.exists(&keyring_dir)? {
|
||||
ctx.command("mkdir").arg("-p").arg(&keyring_dir).status()?;
|
||||
if ctx.exists(&keyring_dir)? {
|
||||
if let context::ContextConfig::Local = ctx.config {
|
||||
// Cached keyrings are trusted as-is whenever they already exist,
|
||||
// so refuse to reuse a directory that is not owned by the current
|
||||
// user or is writable by group/others (it could have been planted
|
||||
// by another local user).
|
||||
let metadata = std::fs::symlink_metadata(&keyring_dir)?;
|
||||
validate_keyring_dir(metadata.uid(), metadata.mode(), euid).map_err(|reason| {
|
||||
format!(
|
||||
"Refusing to use keyring cache directory {}: {reason}; \
|
||||
remove the directory and re-run pkh",
|
||||
keyring_dir.display()
|
||||
)
|
||||
})?;
|
||||
// Upgrade cache directories created by versions that made them
|
||||
// private: mmdebstrap's unshare-mode hooks cannot read them.
|
||||
} else {
|
||||
// Remote contexts (e.g. ssh) have no stat/metadata access through
|
||||
// the context API, so the ownership guard cannot be performed;
|
||||
// keep the previous best-effort behavior of tightening the
|
||||
// directory permissions instead (no group/others write).
|
||||
}
|
||||
ctx.command("chmod").arg("755").arg(&keyring_dir).status()?;
|
||||
} else {
|
||||
// Create the directory readable but not writable by group/others.
|
||||
// mmdebstrap's unshare-mode hooks run under an identity that cannot
|
||||
// read the invoking user's private directories, so 0700 breaks the
|
||||
// keyring copy into the chroot; the planting guard stays on the
|
||||
// ownership and no-write checks of validate_keyring_dir (the
|
||||
// skip-if-exists logic below trusts pre-existing keyrings, so the
|
||||
// directory must never be writable by anyone else).
|
||||
ctx.command("mkdir")
|
||||
.arg("-p")
|
||||
.arg("-m")
|
||||
.arg("755")
|
||||
.arg(&keyring_dir)
|
||||
.status()?;
|
||||
}
|
||||
|
||||
// Make keyring directory world-accessible so mmdebstrap in unshare mode can access it
|
||||
ctx.command("chmod")
|
||||
.arg("a+rwx")
|
||||
.arg(&keyring_dir)
|
||||
.status()?;
|
||||
|
||||
for keyring_url in keyring_urls {
|
||||
// Extract the original filename from the keyring URL
|
||||
let filename = keyring_url
|
||||
@@ -116,9 +170,6 @@ pub async fn download_cache_keyrings(
|
||||
let _ = ctx.command("rm").arg("-f").arg(&download_path).status();
|
||||
}
|
||||
|
||||
// Make the keyring file world-readable so mmdebstrap in unshare mode can access it
|
||||
ctx.command("chmod").arg("a+r").arg(&binary_path).status()?;
|
||||
|
||||
log::info!(
|
||||
"Successfully downloaded keyring for {} to {}",
|
||||
series,
|
||||
@@ -129,9 +180,12 @@ pub async fn download_cache_keyrings(
|
||||
"Keyring already exists at {}, skipping download",
|
||||
binary_path.display()
|
||||
);
|
||||
// Ensure existing keyring is world-readable
|
||||
ctx.command("chmod").arg("a+r").arg(&binary_path).status()?;
|
||||
}
|
||||
|
||||
// Readable like the directory: mmdebstrap's hooks copy these into
|
||||
// the chroot. Applies to legacy files too, which a restrictive
|
||||
// umask may have left private, and a permissive one group-writable.
|
||||
let _ = ctx.command("chmod").arg("644").arg(&binary_path).status();
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -143,7 +197,42 @@ pub async fn download_cache_keyrings(
|
||||
Ok(keyring_dir)
|
||||
}
|
||||
|
||||
/// Download and import a PPA key using Launchpad API
|
||||
/// Effective uid of the current process
|
||||
fn current_euid() -> u32 {
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
/// Check that an existing keyring cache directory is safe to reuse
|
||||
///
|
||||
/// Cached keyrings are trusted whenever the files already exist (see the
|
||||
/// skip-if-exists logic in [`download_cache_keyrings`]), so the directory
|
||||
/// must be owned by the current user and must not be writable by group or
|
||||
/// others, otherwise another local user could plant a malicious keyring.
|
||||
///
|
||||
/// Takes the directory's owner uid and permission mode (e.g. from
|
||||
/// `std::fs::symlink_metadata`) so it can be unit tested without touching
|
||||
/// the filesystem.
|
||||
fn validate_keyring_dir(dir_uid: u32, mode: u32, euid: u32) -> Result<(), String> {
|
||||
if dir_uid != euid {
|
||||
return Err(format!(
|
||||
"owned by uid {dir_uid}, not by the current user (uid {euid})"
|
||||
));
|
||||
}
|
||||
if mode & 0o022 != 0 {
|
||||
return Err(format!(
|
||||
"writable by group or others (permissions {:04o})",
|
||||
mode & 0o7777
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download and import a PPA key using the Launchpad API
|
||||
///
|
||||
/// The signing key fingerprint is looked up through the shared HTTP client;
|
||||
/// the key itself is fetched from the keyserver with curl through the
|
||||
/// context, because the key file must land in the context's filesystem
|
||||
/// (which may be remote).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ctx` - Optional context to use
|
||||
@@ -174,33 +263,36 @@ pub async fn download_trust_ppa_key(
|
||||
ppa_name
|
||||
);
|
||||
|
||||
// Get PPA information from Launchpad API to get signing key fingerprint
|
||||
// Use the correct devel API endpoint
|
||||
let api_url = format!(
|
||||
"https://api.launchpad.net/1.0/~{}/+archive/ubuntu/{}",
|
||||
ppa_owner, ppa_name
|
||||
);
|
||||
// Get PPA information from the Launchpad API to get the signing key
|
||||
// fingerprint. The query is context-independent metadata, so it goes
|
||||
// through the shared HTTP client (timeouts, retries) rather than
|
||||
// shelling out to curl.
|
||||
let api_url = crate::launchpad::archive_url(ppa_owner, ppa_name);
|
||||
log::debug!("Querying Launchpad API: {}", api_url);
|
||||
|
||||
let api_response = ctx
|
||||
.command("curl")
|
||||
.arg("-s")
|
||||
.arg("-f")
|
||||
.arg("-H")
|
||||
.arg("Accept: application/json")
|
||||
.arg(&api_url)
|
||||
.output()?;
|
||||
|
||||
if !api_response.status.success() {
|
||||
let response = distro_info::http_get_retried(&api_url).await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to query Launchpad API for PPA {}/{}: {}",
|
||||
ppa_owner, ppa_name, e
|
||||
)
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to query Launchpad API for PPA {}/{}",
|
||||
ppa_owner, ppa_name
|
||||
"Failed to query Launchpad API for PPA {}/{}: HTTP {}",
|
||||
ppa_owner,
|
||||
ppa_name,
|
||||
response.status()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Parse the JSON response to extract the signing key fingerprint
|
||||
let api_response_str = String::from_utf8_lossy(&api_response.stdout);
|
||||
let api_response_str = response.text().await.map_err(|e| {
|
||||
format!(
|
||||
"Failed to read the Launchpad API response for PPA {}/{}: {}",
|
||||
ppa_owner, ppa_name, e
|
||||
)
|
||||
})?;
|
||||
let ppa_response: LaunchpadPpaResponse =
|
||||
serde_json::from_str(&api_response_str).map_err(|e| {
|
||||
format!(
|
||||
@@ -213,10 +305,7 @@ pub async fn download_trust_ppa_key(
|
||||
log::debug!("Found PPA signing key fingerprint: {}", fingerprint);
|
||||
|
||||
// Download the actual key from the keyserver using the fingerprint
|
||||
let keyserver_url = format!(
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{}",
|
||||
fingerprint
|
||||
);
|
||||
let keyserver_url = keyserver_lookup_url(&fingerprint);
|
||||
log::debug!("Downloading key from keyserver: {}", keyserver_url);
|
||||
|
||||
let mut curl_cmd = ctx.command("curl");
|
||||
@@ -246,3 +335,48 @@ pub async fn download_trust_ppa_key(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The data-driven template renders the lookup URL the hardcoded
|
||||
/// format! used to build (verified against the live keyserver)
|
||||
#[test]
|
||||
fn keyserver_lookup_url_substitutes_the_fingerprint() {
|
||||
assert_eq!(
|
||||
keyserver_lookup_url("0123456789ABCDEF"),
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x0123456789ABCDEF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_keyring_dir_accepts_private_dir_owned_by_current_user() {
|
||||
assert!(validate_keyring_dir(1000, 0o700, 1000).is_ok());
|
||||
assert!(validate_keyring_dir(1000, 0o750, 1000).is_ok());
|
||||
assert!(validate_keyring_dir(1000, 0o1744, 1000).is_ok());
|
||||
assert!(validate_keyring_dir(0, 0o700, 0).is_ok());
|
||||
// The world-readable modes the cache now uses: readable so that
|
||||
// mmdebstrap's unshare-mode hooks can copy the keyrings, while the
|
||||
// ownership and no-write checks keep the planting guard.
|
||||
assert!(validate_keyring_dir(1000, 0o755, 1000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_keyring_dir_rejects_foreign_owner() {
|
||||
let err = validate_keyring_dir(1000, 0o700, 1001).unwrap_err();
|
||||
assert!(err.contains("owned by uid 1000"));
|
||||
let err = validate_keyring_dir(1001, 0o700, 1000).unwrap_err();
|
||||
assert!(err.contains("owned by uid 1001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_keyring_dir_rejects_group_or_other_writable() {
|
||||
assert!(validate_keyring_dir(1000, 0o770, 1000).is_err());
|
||||
assert!(validate_keyring_dir(1000, 0o706, 1000).is_err());
|
||||
assert!(validate_keyring_dir(1000, 0o707, 1000).is_err());
|
||||
assert!(validate_keyring_dir(1000, 0o777, 1000).is_err());
|
||||
// Sticky bit does not neutralize the group/other write bits.
|
||||
assert!(validate_keyring_dir(1000, 0o1777, 1000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod keyring;
|
||||
/// Release-file signature and checksum verification for repositories
|
||||
pub mod release;
|
||||
pub mod sources;
|
||||
|
||||
+1303
File diff suppressed because it is too large
Load Diff
+547
-118
@@ -1,97 +1,188 @@
|
||||
//! APT sources.list management
|
||||
//! Provides a simple structure for managing APT repository sources
|
||||
use crate::context;
|
||||
//!
|
||||
//! Entries carry enough information (kind, signed-by, trusted, enabled) to
|
||||
//! be written back without loss, and remember the file they were loaded
|
||||
//! from ([`SourceEntry::origin`]) so that saving writes each entry back to
|
||||
//! its own file, in that file's own format.
|
||||
use crate::context::{self, Context};
|
||||
use crate::debian::control::{Paragraph, parse_paragraphs, write_paragraph};
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sources file owned by pkh, holding entries added by pkh (e.g. PPAs).
|
||||
///
|
||||
/// New entries never end up in distro-managed files.
|
||||
const PKH_ADDED_PATH: &str = "/etc/apt/sources.list.d/pkh-added.list";
|
||||
|
||||
/// Suffix appended to an origin file path to build its backup path
|
||||
const BACKUP_SUFFIX: &str = ".pkh-backup";
|
||||
|
||||
/// Kind of packages provided by a source entry
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceKind {
|
||||
/// Binary packages ('deb')
|
||||
Deb,
|
||||
/// Source packages ('deb-src')
|
||||
DebSrc,
|
||||
}
|
||||
|
||||
impl SourceKind {
|
||||
/// Token used in legacy lines and deb822 'Types' fields
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SourceKind::Deb => "deb",
|
||||
SourceKind::DebSrc => "deb-src",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a type token ('deb' or 'deb-src')
|
||||
fn parse(token: &str) -> Option<Self> {
|
||||
match token {
|
||||
"deb" => Some(SourceKind::Deb),
|
||||
"deb-src" => Some(SourceKind::DebSrc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk format of a sources file
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceFormat {
|
||||
/// Legacy one-line-per-entry format (sources.list, *.list)
|
||||
Legacy,
|
||||
/// deb822 format (*.sources)
|
||||
Deb822,
|
||||
}
|
||||
|
||||
/// File a source entry was loaded from
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourceOrigin {
|
||||
/// Path of the origin file, inside the context
|
||||
pub path: PathBuf,
|
||||
/// Format of the origin file
|
||||
pub format: SourceFormat,
|
||||
}
|
||||
|
||||
/// Represents a single source entry in sources.list
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourceEntry {
|
||||
/// Is the source enabled?
|
||||
pub enabled: bool,
|
||||
/// Kind of packages provided by the source (binary or source)
|
||||
pub kind: SourceKind,
|
||||
/// Source components (universe, main, contrib)
|
||||
pub components: Vec<String>,
|
||||
/// Source architectures (amd64, riscv64, arm64)
|
||||
pub architectures: Vec<String>,
|
||||
/// Keyring the repository is signed with ('signed-by' option)
|
||||
pub signed_by: Option<String>,
|
||||
/// Explicit trust flag ('trusted' option), when set
|
||||
pub trusted: Option<bool>,
|
||||
/// Source URI
|
||||
pub uri: String,
|
||||
/// Source suites (series-pocket)
|
||||
pub suite: Vec<String>,
|
||||
/// File and format the entry was loaded from
|
||||
///
|
||||
/// Entries without an origin are new (e.g. repositories added by pkh);
|
||||
/// they are saved to the pkh-owned added-sources file.
|
||||
pub origin: Option<SourceOrigin>,
|
||||
}
|
||||
|
||||
impl SourceEntry {
|
||||
/// Parse a string describing a source entry in deb822 format
|
||||
pub fn from_deb822(data: &str) -> Option<Self> {
|
||||
let mut current_entry = SourceEntry {
|
||||
enabled: true,
|
||||
components: Vec::new(),
|
||||
architectures: Vec::new(),
|
||||
uri: String::new(),
|
||||
suite: Vec::new(),
|
||||
};
|
||||
|
||||
for line in data.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line: end of an entry, or beginning
|
||||
if line.is_empty() {
|
||||
if !current_entry.uri.is_empty() {
|
||||
return Some(current_entry);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match key {
|
||||
"Types" => {
|
||||
// We only care about deb types
|
||||
}
|
||||
"URIs" => current_entry.uri = value.to_string(),
|
||||
"Suites" => {
|
||||
current_entry.suite =
|
||||
value.split_whitespace().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"Components" => {
|
||||
current_entry.components =
|
||||
value.split_whitespace().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"Architectures" => {
|
||||
current_entry.architectures =
|
||||
value.split_whitespace().map(|s| s.to_string()).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Build entries from a single deb822 stanza
|
||||
///
|
||||
/// A stanza declaring several types ('Types: deb deb-src') yields one
|
||||
/// entry per type.
|
||||
fn from_deb822_stanza(p: &Paragraph) -> Vec<Self> {
|
||||
// apt defaults 'Types' to 'deb' when the field is absent
|
||||
let mut kinds: Vec<SourceKind> = p
|
||||
.get("Types")
|
||||
.unwrap_or("deb")
|
||||
.split_whitespace()
|
||||
.filter_map(SourceKind::parse)
|
||||
.collect();
|
||||
if kinds.is_empty() {
|
||||
kinds.push(SourceKind::Deb);
|
||||
}
|
||||
|
||||
// End of entry, or empty file?
|
||||
if !current_entry.uri.is_empty() {
|
||||
Some(current_entry)
|
||||
} else {
|
||||
None
|
||||
let enabled = p
|
||||
.get("Enabled")
|
||||
.map(|v| {
|
||||
let v = v.trim();
|
||||
!v.eq_ignore_ascii_case("no") && !v.eq_ignore_ascii_case("false")
|
||||
})
|
||||
.unwrap_or(true);
|
||||
let signed_by = p
|
||||
.get("Signed-By")
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string);
|
||||
let trusted = p
|
||||
.get("Trusted")
|
||||
.map(|v| v.trim().eq_ignore_ascii_case("yes"));
|
||||
let uri = p.get("URIs").unwrap_or("").trim().to_string();
|
||||
if uri.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let suite: Vec<String> = p
|
||||
.get("Suites")
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let components: Vec<String> = p
|
||||
.get("Components")
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let architectures: Vec<String> = p
|
||||
.get("Architectures")
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
kinds
|
||||
.into_iter()
|
||||
.map(|kind| SourceEntry {
|
||||
enabled,
|
||||
kind,
|
||||
components: components.clone(),
|
||||
architectures: architectures.clone(),
|
||||
signed_by: signed_by.clone(),
|
||||
trusted,
|
||||
uri: uri.clone(),
|
||||
suite: suite.clone(),
|
||||
origin: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a line describing a legacy source entry
|
||||
pub fn from_legacy(data: &str) -> Option<Self> {
|
||||
let line = data.lines().next()?.trim();
|
||||
let raw = data.lines().next()?.trim();
|
||||
|
||||
if line.is_empty() || line.starts_with("#") {
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse legacy deb line format: deb [arch=... / signed_by=] uri suite [components...]
|
||||
// Entries commented out with '#' are disabled, not deleted
|
||||
let (enabled, line) = match raw.strip_prefix('#') {
|
||||
Some(rest) => (false, rest.trim_start()),
|
||||
None => (true, raw),
|
||||
};
|
||||
|
||||
// Parse legacy deb line format:
|
||||
// deb [arch=... signed-by=... trusted=...] uri suite [components...]
|
||||
|
||||
// Extract bracket parameters first
|
||||
let mut architectures = Vec::new();
|
||||
let mut signed_by = None;
|
||||
let mut trusted = None;
|
||||
let mut line_without_brackets = line.to_string();
|
||||
|
||||
// Find and process bracket parameters
|
||||
@@ -102,14 +193,13 @@ impl SourceEntry {
|
||||
|
||||
// Parse parameters inside brackets
|
||||
for param in bracket_content.split_whitespace() {
|
||||
if param.starts_with("arch=") {
|
||||
let arch_values = param.split('=').nth(1).unwrap_or("");
|
||||
architectures = arch_values
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
if let Some(values) = param.strip_prefix("arch=") {
|
||||
architectures = values.split(',').map(|s| s.trim().to_string()).collect();
|
||||
} else if let Some(keyring) = param.strip_prefix("signed-by=") {
|
||||
signed_by = Some(keyring.trim_matches('"').to_string());
|
||||
} else if let Some(flag) = param.strip_prefix("trusted=") {
|
||||
trusted = Some(flag.eq_ignore_ascii_case("yes") || flag == "1");
|
||||
}
|
||||
// signed-by parameter is parsed but not stored
|
||||
}
|
||||
|
||||
// Remove the bracket section from the line
|
||||
@@ -120,37 +210,61 @@ impl SourceEntry {
|
||||
let line_without_brackets = line_without_brackets.trim();
|
||||
let parts: Vec<&str> = line_without_brackets.split_whitespace().collect();
|
||||
|
||||
// We need at least: deb, uri, suite
|
||||
if parts.len() < 3 || parts[0] != "deb" {
|
||||
// We need at least: type, uri, suite
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let kind = SourceKind::parse(parts[0])?;
|
||||
|
||||
let uri = parts[1].to_string();
|
||||
let suite = vec![parts[2].to_string()];
|
||||
let components: Vec<String> = parts[3..].iter().map(|&s| s.to_string()).collect();
|
||||
|
||||
Some(SourceEntry {
|
||||
enabled: true,
|
||||
enabled,
|
||||
kind,
|
||||
components,
|
||||
architectures,
|
||||
signed_by,
|
||||
trusted,
|
||||
uri,
|
||||
suite,
|
||||
origin: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert this source entry to legacy format
|
||||
///
|
||||
/// Entries holding several suites are rendered as one line per suite.
|
||||
/// Disabled entries are commented out.
|
||||
pub fn to_legacy(&self) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
// Legacy entries contain one suite per line
|
||||
for suite in &self.suite {
|
||||
// Start with "deb" type
|
||||
result.push_str("deb");
|
||||
if !self.enabled {
|
||||
result.push_str("# ");
|
||||
}
|
||||
result.push_str(self.kind.as_str());
|
||||
|
||||
// Add architectures if present
|
||||
// Bracket options: architectures, signing keyring and trust
|
||||
let mut options = Vec::new();
|
||||
if !self.architectures.is_empty() {
|
||||
result.push_str(" [arch=");
|
||||
result.push_str(&self.architectures.join(","));
|
||||
options.push(format!("arch={}", self.architectures.join(",")));
|
||||
}
|
||||
if let Some(keyring) = &self.signed_by {
|
||||
if keyring.contains(char::is_whitespace) {
|
||||
options.push(format!("signed-by=\"{keyring}\""));
|
||||
} else {
|
||||
options.push(format!("signed-by={keyring}"));
|
||||
}
|
||||
}
|
||||
if let Some(trusted) = self.trusted {
|
||||
options.push(format!("trusted={}", if trusted { "yes" } else { "no" }));
|
||||
}
|
||||
if !options.is_empty() {
|
||||
result.push_str(" [");
|
||||
result.push_str(&options.join(" "));
|
||||
result.push(']');
|
||||
}
|
||||
|
||||
@@ -171,88 +285,199 @@ impl SourceEntry {
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Convert this source entry to a deb822 stanza (with a trailing newline)
|
||||
pub fn to_deb822(&self) -> String {
|
||||
let mut stanza = Paragraph::new();
|
||||
stanza.set("Types", self.kind.as_str());
|
||||
stanza.set("URIs", &self.uri);
|
||||
stanza.set("Suites", &self.suite.join(" "));
|
||||
stanza.set("Components", &self.components.join(" "));
|
||||
if let Some(keyring) = &self.signed_by {
|
||||
stanza.set("Signed-By", keyring);
|
||||
}
|
||||
if !self.architectures.is_empty() {
|
||||
stanza.set("Architectures", &self.architectures.join(" "));
|
||||
}
|
||||
if let Some(trusted) = self.trusted {
|
||||
stanza.set("Trusted", if trusted { "yes" } else { "no" });
|
||||
}
|
||||
if !self.enabled {
|
||||
stanza.set("Enabled", "no");
|
||||
}
|
||||
write_paragraph(&stanza)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a 'source list' string in deb822 format into a SourceEntry vector
|
||||
///
|
||||
/// A stanza declaring several types ('Types: deb deb-src') yields one entry
|
||||
/// per type.
|
||||
pub fn parse_deb822(data: &str) -> Vec<SourceEntry> {
|
||||
data.split("\n\n")
|
||||
.flat_map(SourceEntry::from_deb822)
|
||||
parse_paragraphs(data)
|
||||
.iter()
|
||||
.flat_map(SourceEntry::from_deb822_stanza)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a 'source list' string in legacy format into a SourceEntry vector
|
||||
pub fn parse_legacy(data: &str) -> Vec<SourceEntry> {
|
||||
data.split("\n")
|
||||
data.split('\n')
|
||||
.flat_map(SourceEntry::from_legacy)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load sources from context (or current context by default)
|
||||
pub fn load(ctx: Option<Arc<crate::context::Context>>) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
///
|
||||
/// Reads the deb822 distro sources (ubuntu.sources or debian.sources), the
|
||||
/// legacy '/etc/apt/sources.list' and the pkh-owned added-sources file when
|
||||
/// they exist. Every entry remembers the file and format it came from.
|
||||
pub fn load(ctx: Option<Arc<Context>>) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
let mut sources = Vec::new();
|
||||
let ctx = ctx.unwrap_or_else(context::current);
|
||||
|
||||
// Try DEB822 format first (Ubuntu 24.04+ and Debian Trixie+)
|
||||
if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/ubuntu.sources") {
|
||||
sources.extend(entries);
|
||||
} else if let Ok(entries) = load_deb822(&ctx, "/etc/apt/sources.list.d/debian.sources") {
|
||||
sources.extend(entries);
|
||||
}
|
||||
load_file(
|
||||
&ctx,
|
||||
"/etc/apt/sources.list.d/ubuntu.sources",
|
||||
SourceFormat::Deb822,
|
||||
&mut sources,
|
||||
)?;
|
||||
load_file(
|
||||
&ctx,
|
||||
"/etc/apt/sources.list.d/debian.sources",
|
||||
SourceFormat::Deb822,
|
||||
&mut sources,
|
||||
)?;
|
||||
|
||||
// Fall back to legacy format
|
||||
if let Ok(entries) = load_legacy(&ctx, "/etc/apt/sources.list") {
|
||||
sources.extend(entries);
|
||||
}
|
||||
load_file(
|
||||
&ctx,
|
||||
"/etc/apt/sources.list",
|
||||
SourceFormat::Legacy,
|
||||
&mut sources,
|
||||
)?;
|
||||
|
||||
// Entries added by a previous pkh run
|
||||
load_file(&ctx, PKH_ADDED_PATH, SourceFormat::Legacy, &mut sources)?;
|
||||
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Save sources back to context
|
||||
pub fn save_legacy(
|
||||
ctx: Option<Arc<crate::context::Context>>,
|
||||
sources: Vec<SourceEntry>,
|
||||
path: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = if let Some(c) = ctx {
|
||||
c
|
||||
} else {
|
||||
context::current()
|
||||
};
|
||||
/// Save sources back to the context
|
||||
///
|
||||
/// Each entry is written back to the file it was loaded from
|
||||
/// ([`SourceEntry::origin`]), in that file's format. Entries without an
|
||||
/// origin (e.g. repositories added by pkh) go to the pkh-owned
|
||||
/// added-sources file in legacy format, never to distro-managed files.
|
||||
///
|
||||
/// Files whose rendered content is byte-identical to their current content
|
||||
/// are left untouched; otherwise a '<path>.pkh-backup' copy is created once
|
||||
/// before the first overwrite.
|
||||
pub fn save(ctx: Option<Arc<Context>>, sources: Vec<SourceEntry>) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = ctx.unwrap_or_else(context::current);
|
||||
|
||||
for (path, _format, content) in plan_writes(&sources) {
|
||||
let original = if ctx.exists(&path)? {
|
||||
Some(ctx.read_file(&path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if original.as_deref() == Some(content.as_str()) {
|
||||
// Nothing changed: leave the file untouched
|
||||
continue;
|
||||
}
|
||||
|
||||
// One-time backup before overwriting an existing file
|
||||
if original.is_some() {
|
||||
let backup = backup_path(&path);
|
||||
if !ctx.exists(&backup)? {
|
||||
ctx.copy_path(&path, &backup)?;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.write_file(&path, &content)?;
|
||||
}
|
||||
|
||||
let content = sources
|
||||
.into_iter()
|
||||
.map(|s| s.to_legacy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
ctx.write_file(Path::new(path), &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load sources from DEB822 format
|
||||
fn load_deb822(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
let path = Path::new(path);
|
||||
if path.exists() {
|
||||
let content = ctx.read_file(path)?;
|
||||
return Ok(parse_deb822(&content));
|
||||
/// Load entries from one sources file, if it exists, tagging them with
|
||||
/// their origin
|
||||
fn load_file(
|
||||
ctx: &Context,
|
||||
path: &str,
|
||||
format: SourceFormat,
|
||||
out: &mut Vec<SourceEntry>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let path = PathBuf::from(path);
|
||||
if !ctx.exists(&path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
let content = ctx.read_file(&path)?;
|
||||
let mut entries = match format {
|
||||
SourceFormat::Deb822 => parse_deb822(&content),
|
||||
SourceFormat::Legacy => parse_legacy(&content),
|
||||
};
|
||||
for entry in &mut entries {
|
||||
entry.origin = Some(SourceOrigin {
|
||||
path: path.clone(),
|
||||
format,
|
||||
});
|
||||
}
|
||||
out.append(&mut entries);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load sources from legacy format
|
||||
fn load_legacy(ctx: &context::Context, path: &str) -> Result<Vec<SourceEntry>, Box<dyn Error>> {
|
||||
let path = Path::new(path);
|
||||
if path.exists() {
|
||||
let content = ctx.read_file(path)?;
|
||||
return Ok(content.lines().flat_map(SourceEntry::from_legacy).collect());
|
||||
/// Compute the writes needed to persist entries: one
|
||||
/// (path, format, content) triple per destination file, entries kept in order
|
||||
///
|
||||
/// Entries without an origin are routed to the pkh-owned added-sources file.
|
||||
fn plan_writes(sources: &[SourceEntry]) -> Vec<(PathBuf, SourceFormat, String)> {
|
||||
let mut plan: Vec<(PathBuf, SourceFormat, Vec<&SourceEntry>)> = Vec::new();
|
||||
for entry in sources {
|
||||
let (path, format) = match &entry.origin {
|
||||
Some(origin) => (origin.path.clone(), origin.format),
|
||||
None => (PathBuf::from(PKH_ADDED_PATH), SourceFormat::Legacy),
|
||||
};
|
||||
if let Some((_, _, group)) = plan.iter_mut().find(|(p, _, _)| *p == path) {
|
||||
group.push(entry);
|
||||
} else {
|
||||
plan.push((path, format, vec![entry]));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
plan.into_iter()
|
||||
.map(|(path, format, entries)| {
|
||||
let content = match format {
|
||||
// Legacy entries end with '\n': plain concatenation, no
|
||||
// blank lines in between
|
||||
SourceFormat::Legacy => entries.iter().map(|e| e.to_legacy()).collect(),
|
||||
// deb822 stanzas end with '\n': a '\n' join gives one blank
|
||||
// line between stanzas
|
||||
SourceFormat::Deb822 => entries
|
||||
.iter()
|
||||
.map(|e| e.to_deb822())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
};
|
||||
(path, format, content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Backup path for a sources file ('<path>.pkh-backup')
|
||||
fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut with_suffix = path.as_os_str().to_os_string();
|
||||
with_suffix.push(BACKUP_SUFFIX);
|
||||
PathBuf::from(with_suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::ContextConfig;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_deb822() {
|
||||
@@ -333,4 +558,208 @@ mod tests {
|
||||
assert_eq!(sources[2].suite, vec!["resolute-security"]);
|
||||
assert_eq!(sources[2].components, vec!["main"]);
|
||||
}
|
||||
|
||||
/// Legacy round-trip: kind, signed-by, trusted and arch are preserved,
|
||||
/// and rendering introduces no blank lines
|
||||
#[test]
|
||||
fn legacy_roundtrip_preserves_options() {
|
||||
let input = "\
|
||||
deb [arch=amd64 signed-by=/k.gpg] http://x noble main\n\
|
||||
deb-src http://x noble main\n\
|
||||
deb [trusted=yes] http://x noble universe\n\
|
||||
# deb [arch=i386] http://x noble main\n";
|
||||
|
||||
let sources = parse_legacy(input);
|
||||
assert_eq!(sources.len(), 4);
|
||||
assert_eq!(sources[0].kind, SourceKind::Deb);
|
||||
assert_eq!(sources[0].signed_by.as_deref(), Some("/k.gpg"));
|
||||
assert_eq!(sources[0].architectures, vec!["amd64"]);
|
||||
assert_eq!(sources[1].kind, SourceKind::DebSrc);
|
||||
assert_eq!(sources[2].trusted, Some(true));
|
||||
assert!(!sources[3].enabled);
|
||||
|
||||
// Render as a legacy file through the save planning path
|
||||
let origin = SourceOrigin {
|
||||
path: PathBuf::from("/etc/apt/sources.list"),
|
||||
format: SourceFormat::Legacy,
|
||||
};
|
||||
let mut sources = sources;
|
||||
for entry in &mut sources {
|
||||
entry.origin = Some(origin.clone());
|
||||
}
|
||||
let plan = plan_writes(&sources);
|
||||
assert_eq!(plan.len(), 1);
|
||||
let rendered = &plan[0].2;
|
||||
|
||||
// Rendering is faithful: byte-identical and without blank lines
|
||||
assert_eq!(rendered, input);
|
||||
assert!(!rendered.contains("\n\n"));
|
||||
|
||||
let reparsed = parse_legacy(rendered);
|
||||
assert_eq!(reparsed, parse_legacy(input));
|
||||
}
|
||||
|
||||
/// deb822 round-trip: multiple types are split into one entry per type,
|
||||
/// Signed-By and Enabled are preserved
|
||||
#[test]
|
||||
fn deb822_roundtrip_preserves_types_and_options() {
|
||||
let input = "\
|
||||
Types: deb deb-src\n\
|
||||
URIs: http://archive.ubuntu.com/ubuntu\n\
|
||||
Suites: noble\n\
|
||||
Components: main\n\
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\
|
||||
Enabled: false\n\
|
||||
\n\
|
||||
Types: deb\n\
|
||||
URIs: http://archive.ubuntu.com/ubuntu\n\
|
||||
Suites: noble-updates\n\
|
||||
Components: main universe\n";
|
||||
|
||||
let sources = parse_deb822(input);
|
||||
// The first stanza declares two types: one entry per type
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert_eq!(sources[0].kind, SourceKind::Deb);
|
||||
assert_eq!(sources[1].kind, SourceKind::DebSrc);
|
||||
assert_eq!(sources[2].kind, SourceKind::Deb);
|
||||
assert!(!sources[0].enabled);
|
||||
assert!(!sources[1].enabled);
|
||||
assert!(sources[2].enabled);
|
||||
assert_eq!(
|
||||
sources[0].signed_by.as_deref(),
|
||||
Some("/usr/share/keyrings/ubuntu-archive-keyring.gpg")
|
||||
);
|
||||
assert_eq!(sources[1].signed_by, sources[0].signed_by);
|
||||
assert_eq!(sources[2].signed_by, None);
|
||||
|
||||
// Render as a deb822 file through the save planning path
|
||||
let mut sources = sources;
|
||||
for entry in &mut sources {
|
||||
entry.origin = Some(SourceOrigin {
|
||||
path: PathBuf::from("/etc/apt/sources.list.d/ubuntu.sources"),
|
||||
format: SourceFormat::Deb822,
|
||||
});
|
||||
}
|
||||
let plan = plan_writes(&sources);
|
||||
assert_eq!(plan.len(), 1);
|
||||
let rendered = &plan[0].2;
|
||||
|
||||
let reparsed = parse_deb822(rendered);
|
||||
// Parse/render round-trip preserves the model (origin excepted)
|
||||
assert_eq!(reparsed, parse_deb822(input));
|
||||
assert_eq!(reparsed[0].kind, SourceKind::Deb);
|
||||
assert_eq!(reparsed[1].kind, SourceKind::DebSrc);
|
||||
assert_eq!(reparsed[2].kind, SourceKind::Deb);
|
||||
assert!(!reparsed[0].enabled);
|
||||
assert!(!reparsed[1].enabled);
|
||||
assert!(reparsed[2].enabled);
|
||||
assert_eq!(
|
||||
reparsed[0].signed_by.as_deref(),
|
||||
Some("/usr/share/keyrings/ubuntu-archive-keyring.gpg")
|
||||
);
|
||||
// 'Enabled' is only emitted for disabled entries
|
||||
assert_eq!(rendered.matches("Enabled: no").count(), 2);
|
||||
}
|
||||
|
||||
/// Entries are routed to their origin file in its own format, and new
|
||||
/// entries (no origin) go to the pkh-owned added-sources file
|
||||
#[test]
|
||||
fn plan_writes_routes_by_origin() {
|
||||
let origin_a = SourceOrigin {
|
||||
path: PathBuf::from("/etc/apt/sources.list.d/ubuntu.sources"),
|
||||
format: SourceFormat::Deb822,
|
||||
};
|
||||
|
||||
let mut sources = parse_deb822(
|
||||
"Types: deb\nURIs: http://archive.ubuntu.com/ubuntu\nSuites: noble\nComponents: main\n",
|
||||
);
|
||||
sources[0].origin = Some(origin_a.clone());
|
||||
|
||||
// Modify the origin-A entry and add a brand new (PPA) entry
|
||||
sources[0].components.push("universe".to_string());
|
||||
sources.push(SourceEntry {
|
||||
enabled: true,
|
||||
kind: SourceKind::Deb,
|
||||
components: vec!["main".to_string()],
|
||||
architectures: vec![],
|
||||
signed_by: None,
|
||||
trusted: None,
|
||||
uri: "http://ppa.example.org/user/ppa/ubuntu".to_string(),
|
||||
suite: vec!["noble".to_string()],
|
||||
origin: None,
|
||||
});
|
||||
|
||||
let plan = plan_writes(&sources);
|
||||
assert_eq!(plan.len(), 2);
|
||||
|
||||
assert_eq!(plan[0].0, origin_a.path);
|
||||
assert_eq!(plan[0].1, SourceFormat::Deb822);
|
||||
assert!(plan[0].2.starts_with("Types: deb\n"));
|
||||
assert!(plan[0].2.contains("main universe"));
|
||||
|
||||
assert_eq!(
|
||||
plan[1].0,
|
||||
PathBuf::from("/etc/apt/sources.list.d/pkh-added.list")
|
||||
);
|
||||
assert_eq!(plan[1].1, SourceFormat::Legacy);
|
||||
assert!(plan[1].2.starts_with("deb http://ppa.example.org/"));
|
||||
}
|
||||
|
||||
/// save() leaves unchanged files untouched, and backs up existing files
|
||||
/// once before overwriting them; the backup also works for the
|
||||
/// pkh-owned added-sources file
|
||||
#[test]
|
||||
fn save_skips_unchanged_and_backs_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ubuntu.sources");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n",
|
||||
)
|
||||
.unwrap();
|
||||
let ctx = Arc::new(Context::new(ContextConfig::Local).unwrap());
|
||||
|
||||
let origin = SourceOrigin {
|
||||
path: path.clone(),
|
||||
format: SourceFormat::Deb822,
|
||||
};
|
||||
let mut entries =
|
||||
parse_deb822("Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n");
|
||||
entries[0].origin = Some(origin.clone());
|
||||
|
||||
// Unchanged content: no write, no backup
|
||||
save(Some(ctx.clone()), entries.clone()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n"
|
||||
);
|
||||
assert!(!backup_path(&path).exists());
|
||||
|
||||
// Modified content: backup created, file rewritten in its own format
|
||||
entries[0].components.push("universe".to_string());
|
||||
save(Some(ctx.clone()), entries).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(backup_path(&path)).unwrap(),
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe\n"
|
||||
);
|
||||
|
||||
// A second save does not overwrite the first backup
|
||||
let mut entries =
|
||||
parse_deb822("Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe\n");
|
||||
entries[0].origin = Some(origin);
|
||||
entries[0].components.push("restricted".to_string());
|
||||
save(Some(ctx.clone()), entries).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(backup_path(&path)).unwrap(),
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main\n"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"Types: deb\nURIs: http://a\nSuites: noble\nComponents: main universe restricted\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::changelog::parse_changelog_footer;
|
||||
use crate::utils::gpg;
|
||||
|
||||
/// Build a Debian source package (to a .dsc)
|
||||
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Parse changelog to get maintainer information from the last modification entry
|
||||
let changelog_path = cwd.join("debian/changelog");
|
||||
let (maintainer_name, maintainer_email) = parse_changelog_footer(&changelog_path)?;
|
||||
|
||||
// Check if a GPG key matching the maintainer's email exists
|
||||
let signing_key = match gpg::find_signing_key_for_email(&maintainer_email) {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
// If GPG is not available or there's an error, continue without signing
|
||||
log::warn!("Failed to check for GPG key: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Build command arguments
|
||||
let mut command = Command::new("dpkg-buildpackage");
|
||||
command
|
||||
.current_dir(cwd)
|
||||
.arg("-S")
|
||||
.arg("-I")
|
||||
.arg("-i")
|
||||
.arg("-nc")
|
||||
.arg("-d");
|
||||
|
||||
// If a signing key is found, use it for signing
|
||||
if let Some(key_id) = &signing_key {
|
||||
command.arg(format!("--sign-keyid={}", key_id));
|
||||
log::info!("Using GPG key {} for signing", key_id);
|
||||
} else {
|
||||
command.arg("--no-sign");
|
||||
log::info!(
|
||||
"No GPG key found for {} ({}), building without signing",
|
||||
maintainer_name,
|
||||
maintainer_email
|
||||
);
|
||||
}
|
||||
|
||||
let status = command.status().map_err(|e| {
|
||||
format!(
|
||||
"Failed to run 'dpkg-buildpackage': {}. \
|
||||
Is 'dpkg-dev' (which provides dpkg-buildpackage) installed?",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"dpkg-buildpackage failed with status: {}. \
|
||||
Re-run with 'RUST_LOG=debug' for more details, or run \
|
||||
'dpkg-buildpackage -S -I -i -nc -d' manually in '{}' to see the full output.",
|
||||
status,
|
||||
cwd.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if signing_key.is_some() {
|
||||
println!("Package built and signed successfully!");
|
||||
} else {
|
||||
println!("Package built successfully (unsigned).");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
// We are not testing the build part, as for now this is just a wrapper
|
||||
// around dpkg-buildpackage.
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
//! Binary-build metadata generation: native `.buildinfo` / `.changes`
|
||||
//! production for binary-only builds (`pkh deb`), the equivalent of
|
||||
//! `dpkg-genbuildinfo -b` + `dpkg-genchanges -b`.
|
||||
//!
|
||||
//! All tree/database access goes through a [`Context`] so the generation can
|
||||
//! run against a build tree living in a local directory, an ephemeral
|
||||
//! chroot or a remote host. Artifact digests are computed inside the context
|
||||
//! with coreutils (`md5sum`, `sha1sum`, `sha256sum`, `stat`), keeping the
|
||||
//! flow binary-safe regardless of the transport.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::debian::{ChecksumEntry, ControlInfo, FileChecksums, FilesList};
|
||||
|
||||
/// Digests of one artifact.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ArtifactHashes {
|
||||
size: u64,
|
||||
md5: String,
|
||||
sha1: String,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
/// Options driving binary metadata generation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BinaryMetadataOptions {
|
||||
/// Active build profiles (`Built-For-Profiles`).
|
||||
pub profiles: Vec<String>,
|
||||
/// Vendor name (`Build-Origin`).
|
||||
pub vendor: String,
|
||||
/// Environment variables pkh exported to the build steps (e.g. `LANG`,
|
||||
/// `DEB_BUILD_OPTIONS` with the real parallel count and `nocheck`,
|
||||
/// `SOURCE_DATE_EPOCH`, cross `DEB_*` variables). Recorded — filtered to
|
||||
/// dpkg's allow-list — in the `.buildinfo` `Environment` field, taking
|
||||
/// precedence over whatever the host process inherited, so the metadata
|
||||
/// describes the environment the build actually ran in.
|
||||
pub exported_env: BTreeMap<String, String>,
|
||||
/// Build architecture (the machine inside the build context).
|
||||
pub build_arch: String,
|
||||
/// Host architecture (the packages' target); equals the build
|
||||
/// architecture except for cross builds.
|
||||
pub host_arch: String,
|
||||
}
|
||||
|
||||
/// Generate `<pkg>_<ver>_<arch>.buildinfo` and `.changes` for a finished
|
||||
/// binary build, consuming `debian/files` from `package_dir` and the
|
||||
/// artifacts sitting in `upload_dir`. Returns both paths (inside the
|
||||
/// context).
|
||||
///
|
||||
/// Mirrors the observable behavior of `dpkg-genbuildinfo -b` and
|
||||
/// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture`
|
||||
/// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo`
|
||||
/// registration in `debian/files`, and binary-NMU handling (`Source:
|
||||
/// pkg (prev)` + `Binary-Only-Changes`, with no source files distributed).
|
||||
pub fn generate_binary_metadata(
|
||||
ctx: &Arc<Context>,
|
||||
package_dir: &Path,
|
||||
upload_dir: &Path,
|
||||
opts: &BinaryMetadataOptions,
|
||||
) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
|
||||
// ------------------------------------------------------------------
|
||||
// Metadata sources inside the context
|
||||
// ------------------------------------------------------------------
|
||||
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
||||
let mut entries =
|
||||
crate::debian::changelog::parse_changelog_entries_from_str(&changelog_content, Some(2))?;
|
||||
let entry = entries.remove(0);
|
||||
let previous_entry = entries.into_iter().next();
|
||||
|
||||
let control_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
||||
let control = ControlInfo::parse_content(&control_content)?;
|
||||
|
||||
// A missing `debian/files` is tolerated (first binary build in a fresh
|
||||
// tree has nothing registered yet; that surfaces below as the "no binary
|
||||
// artifacts" error), like `FilesList::load`. Any other read failure must
|
||||
// not be silently mistaken for an empty registry.
|
||||
let files_path = package_dir.join("debian/files");
|
||||
let files_content = if ctx.exists(&files_path)? {
|
||||
ctx.read_file(&files_path)
|
||||
.map_err(|e| format!("cannot read '{}': {}", files_path.display(), e))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut files_list = FilesList::parse(&files_content)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Collect binary artifacts registered in debian/files
|
||||
// ------------------------------------------------------------------
|
||||
let artifact_names: Vec<String> = files_list
|
||||
.iter()
|
||||
.filter(|e| matches!(e.package_type.as_deref(), Some("deb") | Some("udeb")))
|
||||
.map(|e| e.filename.clone())
|
||||
.collect();
|
||||
|
||||
if artifact_names.is_empty() {
|
||||
return Err("binary build with no binary artifacts found; cannot distribute".into());
|
||||
}
|
||||
|
||||
let mut hashes = hashes_in_context(ctx, upload_dir, &artifact_names)?;
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
let mut arch_values: Vec<String> = Vec::new();
|
||||
let mut arch_seen = std::collections::HashSet::new();
|
||||
for name in &artifact_names {
|
||||
let entry_hashes = hashes
|
||||
.remove(name)
|
||||
.ok_or_else(|| format!("artifact '{name}' listed in debian/files but not found"))?;
|
||||
// SHA-512 stays unknown here: like dpkg-genbuildinfo, no SHA-512
|
||||
// digest is computed for the artifacts, and an empty digest keeps
|
||||
// the `Checksums-Sha512` field of the `.buildinfo` omitted.
|
||||
checksums.insert_entry(
|
||||
name,
|
||||
ChecksumEntry {
|
||||
size: entry_hashes.size,
|
||||
md5: entry_hashes.md5,
|
||||
sha1: entry_hashes.sha1,
|
||||
sha256: entry_hashes.sha256,
|
||||
sha512: String::new(),
|
||||
},
|
||||
);
|
||||
// Architecture accumulation in encounter order (dpkg-genchanges).
|
||||
if let Some(file_entry) = files_list.get(name)
|
||||
&& let Some(arch) = file_entry
|
||||
.arch
|
||||
.as_ref()
|
||||
.or_else(|| file_entry.attrs.get("architecture"))
|
||||
&& arch_seen.insert(arch.clone())
|
||||
{
|
||||
arch_values.push(arch.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Binary-NMU: reference the previous source version, textually only
|
||||
// ------------------------------------------------------------------
|
||||
let sversion = entry.version.no_epoch();
|
||||
let mut source_display = entry.source.clone();
|
||||
let mut binary_only_changes = None;
|
||||
|
||||
if entry.binary_only {
|
||||
// Like dpkg-genchanges/genbuildinfo, a binary-only upload references
|
||||
// the previous source version in the `Source` field and records the
|
||||
// entry in `Binary-Only-Changes`, but distributes NO source files:
|
||||
// the previous `.dsc` and its tarballs already sit in the archive,
|
||||
// and are not re-uploaded even when present next to the tree.
|
||||
if let Some(prev) = &previous_entry {
|
||||
source_display = format!("{} ({})", entry.source, prev.version.full());
|
||||
binary_only_changes = Some(format!(
|
||||
"{}\n\n -- {} <{}> {}",
|
||||
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Binary package names and descriptions
|
||||
// ------------------------------------------------------------------
|
||||
let mut binaries: Vec<String> = Vec::new();
|
||||
for name in &artifact_names {
|
||||
if let Some(e) = files_list.get(name)
|
||||
&& let Some(pkg) = &e.package
|
||||
&& !binaries.contains(pkg)
|
||||
{
|
||||
binaries.push(pkg.clone());
|
||||
}
|
||||
}
|
||||
binaries.sort();
|
||||
|
||||
// Description lines: first line of each binary stanza's Description,
|
||||
// formatted exactly like dpkg-genchanges, sorted.
|
||||
let mut descriptions = Vec::new();
|
||||
for stanza in &control.binaries {
|
||||
let Some(pkg) = stanza.get("Package") else {
|
||||
continue;
|
||||
};
|
||||
if !binaries.contains(&pkg.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let summary = stanza
|
||||
.get("Description")
|
||||
.unwrap_or("no description available")
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("no description available");
|
||||
// Package-Type overrides the artifact-derived type (deb default).
|
||||
let pkg_type = stanza
|
||||
.get("Package-Type")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
files_list
|
||||
.iter()
|
||||
.find(|f| f.package.as_deref() == Some(pkg))
|
||||
.and_then(|f| f.package_type.clone())
|
||||
.unwrap_or_else(|| "deb".to_string())
|
||||
});
|
||||
descriptions.push(crate::build::changes::format_description(
|
||||
pkg, &pkg_type, summary,
|
||||
));
|
||||
}
|
||||
descriptions.sort();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Installed-Build-Depends closure over the context status database
|
||||
// ------------------------------------------------------------------
|
||||
// Like the source-build path, a status database that cannot be read is
|
||||
// a hard error: silently treating it as empty would drop (or gut) the
|
||||
// `Installed-Build-Depends` field of the produced metadata.
|
||||
let status_path = Path::new("/var/lib/dpkg/status");
|
||||
let status_content = ctx
|
||||
.read_file(status_path)
|
||||
.map_err(|e| format!("cannot read status file '{}': {}", status_path.display(), e))?;
|
||||
let bd_fields = [
|
||||
control.source.get("Build-Depends").unwrap_or(""),
|
||||
control.source.get("Build-Depends-Arch").unwrap_or(""),
|
||||
control.source.get("Build-Depends-Indep").unwrap_or(""),
|
||||
];
|
||||
let installed_build_depends =
|
||||
crate::build::buildinfo::installed_build_depends_from_content(&status_content, &bd_fields)?;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// .buildinfo generation, then registration in debian/files
|
||||
// ------------------------------------------------------------------
|
||||
// Record exactly the environment that was exported to the build steps,
|
||||
// overriding any host-inherited value (dpkg-style allowed-variable
|
||||
// filtering, export precedence).
|
||||
let environment = crate::build::env::buildinfo_environment(&opts.exported_env);
|
||||
|
||||
// dpkg-genbuildinfo sorts the accumulated architecture values, while
|
||||
// dpkg-genchanges keeps encounter order.
|
||||
let mut buildinfo_arch_values = arch_values.clone();
|
||||
buildinfo_arch_values.sort();
|
||||
|
||||
let buildinfo_name = format!("{}_{}_{}.buildinfo", entry.source, sversion, opts.host_arch);
|
||||
let buildinfo_doc =
|
||||
crate::build::buildinfo::render_buildinfo(&crate::build::buildinfo::BuildInfoInput {
|
||||
source: source_display.clone(),
|
||||
binaries: binaries.clone(),
|
||||
architecture: buildinfo_arch_values.join(" "),
|
||||
version: entry.version.full(),
|
||||
binary_only_changes: binary_only_changes.clone(),
|
||||
build_origin: opts.vendor.clone(),
|
||||
build_architecture: opts.build_arch.clone(),
|
||||
build_date: chrono::Local::now().to_rfc2822(),
|
||||
checksums: checksums.clone(),
|
||||
installed_build_depends,
|
||||
environment,
|
||||
});
|
||||
let buildinfo_path = upload_dir.join(&buildinfo_name);
|
||||
ctx.write_file(
|
||||
&buildinfo_path,
|
||||
&crate::debian::control::write_paragraph(&buildinfo_doc),
|
||||
)?;
|
||||
|
||||
// Register the .buildinfo in debian/files, like dpkg-genbuildinfo does,
|
||||
// so the .changes distributes it.
|
||||
files_list.add(crate::debian::FilesEntry::new(
|
||||
&buildinfo_name,
|
||||
control.section(),
|
||||
control.priority(),
|
||||
));
|
||||
ctx.write_file(&package_dir.join("debian/files"), &files_list.render())?;
|
||||
|
||||
// Hash the freshly written .buildinfo inside the context.
|
||||
let buildinfo_hashes =
|
||||
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&buildinfo_name))?;
|
||||
if let Some(h) = buildinfo_hashes.get(&buildinfo_name) {
|
||||
checksums.insert_entry(
|
||||
&buildinfo_name,
|
||||
ChecksumEntry {
|
||||
size: h.size,
|
||||
md5: h.md5.clone(),
|
||||
sha1: h.sha1.clone(),
|
||||
sha256: h.sha256.clone(),
|
||||
// No SHA-512 digest available (see above); keeps the
|
||||
// `Checksums-Sha512` `.buildinfo` field omitted.
|
||||
sha512: String::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// .changes generation
|
||||
// ------------------------------------------------------------------
|
||||
let changes_name = format!("{}_{}_{}.changes", entry.source, sversion, opts.host_arch);
|
||||
let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email);
|
||||
let changes_doc = crate::build::changes::render_changes(&crate::build::changes::ChangesInput {
|
||||
date: entry.date_raw.clone(),
|
||||
source: source_display,
|
||||
binaries,
|
||||
binary_only: entry.binary_only,
|
||||
built_for_profiles: opts.profiles.clone(),
|
||||
architecture: arch_values.join(" "),
|
||||
version: entry.version.full(),
|
||||
distribution: entry.distribution.clone(),
|
||||
urgency: entry.urgency.clone(),
|
||||
maintainer: control.source.get("Maintainer").map(str::to_string),
|
||||
changed_by: Some(changed_by),
|
||||
descriptions,
|
||||
closes: entry.closes.clone(),
|
||||
changes_field: entry.changes_field.clone(),
|
||||
checksums,
|
||||
files_list,
|
||||
});
|
||||
let changes_path = upload_dir.join(&changes_name);
|
||||
ctx.write_file(
|
||||
&changes_path,
|
||||
&crate::debian::control::write_paragraph(&changes_doc),
|
||||
)?;
|
||||
|
||||
Ok((buildinfo_path, changes_path))
|
||||
}
|
||||
|
||||
/// Compute md5/sha1/sha256 digests and sizes for the named files inside the
|
||||
/// context directory `dir`, using coreutils.
|
||||
fn hashes_in_context(
|
||||
ctx: &Arc<Context>,
|
||||
dir: &Path,
|
||||
names: &[String],
|
||||
) -> Result<BTreeMap<String, ArtifactHashes>, Box<dyn Error>> {
|
||||
let mut out: BTreeMap<String, ArtifactHashes> = names
|
||||
.iter()
|
||||
.map(|n| (n.clone(), ArtifactHashes::default()))
|
||||
.collect();
|
||||
|
||||
// Sizes. A failed `stat` must fail the metadata generation: an unchecked
|
||||
// exit status would leave the default size 0 in the produced
|
||||
// `.changes`/`.buildinfo` checksum entries.
|
||||
let output = ctx
|
||||
.command("stat")
|
||||
.current_dir(dir)
|
||||
.arg("-c")
|
||||
.arg("%s %n")
|
||||
.args(names)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run 'stat' inside the build context: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"'stat' failed inside the build context: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let Some((size, name)) = line.trim().split_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
let size = size
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("'stat' reported an invalid size '{size}' for '{name}'"))?;
|
||||
if let Some(slot) = out.get_mut(name) {
|
||||
slot.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
// Digests.
|
||||
for (tool, field) in [
|
||||
("md5sum", 0usize),
|
||||
("sha1sum", 1usize),
|
||||
("sha256sum", 2usize),
|
||||
] {
|
||||
let output = ctx
|
||||
.command(tool)
|
||||
.current_dir(dir)
|
||||
.args(names)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run '{tool}' inside the build context: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"'{tool}' failed inside the build context: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let Some((digest, name)) = line.trim().split_once(" ") else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim_start_matches('*');
|
||||
if let Some(slot) = out.get_mut(name) {
|
||||
match field {
|
||||
0 => slot.md5 = digest.to_string(),
|
||||
1 => slot.sha1 = digest.to_string(),
|
||||
_ => slot.sha256 = digest.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The recorded `.buildinfo` `Environment` must carry the environment
|
||||
/// actually exported to the build steps (`parallel=N nocheck`, `LANG=C`,
|
||||
/// ...), taking precedence over any host-inherited value, instead of
|
||||
/// values recomputed from host state at generation time.
|
||||
#[test]
|
||||
fn environment_records_exported_env_not_host_defaults() {
|
||||
let mut exported_env = BTreeMap::new();
|
||||
exported_env.insert("LANG".to_string(), "C".to_string());
|
||||
exported_env.insert(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
"parallel=7 nocheck".to_string(),
|
||||
);
|
||||
let opts = BinaryMetadataOptions {
|
||||
profiles: Vec::new(),
|
||||
vendor: "debian".to_string(),
|
||||
exported_env,
|
||||
build_arch: "amd64".to_string(),
|
||||
host_arch: "amd64".to_string(),
|
||||
};
|
||||
|
||||
let environment = crate::build::env::buildinfo_environment(&opts.exported_env);
|
||||
assert!(
|
||||
environment.contains("DEB_BUILD_OPTIONS=\"parallel=7 nocheck\""),
|
||||
"recorded Environment must carry the exported DEB_BUILD_OPTIONS: {environment}"
|
||||
);
|
||||
assert!(
|
||||
environment.contains("LANG=\"C\""),
|
||||
"recorded Environment must carry the exported LANG: {environment}"
|
||||
);
|
||||
// Not in dpkg's allowed-variable list: never recorded.
|
||||
assert!(!environment.contains("DEBIAN_FRONTEND"), "{environment}");
|
||||
}
|
||||
|
||||
/// A binary-only (binNMU) build whose changelog cannot yield the
|
||||
/// previous entry (malformed second header, unbalanced parenthesis) must
|
||||
/// fail the metadata generation with a diagnostic naming the problem,
|
||||
/// instead of silently emitting a plain `Source:` `.changes` with no
|
||||
/// `Binary-Only-Changes` and no previous-version reference.
|
||||
#[test]
|
||||
fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() {
|
||||
let changelog = "\
|
||||
hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only rebuild.
|
||||
|
||||
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
|
||||
hello (1.0-1 unstable; urgency=medium
|
||||
|
||||
* Previous entry with an unbalanced parenthesis.
|
||||
|
||||
-- A B <a@b.c> Sun, 31 Dec 2023 00:00:00 +0000
|
||||
";
|
||||
let control = "\
|
||||
Source: hello
|
||||
Section: devel
|
||||
Priority: optional
|
||||
Maintainer: A B <a@b.c>
|
||||
|
||||
Package: hello
|
||||
Architecture: all
|
||||
Description: test package
|
||||
";
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = base.path().join("hello-1.0");
|
||||
std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree");
|
||||
std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog");
|
||||
std::fs::write(tree.join("debian/control"), control).expect("write control");
|
||||
std::fs::write(
|
||||
tree.join("debian/files"),
|
||||
"hello_1.0-1+b1_all.deb devel optional\n",
|
||||
)
|
||||
.expect("write files");
|
||||
std::fs::write(base.path().join("hello_1.0-1+b1_all.deb"), "deb payload")
|
||||
.expect("write deb");
|
||||
|
||||
let ctx = Arc::new(
|
||||
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
||||
);
|
||||
let opts = BinaryMetadataOptions {
|
||||
profiles: Vec::new(),
|
||||
vendor: "debian".to_string(),
|
||||
exported_env: BTreeMap::new(),
|
||||
build_arch: "amd64".to_string(),
|
||||
host_arch: "amd64".to_string(),
|
||||
};
|
||||
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
||||
.expect_err("binary-only build with an unparseable changelog must fail");
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("unbalanced parenthesis"), "{err}");
|
||||
assert!(err.contains("1.0-1 unstable"), "{err}");
|
||||
}
|
||||
|
||||
/// An unreadable `debian/files` (e.g. permissions) must fail the
|
||||
/// metadata generation with an error naming the read failure, instead of
|
||||
/// being silently treated as an empty registry and reported as "no
|
||||
/// binary artifacts found". A *missing* file stays tolerated (first
|
||||
/// build in a fresh tree); the distinction matters.
|
||||
#[test]
|
||||
fn unreadable_debian_files_errors_instead_of_empty_registry() {
|
||||
if crate::utils::root::is_root().unwrap_or(false) {
|
||||
// Root can read files regardless of permissions.
|
||||
return;
|
||||
}
|
||||
let changelog = "\
|
||||
hello (1.0-1) unstable; urgency=medium
|
||||
|
||||
* Regular build.
|
||||
|
||||
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
";
|
||||
let control = "\
|
||||
Source: hello
|
||||
Section: devel
|
||||
Priority: optional
|
||||
Maintainer: A B <a@b.c>
|
||||
|
||||
Package: hello
|
||||
Architecture: all
|
||||
Description: test package
|
||||
";
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = base.path().join("hello-1.0");
|
||||
std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree");
|
||||
std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog");
|
||||
std::fs::write(tree.join("debian/control"), control).expect("write control");
|
||||
let files_path = tree.join("debian/files");
|
||||
std::fs::write(&files_path, "hello_1.0-1_all.deb devel optional\n").expect("write files");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&files_path, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("chmod files");
|
||||
}
|
||||
|
||||
let ctx = Arc::new(
|
||||
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
||||
);
|
||||
let opts = BinaryMetadataOptions {
|
||||
profiles: Vec::new(),
|
||||
vendor: "debian".to_string(),
|
||||
exported_env: BTreeMap::new(),
|
||||
build_arch: "amd64".to_string(),
|
||||
host_arch: "amd64".to_string(),
|
||||
};
|
||||
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
||||
.expect_err("unreadable debian/files must fail with a read error");
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("cannot read"), "{err}");
|
||||
assert!(err.contains("debian/files"), "{err}");
|
||||
#[cfg(unix)]
|
||||
assert!(err.contains("Permission denied"), "{err}");
|
||||
}
|
||||
|
||||
/// A binary-only (binNMU) build references the previous source version
|
||||
/// (`Source: pkg (prev)`, `Binary-Only-Changes`) but must NOT
|
||||
/// redistribute any source file: like dpkg-genchanges/genbuildinfo, the
|
||||
/// previous `.dsc` and its tarballs stay out of both documents even when
|
||||
/// they exist next to the artifacts.
|
||||
#[test]
|
||||
fn binary_only_metadata_references_previous_source_without_redistributing_it() {
|
||||
let changelog = "\
|
||||
hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only rebuild.
|
||||
|
||||
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
|
||||
hello (1.0-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- A B <a@b.c> Sun, 31 Dec 2023 00:00:00 +0000
|
||||
";
|
||||
let control = "\
|
||||
Source: hello
|
||||
Section: devel
|
||||
Priority: optional
|
||||
Maintainer: A B <a@b.c>
|
||||
|
||||
Package: hello
|
||||
Architecture: all
|
||||
Description: test package
|
||||
";
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = base.path().join("hello-1.0");
|
||||
std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree");
|
||||
std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog");
|
||||
std::fs::write(tree.join("debian/control"), control).expect("write control");
|
||||
std::fs::write(
|
||||
tree.join("debian/files"),
|
||||
"hello_1.0-1+b1_all.deb devel optional\n",
|
||||
)
|
||||
.expect("write files");
|
||||
std::fs::write(base.path().join("hello_1.0-1+b1_all.deb"), "deb payload")
|
||||
.expect("write deb");
|
||||
|
||||
// The trap: the previous source artifacts sit right next to the
|
||||
// binaries, as they would after a source build. dpkg does not
|
||||
// redistribute them for a binary-only upload, and neither must we.
|
||||
std::fs::write(
|
||||
base.path().join("hello_1.0-1.dsc"),
|
||||
"Format: 3.0 (quilt)\nSource: hello\nBinary: hello\nArchitecture: any\nVersion: \
|
||||
1.0-1\nMaintainer: A B <a@b.c>\nChecksums-Sha1:\n aaa111 12 \
|
||||
hello_1.0.orig.tar.xz\n",
|
||||
)
|
||||
.expect("write previous dsc");
|
||||
std::fs::write(base.path().join("hello_1.0.orig.tar.xz"), "tarball bytes")
|
||||
.expect("write previous tarball");
|
||||
|
||||
let ctx = Arc::new(
|
||||
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
||||
);
|
||||
let opts = BinaryMetadataOptions {
|
||||
profiles: Vec::new(),
|
||||
vendor: "debian".to_string(),
|
||||
exported_env: BTreeMap::new(),
|
||||
build_arch: "amd64".to_string(),
|
||||
host_arch: "amd64".to_string(),
|
||||
};
|
||||
let (buildinfo_path, changes_path) =
|
||||
generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
||||
.expect("binNMU metadata generation must succeed");
|
||||
|
||||
let changes = std::fs::read_to_string(&changes_path).expect("read changes");
|
||||
let buildinfo = std::fs::read_to_string(&buildinfo_path).expect("read buildinfo");
|
||||
|
||||
// The previous version is referenced textually.
|
||||
assert!(
|
||||
changes.contains("Source: hello (1.0-1)"),
|
||||
"changes must reference the previous version: {changes}"
|
||||
);
|
||||
assert!(
|
||||
buildinfo.contains("Binary-Only-Changes"),
|
||||
"buildinfo must record the binary-only entry: {buildinfo}"
|
||||
);
|
||||
// ... but no source file is distributed, on either side.
|
||||
for (doc, text) in [("changes", &changes), ("buildinfo", &buildinfo)] {
|
||||
assert!(
|
||||
!text.contains("hello_1.0-1.dsc"),
|
||||
"{doc} must not redistribute the previous .dsc: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("hello_1.0.orig.tar.xz"),
|
||||
"{doc} must not redistribute the previous tarball: {text}"
|
||||
);
|
||||
}
|
||||
// The distributed set is exactly the binary artifacts + buildinfo.
|
||||
assert!(
|
||||
changes.contains("hello_1.0-1+b1_all.deb") && changes.contains(".buildinfo"),
|
||||
"changes must distribute the deb and the buildinfo: {changes}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Native `.buildinfo` generation (Format 1.0), mirroring
|
||||
//! `dpkg-genbuildinfo`: artifact checksums, a snapshot of installed build
|
||||
//! dependencies and the sanitized build environment.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::{Paragraph, parse_paragraphs, write_paragraph};
|
||||
|
||||
/// One installed package relevant for dependency resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstalledPkg {
|
||||
version: String,
|
||||
arch: String,
|
||||
}
|
||||
|
||||
/// A snapshot of the dpkg status database, restricted to what the
|
||||
/// `Installed-Build-Depends` computation needs.
|
||||
#[derive(Debug, Default)]
|
||||
struct StatusDb {
|
||||
/// Installed packages grouped by name.
|
||||
pkgs: HashMap<String, Vec<InstalledPkg>>,
|
||||
/// Raw `Depends`/`Pre-Depends` strings keyed by `package:arch`.
|
||||
depends: HashMap<String, Vec<String>>,
|
||||
/// Names of installed essential packages.
|
||||
essential: Vec<String>,
|
||||
}
|
||||
|
||||
impl StatusDb {
|
||||
fn from_str(content: &str) -> StatusDb {
|
||||
let mut db = StatusDb::default();
|
||||
for para in parse_paragraphs(content) {
|
||||
// Only fully installed packages participate.
|
||||
let status = para.get("Status").unwrap_or("");
|
||||
if !status.split_whitespace().eq(["install", "ok", "installed"]) {
|
||||
// Accept any status containing 'ok installed' like dpkg's
|
||||
// `/^Status: .*ok installed$/` check.
|
||||
if !status.contains("ok installed") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(package) = para.get("Package") else {
|
||||
continue;
|
||||
};
|
||||
let arch = para.get("Architecture").unwrap_or("").to_string();
|
||||
if let (Some(version), false) = (para.get("Version"), arch.is_empty()) {
|
||||
db.pkgs
|
||||
.entry(package.to_string())
|
||||
.or_default()
|
||||
.push(InstalledPkg {
|
||||
version: version.to_string(),
|
||||
arch: arch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if para
|
||||
.get("Essential")
|
||||
.map(|v| v.eq_ignore_ascii_case("yes"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
db.essential.push(package.to_string());
|
||||
}
|
||||
|
||||
let qualified = format!("{}:{}", package, arch);
|
||||
for field in ["Pre-Depends", "Depends"] {
|
||||
if let Some(value) = para.get(field) {
|
||||
db.depends
|
||||
.entry(qualified.clone())
|
||||
.or_default()
|
||||
.push(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
db
|
||||
}
|
||||
|
||||
/// Find an installed package by name, optionally restricted to an exact
|
||||
/// architecture.
|
||||
fn find(&self, name: &str, arch: Option<&str>) -> Option<&InstalledPkg> {
|
||||
self.pkgs.get(name)?.iter().find(|p| match arch {
|
||||
Some(a) => p.arch == a,
|
||||
None => true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract candidate package names from a dependency field value.
|
||||
///
|
||||
/// Every alternative of every clause is returned (dpkg cannot know which one
|
||||
/// was actually used), with version constraints and build-profile
|
||||
/// restrictions stripped but `:arch` qualifiers preserved.
|
||||
fn dep_candidates(dep_value: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for clause in dep_value.split(',') {
|
||||
for alternative in clause.split('|') {
|
||||
// Drop build-profile restrictions `[...]` (they may follow any
|
||||
// individual alternative).
|
||||
let alternative = match alternative.find('[') {
|
||||
Some(i) => &alternative[..i],
|
||||
None => alternative,
|
||||
};
|
||||
// Drop version constraints `(>= 1.0)`.
|
||||
let name = match alternative.find('(') {
|
||||
Some(i) => &alternative[..i],
|
||||
None => alternative,
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the `Installed-Build-Depends` value: the transitive closure of
|
||||
/// installed packages reachable from the essential set and the active
|
||||
/// `Build-Depends*` fields, formatted as `name (= version)` pairs.
|
||||
///
|
||||
/// Mirrors `collect_installed_builddeps()` in `dpkg-genbuildinfo`, including
|
||||
/// the foreign-architecture qualification of dependencies.
|
||||
pub fn installed_build_depends(
|
||||
status_path: &Path,
|
||||
build_depends_fields: &[&str],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(status_path)
|
||||
.map_err(|e| format!("cannot read status file '{}': {}", status_path.display(), e))?;
|
||||
installed_build_depends_from_content(&content, build_depends_fields).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
/// Compute the `Installed-Build-Depends` value from the textual content of a
|
||||
/// dpkg status database (used when the database lives in another context,
|
||||
/// e.g. inside a chroot).
|
||||
pub fn installed_build_depends_from_content(
|
||||
status_content: &str,
|
||||
build_depends_fields: &[&str],
|
||||
) -> Result<String, String> {
|
||||
let db = StatusDb::from_str(status_content);
|
||||
|
||||
let mut work: VecDeque<String> = VecDeque::new();
|
||||
for name in &db.essential {
|
||||
work.push_back(name.clone());
|
||||
}
|
||||
for field in build_depends_fields {
|
||||
if !field.trim().is_empty() {
|
||||
for candidate in dep_candidates(field) {
|
||||
work.push_back(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut entries: Vec<(String, String)> = Vec::new();
|
||||
|
||||
while let Some(entry) = work.pop_front() {
|
||||
if !seen.insert(entry.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name, qual) = match entry.split_once(':') {
|
||||
Some((n, q)) => (n.to_string(), Some(q.to_string())),
|
||||
None => (entry.clone(), None),
|
||||
};
|
||||
|
||||
// `all`, `any` and `native` qualifiers do not pin an architecture.
|
||||
let required_arch = qual.filter(|q| !matches!(q.as_str(), "all" | "any" | "native"));
|
||||
|
||||
let Some(installed) = db.find(&name, required_arch.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let qualified_name = if required_arch.is_none() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}:{}", name, installed.arch)
|
||||
};
|
||||
entries.push((
|
||||
qualified_name.clone(),
|
||||
format!("{} (= {})", qualified_name, installed.version),
|
||||
));
|
||||
|
||||
// Enqueue dependencies of the visited package.
|
||||
let dep_key = format!("{}:{}", name, installed.arch);
|
||||
let foreign = required_arch.is_some();
|
||||
for raw in db.depends.get(&dep_key).into_iter().flatten() {
|
||||
for mut candidate in dep_candidates(raw) {
|
||||
if foreign && !candidate.contains(':') {
|
||||
// Dependencies of foreign packages are foreign too (or
|
||||
// Arch:all); qualify them when such an install exists.
|
||||
let base = candidate.as_str();
|
||||
let has_foreign_arch = db
|
||||
.pkgs
|
||||
.get(base)
|
||||
.map(|v| v.iter().any(|p| p.arch == installed.arch))
|
||||
.unwrap_or(false);
|
||||
if has_foreign_arch {
|
||||
candidate = format!("{}:{}", candidate, installed.arch);
|
||||
}
|
||||
}
|
||||
work.push_back(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
entries.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
// With no reachable entries, return an empty value so `render_buildinfo`
|
||||
// omits the field entirely; a leading `\n` alone would render a
|
||||
// malformed `Installed-Build-Depends:` with only a blank continuation.
|
||||
if entries.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
let mut out = String::from("\n");
|
||||
out.push_str(
|
||||
&entries
|
||||
.into_iter()
|
||||
.map(|(_, formatted)| formatted)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n"),
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everything needed to render a `.buildinfo` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfoInput {
|
||||
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
|
||||
pub source: String,
|
||||
/// Sorted binary package names included in the build (may be empty).
|
||||
pub binaries: Vec<String>,
|
||||
/// `Architecture` field value (e.g. `source`, `amd64`, `all amd64`).
|
||||
pub architecture: String,
|
||||
/// Full binary version.
|
||||
pub version: String,
|
||||
/// `Binary-Only-Changes` payload for binNMU builds.
|
||||
pub binary_only_changes: Option<String>,
|
||||
/// `Build-Origin` (vendor name).
|
||||
pub build_origin: String,
|
||||
/// `Build-Architecture` (machine the build ran on).
|
||||
pub build_architecture: String,
|
||||
/// `Build-Date`, RFC2822.
|
||||
pub build_date: String,
|
||||
/// Computed artifact checksums.
|
||||
pub checksums: FileChecksums,
|
||||
/// Rendered `Installed-Build-Depends` value.
|
||||
pub installed_build_depends: String,
|
||||
/// Rendered `Environment` value.
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
/// Wrap an overly long single-line field value (> 980 characters) over
|
||||
/// multiple lines at spaces, like dpkg does for `Binary`.
|
||||
fn wrap_long(value: &str) -> String {
|
||||
if value.len() <= 980 {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
let mut line_len = 0usize;
|
||||
for (i, word) in value.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
if line_len + 1 + word.len() > 980 {
|
||||
out.push('\n');
|
||||
line_len = 0;
|
||||
} else {
|
||||
out.push(' ');
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len += word.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the `.buildinfo` document (without trailing signature), with fields
|
||||
/// in dpkg's canonical order for `CTRL_FILE_BUILDINFO`.
|
||||
pub fn render_buildinfo(input: &BuildInfoInput) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Format", "1.0");
|
||||
p.set("Source", &input.source);
|
||||
if !input.binaries.is_empty() {
|
||||
let joined = input.binaries.join(" ");
|
||||
p.set("Binary", &wrap_long(&joined));
|
||||
}
|
||||
p.set("Architecture", &input.architecture);
|
||||
p.set("Version", &input.version);
|
||||
if let Some(boc) = &input.binary_only_changes {
|
||||
p.set("Binary-Only-Changes", boc);
|
||||
}
|
||||
if !input.checksums.is_empty() {
|
||||
p.set("Checksums-Md5", &input.checksums.field_md5());
|
||||
p.set("Checksums-Sha1", &input.checksums.field_sha1());
|
||||
p.set("Checksums-Sha256", &input.checksums.field_sha256());
|
||||
// Only-if-populated: entries merged from a `.dsc` carry no SHA-512
|
||||
// (dpkg only records sha1/sha256 there), and an incomplete checksum
|
||||
// list must never be rendered.
|
||||
if let Some(sha512) = input.checksums.field_sha512() {
|
||||
p.set("Checksums-Sha512", &sha512);
|
||||
}
|
||||
}
|
||||
p.set("Build-Origin", &input.build_origin);
|
||||
p.set("Build-Architecture", &input.build_architecture);
|
||||
p.set("Build-Date", &input.build_date);
|
||||
if !input.installed_build_depends.is_empty() {
|
||||
p.set("Installed-Build-Depends", &input.installed_build_depends);
|
||||
}
|
||||
if !input.environment.is_empty() {
|
||||
p.set("Environment", &input.environment);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize and atomically write a `.buildinfo` file.
|
||||
pub fn save_buildinfo(
|
||||
path: &Path,
|
||||
paragraph: &Paragraph,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
std::fs::write(&tmp, write_paragraph(paragraph))
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dep_candidate_extraction() {
|
||||
assert_eq!(
|
||||
dep_candidates("debhelper-compat (= 13), pkg:any [!profile] | alt (>= 2)"),
|
||||
vec![
|
||||
"debhelper-compat".to_string(),
|
||||
"pkg:any".to_string(),
|
||||
"alt".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(dep_candidates(""), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_over_status_db() {
|
||||
let status = "\
|
||||
Package: build-essential
|
||||
Status: install ok installed
|
||||
Version: 12.10
|
||||
Architecture: amd64
|
||||
Essential: no
|
||||
Depends: gcc, make
|
||||
|
||||
Package: gcc
|
||||
Status: install ok installed
|
||||
Version: 13.2
|
||||
Architecture: amd64
|
||||
Depends: cpp-13
|
||||
|
||||
Package: cpp-13
|
||||
Status: install ok installed
|
||||
Version: 13.2
|
||||
Architecture: amd64
|
||||
|
||||
Package: make
|
||||
Status: install ok installed
|
||||
Version: 4.3
|
||||
Architecture: amd64
|
||||
|
||||
Package: not-installed
|
||||
Status: deinstall ok config-files
|
||||
Version: 9.9
|
||||
Architecture: amd64
|
||||
";
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("status");
|
||||
std::fs::write(&path, status).unwrap();
|
||||
|
||||
let ibd = installed_build_depends(&path, &["build-essential"]).unwrap();
|
||||
let names: Vec<&str> = ibd
|
||||
.trim_start()
|
||||
.lines()
|
||||
.map(|l| l.split(' ').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["build-essential", "cpp-13", "gcc", "make"]);
|
||||
assert!(ibd.contains("gcc (= 13.2)"));
|
||||
assert!(ibd.contains("cpp-13 (= 13.2)"));
|
||||
assert!(!ibd.contains("not-installed"));
|
||||
}
|
||||
|
||||
/// With no installed entries reachable (empty status database), the
|
||||
/// computed value must be EMPTY so `render_buildinfo` omits the
|
||||
/// `Installed-Build-Depends` field entirely, instead of emitting a
|
||||
/// malformed field with only a blank continuation line.
|
||||
#[test]
|
||||
fn installed_build_depends_without_entries_is_empty_and_omitted() {
|
||||
let ibd = installed_build_depends_from_content("", &["libc6"]).unwrap();
|
||||
assert_eq!(
|
||||
ibd, "",
|
||||
"zero entries must yield an empty value, not \"\\n\""
|
||||
);
|
||||
|
||||
let input = BuildInfoInput {
|
||||
source: "hello".to_string(),
|
||||
binaries: vec!["hello".to_string()],
|
||||
architecture: "amd64".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
binary_only_changes: None,
|
||||
build_origin: "debian".to_string(),
|
||||
build_architecture: "amd64".to_string(),
|
||||
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
|
||||
checksums: FileChecksums::new(),
|
||||
installed_build_depends: ibd,
|
||||
environment: String::new(),
|
||||
};
|
||||
let p = render_buildinfo(&input);
|
||||
assert!(
|
||||
p.get("Installed-Build-Depends").is_none(),
|
||||
"empty value must omit the field entirely"
|
||||
);
|
||||
}
|
||||
|
||||
/// With installed entries, the value keeps the dpkg-style leading `\n`
|
||||
/// (pre-wrapped multiline field) and the field is rendered.
|
||||
#[test]
|
||||
fn installed_build_depends_with_entries_renders_field() {
|
||||
let status = "\
|
||||
Package: gcc
|
||||
Status: install ok installed
|
||||
Version: 13.2
|
||||
Architecture: amd64
|
||||
";
|
||||
let ibd = installed_build_depends_from_content(status, &["gcc"]).unwrap();
|
||||
assert_eq!(ibd, "\ngcc (= 13.2)");
|
||||
|
||||
let input = BuildInfoInput {
|
||||
source: "hello".to_string(),
|
||||
binaries: vec!["hello".to_string()],
|
||||
architecture: "amd64".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
binary_only_changes: None,
|
||||
build_origin: "debian".to_string(),
|
||||
build_architecture: "amd64".to_string(),
|
||||
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
|
||||
checksums: FileChecksums::new(),
|
||||
installed_build_depends: ibd,
|
||||
environment: String::new(),
|
||||
};
|
||||
let p = render_buildinfo(&input);
|
||||
assert_eq!(p.get("Installed-Build-Depends"), Some("\ngcc (= 13.2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_binary_field() {
|
||||
assert_eq!(wrap_long("abc"), "abc");
|
||||
let long = (0..500)
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let wrapped = wrap_long(&long);
|
||||
assert!(wrapped.contains('\n'));
|
||||
for line in wrapped.lines() {
|
||||
assert!(line.len() <= 980);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_minimal_source_buildinfo() {
|
||||
let input = BuildInfoInput {
|
||||
source: "hello".to_string(),
|
||||
binaries: vec![],
|
||||
architecture: "source".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
binary_only_changes: None,
|
||||
build_origin: "Ubuntu".to_string(),
|
||||
build_architecture: "amd64".to_string(),
|
||||
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
|
||||
checksums: FileChecksums::new(),
|
||||
installed_build_depends: "gcc (= 13)".to_string(),
|
||||
environment: "DEB_BUILD_OPTIONS=\"parallel=8\"".to_string(),
|
||||
};
|
||||
let p = render_buildinfo(&input);
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Build-Origin",
|
||||
"Build-Architecture",
|
||||
"Build-Date",
|
||||
"Installed-Build-Depends",
|
||||
"Environment"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.get("Format"), Some("1.0"));
|
||||
}
|
||||
|
||||
/// `Checksums-Sha512` is emitted (after `Checksums-Sha256`) only when
|
||||
/// every distributed file has a SHA-512 digest; entries merged without
|
||||
/// one (e.g. taken from a `.dsc`) omit the field entirely instead of
|
||||
/// rendering an incomplete checksum list.
|
||||
#[test]
|
||||
fn checksums_sha512_emitted_only_when_populated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let artifact = dir.path().join("hello_1.0_all.deb");
|
||||
std::fs::write(&artifact, b"deb payload").unwrap();
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file(&artifact).unwrap();
|
||||
|
||||
let mk_input = |checksums: FileChecksums| BuildInfoInput {
|
||||
source: "hello".to_string(),
|
||||
binaries: vec![],
|
||||
architecture: "all".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
binary_only_changes: None,
|
||||
build_origin: "debian".to_string(),
|
||||
build_architecture: "amd64".to_string(),
|
||||
build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(),
|
||||
checksums,
|
||||
installed_build_depends: String::new(),
|
||||
environment: String::new(),
|
||||
};
|
||||
|
||||
// All digests computed: the field is present and parses back.
|
||||
let p = render_buildinfo(&mk_input(checksums.clone()));
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Checksums-Md5",
|
||||
"Checksums-Sha1",
|
||||
"Checksums-Sha256",
|
||||
"Checksums-Sha512",
|
||||
"Build-Origin",
|
||||
"Build-Architecture",
|
||||
"Build-Date",
|
||||
]
|
||||
);
|
||||
let sha512_field = p.get("Checksums-Sha512").unwrap();
|
||||
let parsed =
|
||||
FileChecksums::parse_field(crate::debian::ChecksumKind::Sha512, sha512_field).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "hello_1.0_all.deb");
|
||||
assert_eq!(
|
||||
parsed[0].1.sha512,
|
||||
checksums.get("hello_1.0_all.deb").unwrap().sha512
|
||||
);
|
||||
|
||||
// An entry without SHA-512 (as merged from a `.dsc`) suppresses the
|
||||
// field; the other Checksums fields keep listing every file.
|
||||
checksums.insert_entry(
|
||||
"hello_1.0.orig.tar.xz",
|
||||
crate::debian::ChecksumEntry {
|
||||
size: 3,
|
||||
md5: checksums.get("hello_1.0_all.deb").unwrap().md5.clone(),
|
||||
sha1: String::new(),
|
||||
sha256: String::new(),
|
||||
sha512: String::new(),
|
||||
},
|
||||
);
|
||||
let p = render_buildinfo(&mk_input(checksums));
|
||||
assert!(p.get("Checksums-Sha512").is_none());
|
||||
let sha256_lines = p.get("Checksums-Sha256").unwrap().lines();
|
||||
assert_eq!(sha256_lines.filter(|l| !l.is_empty()).count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Debian build types (`dpkg-buildpackage -b/-B/-A/-S/-g/-G/--build=...`)
|
||||
//! and their mapping to `debian/rules` targets.
|
||||
|
||||
/// Build type bit flags, mirroring `Dpkg::BuildTypes`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuildType(u8);
|
||||
|
||||
/// Source build component (`-S`, `--build=source`).
|
||||
pub const SOURCE: BuildType = BuildType(0x1);
|
||||
/// Arch-dependent binary build component (`-B`, `--build=any`).
|
||||
pub const ARCH_DEP: BuildType = BuildType(0x2);
|
||||
/// Arch-independent binary build component (`-A`, `--build=all`).
|
||||
pub const ARCH_INDEP: BuildType = BuildType(0x4);
|
||||
|
||||
/// Any binary component.
|
||||
pub const BINARY: BuildType = BuildType(ARCH_DEP.0 | ARCH_INDEP.0);
|
||||
/// Normal full build: source + binaries (`-F`, default).
|
||||
pub const FULL: BuildType = BuildType(SOURCE.0 | BINARY.0);
|
||||
/// Source + arch-dependent (`-G`).
|
||||
pub const SOURCE_ARCH_DEP: BuildType = BuildType(SOURCE.0 | ARCH_DEP.0);
|
||||
/// Source + arch-indep (`-g`).
|
||||
pub const SOURCE_ARCH_INDEP: BuildType = BuildType(SOURCE.0 | ARCH_INDEP.0);
|
||||
|
||||
impl BuildType {
|
||||
/// Construct from raw bits.
|
||||
pub const fn from_bits(bits: u8) -> Self {
|
||||
BuildType(bits)
|
||||
}
|
||||
|
||||
/// Raw bits.
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// True if any of `other`'s components are set.
|
||||
pub fn has_any(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
|
||||
/// True if all of `other`'s components are set.
|
||||
pub fn has_all(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
|
||||
/// True if none of `other`'s components are set.
|
||||
pub fn has_none(self, other: BuildType) -> bool {
|
||||
self.0 & other.0 == 0
|
||||
}
|
||||
|
||||
/// Parse a comma-separated `--build=<type>[,...]` option value.
|
||||
///
|
||||
/// Valid components: `full`, `source`, `binary`, `any`, `all`.
|
||||
pub fn from_options(value: &str) -> Result<BuildType, String> {
|
||||
let mut result = BuildType(0);
|
||||
for part in value.split(',') {
|
||||
match part.trim() {
|
||||
"full" => result = FULL,
|
||||
"source" => result = BuildType(result.0 | SOURCE.0),
|
||||
"binary" => result = BuildType(result.0 | BINARY.0),
|
||||
"any" => result = BuildType(result.0 | ARCH_DEP.0),
|
||||
"all" => result = BuildType(result.0 | ARCH_INDEP.0),
|
||||
other => return Err(format!("unknown build type component '{}'", other)),
|
||||
}
|
||||
}
|
||||
if result.0 == 0 {
|
||||
return Err("empty build type".to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Canonical comma-separated representation (as passed to
|
||||
/// `dpkg-genchanges --build=` / `dpkg-genbuildinfo --build=`).
|
||||
pub fn to_options(self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if self.has_any(SOURCE) {
|
||||
parts.push("source");
|
||||
}
|
||||
if self.has_all(BINARY) {
|
||||
parts.push("binary");
|
||||
} else {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
parts.push("any");
|
||||
}
|
||||
if self.has_any(ARCH_INDEP) {
|
||||
parts.push("all");
|
||||
}
|
||||
}
|
||||
parts.join(",")
|
||||
}
|
||||
|
||||
/// The `debian/rules` build target for this type:
|
||||
/// `build`, `build-arch` or `build-indep`.
|
||||
pub fn build_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"build"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"build-arch"
|
||||
} else {
|
||||
"build-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The `debian/rules` binary target for this type:
|
||||
/// `binary`, `binary-arch` or `binary-indep`.
|
||||
pub fn binary_target(self) -> &'static str {
|
||||
if self.has_all(BINARY) || self.has_none(BINARY) {
|
||||
"binary"
|
||||
} else if self.has_any(ARCH_DEP) {
|
||||
"binary-arch"
|
||||
} else {
|
||||
"binary-indep"
|
||||
}
|
||||
}
|
||||
|
||||
/// The architecture suffix used in artifact file names:
|
||||
/// host arch, `all` or `source`.
|
||||
pub fn arch_suffix(self, host_arch: &str) -> &str {
|
||||
if self.has_any(ARCH_DEP) {
|
||||
host_arch
|
||||
} else if self.has_any(ARCH_INDEP) {
|
||||
"all"
|
||||
} else {
|
||||
"source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_options() {
|
||||
assert_eq!(BuildType::from_options("full").unwrap(), FULL);
|
||||
assert_eq!(BuildType::from_options("source").unwrap(), SOURCE);
|
||||
assert_eq!(
|
||||
BuildType::from_options("source,any").unwrap(),
|
||||
SOURCE_ARCH_DEP
|
||||
);
|
||||
assert_eq!(BuildType::from_options("any,all").unwrap(), BINARY);
|
||||
assert!(BuildType::from_options("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_options() {
|
||||
for t in [FULL, SOURCE, BINARY, SOURCE_ARCH_DEP, SOURCE_ARCH_INDEP] {
|
||||
assert_eq!(BuildType::from_options(&t.to_options()).unwrap(), t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets() {
|
||||
assert_eq!(FULL.build_target(), "build");
|
||||
assert_eq!(FULL.binary_target(), "binary");
|
||||
assert_eq!(ARCH_DEP.build_target(), "build-arch");
|
||||
assert_eq!(ARCH_DEP.binary_target(), "binary-arch");
|
||||
assert_eq!(ARCH_INDEP.build_target(), "build-indep");
|
||||
assert_eq!(ARCH_INDEP.binary_target(), "binary-indep");
|
||||
assert_eq!(SOURCE.arch_suffix("amd64"), "source");
|
||||
assert_eq!(ARCH_DEP.arch_suffix("amd64"), "amd64");
|
||||
assert_eq!(ARCH_INDEP.arch_suffix("amd64"), "all");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Native `.changes` generation (Format 1.8), mirroring `dpkg-genchanges`
|
||||
//! for the artifact aggregation part: checksums, per-file sections and
|
||||
//! priorities, changelog-derived fields.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::OrigSourceMode;
|
||||
use crate::debian::changelog::ChangelogEntry;
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::{Paragraph, write_paragraph};
|
||||
use crate::debian::files::FilesList;
|
||||
|
||||
/// Compression suffixes dpkg recognizes on source tarballs.
|
||||
const TARBALL_COMPRESSIONS: &[&str] = &[".gz", ".bz2", ".xz", ".lzma", ".zst"];
|
||||
|
||||
/// Whether this `.dsc`-listed file is an upstream orig tarball
|
||||
/// (`*.orig.tar.<ext>` or a component tarball `*.orig-<c>.tar.<ext>`),
|
||||
/// mirroring dpkg-genchanges' strip pattern `\.orig(-.+)?\.tar\.$ext`.
|
||||
pub fn is_orig_tarball(name: &str) -> bool {
|
||||
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
||||
name.strip_suffix(ext)
|
||||
.and_then(|s| s.strip_suffix(".tar"))
|
||||
.is_some_and(|stem| stem.ends_with(".orig") || stem.contains(".orig-"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this `.dsc`-listed file is the Debian part of the source package
|
||||
/// (`*.debian.tar.<ext>` for the 3.0 formats, `*.diff.<ext>` for 1.0).
|
||||
pub fn is_debian_tarball_or_diff(name: &str) -> bool {
|
||||
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
||||
name.ends_with(&format!(".debian.tar{ext}")) || name.ends_with(&format!(".diff{ext}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the upload redistributes the upstream tarballs, mirroring the
|
||||
/// dpkg-genchanges source styles: `Always`/`Never` are the forced
|
||||
/// `-sa`/`-sd`, while `Auto` is the default `-si` — include them only when
|
||||
/// there is no previous changelog entry (first upload) or the source name or
|
||||
/// upstream version changed since it. Like dpkg, the comparison uses the
|
||||
/// epoch-less upstream version: a plain revision bump reuses the tarball
|
||||
/// already in the archive.
|
||||
pub fn include_orig_tarball(
|
||||
mode: OrigSourceMode,
|
||||
current: &ChangelogEntry,
|
||||
previous: Option<&ChangelogEntry>,
|
||||
) -> bool {
|
||||
match mode {
|
||||
OrigSourceMode::Always => true,
|
||||
OrigSourceMode::Never => false,
|
||||
OrigSourceMode::Auto => match previous {
|
||||
None => true,
|
||||
Some(prev) => {
|
||||
prev.source != current.source || prev.version.upstream != current.version.upstream
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to render a `.changes` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangesInput {
|
||||
/// `Date` field: the changelog entry date (verbatim trailer date).
|
||||
pub date: String,
|
||||
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
|
||||
pub source: String,
|
||||
/// Sorted binary package names with artifacts (empty for source-only).
|
||||
pub binaries: Vec<String>,
|
||||
/// Whether the changelog entry is a binary-only (binNMU) upload
|
||||
/// (`Binary-Only: yes` field).
|
||||
pub binary_only: bool,
|
||||
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
|
||||
pub built_for_profiles: Vec<String>,
|
||||
/// `Architecture` field value in encounter order (e.g. `source`,
|
||||
/// `amd64 all`, ...).
|
||||
pub architecture: String,
|
||||
/// Full version.
|
||||
pub version: String,
|
||||
/// Distribution(s).
|
||||
pub distribution: String,
|
||||
/// Urgency.
|
||||
pub urgency: String,
|
||||
/// `Maintainer` from the control source stanza.
|
||||
pub maintainer: Option<String>,
|
||||
/// `Changed-By` from the changelog maintainer.
|
||||
pub changed_by: Option<String>,
|
||||
/// Formatted per-package description lines (empty for source-only).
|
||||
pub descriptions: Vec<String>,
|
||||
/// Bug numbers collected from the changelog (`Closes` field), if any.
|
||||
pub closes: Option<String>,
|
||||
/// Rendered `Changes` field value from the changelog entry.
|
||||
pub changes_field: String,
|
||||
/// Computed artifact checksums (dsc, tarballs, debs, buildinfo).
|
||||
pub checksums: FileChecksums,
|
||||
/// Registry providing section/priority per file.
|
||||
pub files_list: FilesList,
|
||||
}
|
||||
|
||||
/// Wrap an overly long single-line field value (> 980 characters) over
|
||||
/// multiple lines at spaces, like dpkg does for `Binary`.
|
||||
fn wrap_long(value: &str) -> String {
|
||||
if value.len() <= 980 {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
let mut line_len = 0usize;
|
||||
for (i, word) in value.split(' ').enumerate() {
|
||||
if i > 0 {
|
||||
if line_len + 1 + word.len() > 980 {
|
||||
out.push('\n');
|
||||
line_len = 0;
|
||||
} else {
|
||||
out.push(' ');
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len += word.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format one `Description` line: `%-10s - %-.65s` plus a ` (type)` suffix
|
||||
/// for non-deb package types, matching `format_desc()` in dpkg-genchanges.
|
||||
pub fn format_description(package: &str, package_type: &str, summary: &str) -> String {
|
||||
let mut line = format!("{:<10} - {:.65}", package, summary);
|
||||
if package_type != "deb" && !package_type.is_empty() {
|
||||
line.push_str(&format!(" ({})", package_type));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// Render the `.changes` document (without signature), with fields in dpkg's
|
||||
/// canonical order for `CTRL_FILE_CHANGES`.
|
||||
///
|
||||
/// Note: the legacy `Files` field carries md5+size+section+priority+name,
|
||||
/// while `Checksums-Sha1`/`Checksums-Sha256` carry the stronger hashes;
|
||||
/// `Checksums-Md5` is deliberately omitted as redundant, exactly like
|
||||
/// dpkg-genchanges does.
|
||||
pub fn render_changes(input: &ChangesInput) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Format", "1.8");
|
||||
p.set("Date", &input.date);
|
||||
p.set("Source", &input.source);
|
||||
if !input.binaries.is_empty() {
|
||||
let joined = input.binaries.join(" ");
|
||||
p.set("Binary", &wrap_long(&joined));
|
||||
}
|
||||
if input.binary_only {
|
||||
p.set("Binary-Only", "yes");
|
||||
}
|
||||
if !input.built_for_profiles.is_empty() {
|
||||
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
|
||||
}
|
||||
p.set("Architecture", &input.architecture);
|
||||
p.set("Version", &input.version);
|
||||
p.set("Distribution", &input.distribution);
|
||||
p.set("Urgency", &input.urgency);
|
||||
if let Some(maintainer) = &input.maintainer {
|
||||
p.set("Maintainer", maintainer);
|
||||
}
|
||||
if let Some(changed_by) = &input.changed_by {
|
||||
p.set("Changed-By", changed_by);
|
||||
}
|
||||
if !input.descriptions.is_empty() {
|
||||
let mut sorted = input.descriptions.clone();
|
||||
sorted.sort();
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
p.set("Description", &format!("\n{}", sorted.join("\n")));
|
||||
}
|
||||
if let Some(closes) = &input.closes {
|
||||
p.set("Closes", closes);
|
||||
}
|
||||
p.set("Changes", &input.changes_field);
|
||||
|
||||
if !input.checksums.is_empty() {
|
||||
p.set("Checksums-Sha1", &input.checksums.field_sha1());
|
||||
p.set("Checksums-Sha256", &input.checksums.field_sha256());
|
||||
|
||||
// Legacy Files field: md5 size section priority filename
|
||||
let mut files = String::new();
|
||||
for (key, entry) in input.checksums.iter() {
|
||||
let (section, priority) = input
|
||||
.files_list
|
||||
.get(key)
|
||||
.map(|f| (f.section.as_str(), f.priority.as_str()))
|
||||
.unwrap_or(("-", "-"));
|
||||
files.push('\n');
|
||||
files.push_str(&entry.md5);
|
||||
files.push(' ');
|
||||
files.push_str(&entry.size.to_string());
|
||||
files.push(' ');
|
||||
files.push_str(section);
|
||||
files.push(' ');
|
||||
files.push_str(priority);
|
||||
files.push(' ');
|
||||
files.push_str(key);
|
||||
}
|
||||
p.set("Files", &files);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Serialize and atomically write a `.changes` file.
|
||||
pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
std::fs::write(&tmp, write_paragraph(paragraph))
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orig_tarball_detection() {
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.gz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.xz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.zst"));
|
||||
assert!(is_orig_tarball("pkg_1.0~rc1.orig.tar.bz2"));
|
||||
// Component tarballs.
|
||||
assert!(is_orig_tarball("pkg_1.0.orig-docs.tar.xz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig-vendor.tar.gz"));
|
||||
// Not orig tarballs.
|
||||
assert!(!is_orig_tarball("pkg_1.0.debian.tar.xz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
||||
assert!(!is_orig_tarball("pkg_1.0.tar.xz")); // native tarball
|
||||
assert!(!is_orig_tarball("pkg_1.0.dsc"));
|
||||
assert!(!is_orig_tarball("pkg_1.0.orig.tar")); // no compression suffix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_tarball_detection() {
|
||||
assert!(is_debian_tarball_or_diff("pkg_1.0.debian.tar.xz"));
|
||||
assert!(is_debian_tarball_or_diff("pkg_1.0.diff.gz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.tar.xz"));
|
||||
}
|
||||
|
||||
/// Build a minimal changelog entry for one source/version pair.
|
||||
fn entry(src: &str, ver: &str) -> ChangelogEntry {
|
||||
crate::debian::changelog::parse_changelog_entries_from_str(
|
||||
&format!(
|
||||
"{src} ({ver}) unstable; urgency=medium\n\n * x\n\n \
|
||||
-- A B <a@b.c> Thu, 01 Jan 2026 00:00:00 +0000\n"
|
||||
),
|
||||
Some(1),
|
||||
)
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orig_inclusion_matrix() {
|
||||
let cur = entry("pkg", "1.4-2");
|
||||
let prev_same_upstream = entry("pkg", "1.4-1");
|
||||
let prev_new_upstream = entry("pkg", "2.0-1");
|
||||
let prev_renamed = entry("renamed", "1.4-1");
|
||||
|
||||
// -sa / -sd force the outcome.
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Always,
|
||||
&cur,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Never,
|
||||
&cur,
|
||||
Some(&prev_new_upstream)
|
||||
));
|
||||
|
||||
// -si: first upload includes; a revision bump excludes; a new
|
||||
// upstream version or a renamed source includes.
|
||||
assert!(include_orig_tarball(OrigSourceMode::Auto, &cur, None));
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_new_upstream)
|
||||
));
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_renamed)
|
||||
));
|
||||
|
||||
// The epoch is not part of the comparison, like dpkg's version().
|
||||
let cur_epoch = entry("pkg", "2:1.4-2");
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur_epoch,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_formatting() {
|
||||
assert_eq!(
|
||||
format_description("hello", "deb", "The classic greeting"),
|
||||
"hello - The classic greeting"
|
||||
);
|
||||
assert_eq!(
|
||||
format_description("verylongpkgname", "udeb", "short"),
|
||||
"verylongpkgname - short (udeb)"
|
||||
);
|
||||
let long_summary = "x".repeat(100);
|
||||
assert_eq!(
|
||||
format_description("p", "deb", &long_summary).len(),
|
||||
10 + 3 + 65
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_source_only_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dsc_path = dir.path().join("pkg_1.0.dsc");
|
||||
std::fs::write(&dsc_path, b"content\n").unwrap();
|
||||
|
||||
let mut checksums = FileChecksums::new();
|
||||
checksums.add_file(&dsc_path).unwrap();
|
||||
|
||||
let mut files_list = FilesList::new();
|
||||
files_list.add(crate::debian::FilesEntry::new(
|
||||
"pkg_1.0.dsc",
|
||||
"utils",
|
||||
"optional",
|
||||
));
|
||||
|
||||
let input = ChangesInput {
|
||||
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
|
||||
source: "pkg".to_string(),
|
||||
binaries: vec![],
|
||||
binary_only: false,
|
||||
built_for_profiles: vec![],
|
||||
architecture: "source".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
distribution: "unstable".to_string(),
|
||||
urgency: "medium".to_string(),
|
||||
maintainer: Some("A B <a@b.c>".to_string()),
|
||||
changed_by: Some("A B <a@b.c>".to_string()),
|
||||
descriptions: vec![],
|
||||
closes: None,
|
||||
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
|
||||
checksums,
|
||||
files_list,
|
||||
};
|
||||
|
||||
let p = render_changes(&input);
|
||||
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"Format",
|
||||
"Date",
|
||||
"Source",
|
||||
"Architecture",
|
||||
"Version",
|
||||
"Distribution",
|
||||
"Urgency",
|
||||
"Maintainer",
|
||||
"Changed-By",
|
||||
"Changes",
|
||||
"Checksums-Sha1",
|
||||
"Checksums-Sha256",
|
||||
"Files"
|
||||
]
|
||||
);
|
||||
// No Binary / Description / Checksums-Md5 for source-only uploads.
|
||||
assert!(p.get("Binary").is_none());
|
||||
assert!(p.get("Description").is_none());
|
||||
assert!(p.get("Checksums-Md5").is_none());
|
||||
|
||||
let files_value = p.get("Files").unwrap();
|
||||
assert_eq!(
|
||||
files_value,
|
||||
"\n<md5> 8 utils optional pkg_1.0.dsc"
|
||||
.replace("<md5>", files_value.split_whitespace().next().unwrap_or(""))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Build environment setup: `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`,
|
||||
//! architecture variables (native `dpkg-architecture` equivalent) and the
|
||||
//! sanitized environment recorded in `.buildinfo` files.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Number of parallel jobs to advertise in `DEB_BUILD_OPTIONS`.
|
||||
pub fn num_parallel() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Merge an inherited `DEB_BUILD_OPTIONS` value with options pkh computes
|
||||
/// itself.
|
||||
///
|
||||
/// `dpkg-buildpackage` prepends the environment's `DEB_BUILD_OPTIONS` to the
|
||||
/// options it derives (`parallel=N`, ...), so caller-set options such as
|
||||
/// `terse` or `nocheck` survive alongside pkh's own. The result is therefore
|
||||
/// the inherited options followed by `computed`, space-separated; each side is
|
||||
/// trimmed and its internal whitespace runs collapsed. An unset or blank
|
||||
/// inherited value yields just `computed`.
|
||||
pub fn merge_deb_build_options(inherited: Option<&str>, computed: &str) -> String {
|
||||
let computed = normalize_build_options(computed);
|
||||
match inherited.map(normalize_build_options) {
|
||||
Some(inherited) if !inherited.is_empty() => format!("{} {}", inherited, computed),
|
||||
_ => computed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim and collapse internal whitespace in a `DEB_BUILD_OPTIONS` fragment.
|
||||
fn normalize_build_options(options: &str) -> String {
|
||||
options.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Compute the environment variables exported before running any build step.
|
||||
///
|
||||
/// Mirrors dpkg behavior:
|
||||
/// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>),
|
||||
/// - `DEB_BUILD_OPTIONS`: any value inherited from the invoking environment
|
||||
/// (dpkg-buildpackage prepends it) followed by `parallel=N` (auto-detected
|
||||
/// job count),
|
||||
/// - `DEB_BUILD_PROFILES` when non-default profiles are requested.
|
||||
///
|
||||
/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any
|
||||
/// inherited session setting, plus `LANG`) so build tools emit deterministic,
|
||||
/// English diagnostics — required for reliable log classification and
|
||||
/// reproducible builds.
|
||||
pub fn build_env(
|
||||
source_date_epoch: i64,
|
||||
parallel: usize,
|
||||
build_profiles: &[String],
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("LANG".to_string(), "C".to_string());
|
||||
env.insert("LC_ALL".to_string(), "C".to_string());
|
||||
env.insert(
|
||||
"SOURCE_DATE_EPOCH".to_string(),
|
||||
source_date_epoch.to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
merge_deb_build_options(
|
||||
std::env::var("DEB_BUILD_OPTIONS").ok().as_deref(),
|
||||
&format!("parallel={}", parallel),
|
||||
),
|
||||
);
|
||||
if !build_profiles.is_empty() {
|
||||
env.insert("DEB_BUILD_PROFILES".to_string(), build_profiles.join(","));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Import the full architecture variable set, computed natively by
|
||||
/// [`crate::debian::arch`] (the equivalent of `dpkg-architecture -f
|
||||
/// [-a <host-arch>]`).
|
||||
///
|
||||
/// 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<BTreeMap<String, String>, String> {
|
||||
crate::debian::arch::arch_env(host_arch)
|
||||
}
|
||||
|
||||
/// Read the current vendor name from the active dpkg origins `default` file
|
||||
/// (`$DPKG_ORIGINS_DIR/default`, falling back to `/etc/dpkg/origins/default`;
|
||||
/// its `Vendor:` or `Origin:` field), defaulting to `"debian"`.
|
||||
pub fn current_vendor() -> String {
|
||||
let path = resolve_origins_default(
|
||||
std::env::var("DPKG_ORIGINS_DIR").ok().as_deref(),
|
||||
"/etc/dpkg/origins",
|
||||
);
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|content| vendor_from_origins_content(&content))
|
||||
.unwrap_or_else(|| "debian".to_string())
|
||||
}
|
||||
|
||||
/// Resolve the path of the active dpkg origins file from the
|
||||
/// `DPKG_ORIGINS_DIR` value (the directory holding the origin files, where
|
||||
/// `default` selects the active one) and the fallback directory
|
||||
/// (`/etc/dpkg/origins`). An unset or empty directory value falls back.
|
||||
fn resolve_origins_default(origins_dir: Option<&str>, fallback_dir: &str) -> PathBuf {
|
||||
let dir = origins_dir
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or(fallback_dir);
|
||||
Path::new(dir).join("default")
|
||||
}
|
||||
|
||||
/// Extract the vendor name from the content of a dpkg origins file: its
|
||||
/// `Vendor:` field, falling back to `Origin:` when absent. `None` when
|
||||
/// neither field carries a non-empty value.
|
||||
pub fn vendor_from_origins_content(content: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Vendor:") {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to Origin if no Vendor field is present.
|
||||
for line in content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Origin:") {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Default build profiles applied by vendor hooks.
|
||||
///
|
||||
/// The distro data carries them (`build_profiles` of the vendor's
|
||||
/// distribution in `data/distro_info.yml` — the Ubuntu vendor activates
|
||||
/// `derivative.ubuntu noudeb`, Debian applies none), mirroring what
|
||||
/// `Dpkg::BuildProfiles` resolves when `DEB_BUILD_PROFILES` is unset. The
|
||||
/// vendor is matched case-insensitively against the distro data keys
|
||||
/// (dpkg's `Vendor:` field keeps its original casing); a vendor with no
|
||||
/// distro entry gets no profiles.
|
||||
pub fn default_build_profiles(vendor: &str) -> Vec<String> {
|
||||
crate::distro_info::get_build_profiles(&vendor.to_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the active build profiles: explicit `-P` profiles take precedence,
|
||||
/// then `DEB_BUILD_PROFILES` from the environment, then vendor defaults.
|
||||
pub fn resolve_build_profiles(explicit: &[String], vendor: &str) -> Vec<String> {
|
||||
if !explicit.is_empty() {
|
||||
return explicit.to_vec();
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEB_BUILD_PROFILES") {
|
||||
let profiles: Vec<String> = value
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if !profiles.is_empty() {
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
default_build_profiles(vendor)
|
||||
}
|
||||
|
||||
/// Environment variables that may affect a build without leaking private
|
||||
/// information; only these are recorded in the `.buildinfo` `Environment`
|
||||
/// field. Mirrors `Dpkg::BuildInfo::get_build_env_allowed()`.
|
||||
const ENV_ALLOWED: &[&str] = &[
|
||||
// Tool behavior.
|
||||
"POSIXLY_CORRECT",
|
||||
"GETCONF_DIR",
|
||||
// Resolver.
|
||||
"RESOLV_HOST_CONF",
|
||||
"RESOLV_MULTI",
|
||||
"RESOLV_REORDER",
|
||||
"RES_OPTIONS",
|
||||
// Toolchain.
|
||||
"CC",
|
||||
"CPP",
|
||||
"CXX",
|
||||
"OBJC",
|
||||
"OBJCXX",
|
||||
"PC",
|
||||
"FC",
|
||||
"M2C",
|
||||
"AS",
|
||||
"LD",
|
||||
"AR",
|
||||
"RANLIB",
|
||||
"MAKE",
|
||||
"AWK",
|
||||
"LEX",
|
||||
"YACC",
|
||||
// Toolchain flags.
|
||||
"ASFLAGS",
|
||||
"ASFLAGS_FOR_BUILD",
|
||||
"CFLAGS",
|
||||
"CFLAGS_FOR_BUILD",
|
||||
"CPPFLAGS",
|
||||
"CPPFLAGS_FOR_BUILD",
|
||||
"CXXFLAGS",
|
||||
"CXXFLAGS_FOR_BUILD",
|
||||
"OBJCFLAGS",
|
||||
"OBJCFLAGS_FOR_BUILD",
|
||||
"OBJCXXFLAGS",
|
||||
"OBJCXXFLAGS_FOR_BUILD",
|
||||
"DFLAGS",
|
||||
"DFLAGS_FOR_BUILD",
|
||||
"FFLAGS",
|
||||
"FFLAGS_FOR_BUILD",
|
||||
"LDFLAGS",
|
||||
"LDFLAGS_FOR_BUILD",
|
||||
"ARFLAGS",
|
||||
"LFLAGS",
|
||||
"YFLAGS",
|
||||
"MAKEFLAGS",
|
||||
"GNUMAKEFLAGS",
|
||||
// Dynamic linker.
|
||||
"LD_ASSUME_KERNEL",
|
||||
"LD_AUDIT",
|
||||
"LD_BIND_NOT",
|
||||
"LD_BIND_NOW",
|
||||
"LD_DYNAMIC_WEAK",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_ORIGIN_PATH",
|
||||
"LD_PREFER_MAP_32BIT_EXEC",
|
||||
"LD_PRELOAD",
|
||||
// Timezone.
|
||||
"TZ",
|
||||
"TZDIR",
|
||||
// Dates.
|
||||
"DATEMSK",
|
||||
// Locale.
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_NUMERIC",
|
||||
"LC_TIME",
|
||||
"LC_COLLATE",
|
||||
"LC_MONETARY",
|
||||
"LC_MESSAGES",
|
||||
"LC_PAPER",
|
||||
"LC_NAME",
|
||||
"LC_ADDRESS",
|
||||
"LC_TELEPHONE",
|
||||
"LC_MEASUREMENT",
|
||||
"LC_IDENTIFICATION",
|
||||
// Locale paths.
|
||||
"LOCPATH",
|
||||
"I18NPATH",
|
||||
"NLSPATH",
|
||||
"GCONV_PATH",
|
||||
// Build flags.
|
||||
"DEB_BUILD_OPTIONS",
|
||||
"DEB_BUILD_PROFILES",
|
||||
"DEB_VENDOR",
|
||||
// dpkg.
|
||||
"DPKG_ROOT",
|
||||
"DPKG_ADMINDIR",
|
||||
"DPKG_DATADIR",
|
||||
"DPKG_ORIGINS_DIR",
|
||||
// dpkg-deb.
|
||||
"DPKG_DEB_COMPRESSOR_TYPE",
|
||||
"DPKG_DEB_COMPRESSOR_LEVEL",
|
||||
// dpkg-gensymbols.
|
||||
"DPKG_GENSYMBOLS_CHECK_LEVEL",
|
||||
// Reproducible builds.
|
||||
"SOURCE_DATE_EPOCH",
|
||||
];
|
||||
|
||||
/// Build the `.buildinfo` `Environment` field value: allowed variables from
|
||||
/// the current process environment plus the `extra` overrides exported to
|
||||
/// build steps (e.g. `SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`), sorted by
|
||||
/// name, quoted and escaped, one per line.
|
||||
///
|
||||
/// Matches `cleansed_environment()` in `dpkg-genbuildinfo` (minus
|
||||
/// `dpkg-buildflags` origin tracking).
|
||||
pub fn buildinfo_environment(extra: &BTreeMap<String, String>) -> String {
|
||||
let mut values: BTreeMap<String, String> = BTreeMap::new();
|
||||
for var in ENV_ALLOWED {
|
||||
if let Ok(value) = std::env::var(var) {
|
||||
values.insert(var.to_string(), value);
|
||||
}
|
||||
}
|
||||
// Variables we export ourselves always take precedence.
|
||||
for (key, value) in extra {
|
||||
if ENV_ALLOWED.contains(&key.as_str()) {
|
||||
values.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
// Leading `\n`: pre-wrapped multiline field, dpkg-style.
|
||||
let mut out = String::from("\n");
|
||||
out.push_str(
|
||||
&values
|
||||
.into_iter()
|
||||
.map(|(var, value)| format!("{}=\"{}\"", var, value.replace('"', "\\\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_env_values() {
|
||||
let env = build_env(1787392800, 16, &[]);
|
||||
assert_eq!(env.get("LANG").unwrap(), "C");
|
||||
assert_eq!(env.get("LC_ALL").unwrap(), "C");
|
||||
assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800");
|
||||
// Reading the var is race-free; the expected value goes through the
|
||||
// same merge so the assertion holds whatever the ambient environment
|
||||
// carries.
|
||||
let expected = merge_deb_build_options(
|
||||
std::env::var("DEB_BUILD_OPTIONS").ok().as_deref(),
|
||||
"parallel=16",
|
||||
);
|
||||
assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), &expected);
|
||||
assert!(!env.contains_key("DEB_BUILD_PROFILES"));
|
||||
|
||||
let env = build_env(1, 4, &["nodoc".to_string(), "cross".to_string()]);
|
||||
assert_eq!(env.get("DEB_BUILD_PROFILES").unwrap(), "nodoc,cross");
|
||||
}
|
||||
|
||||
/// dpkg-buildpackage prepends the inherited `DEB_BUILD_OPTIONS`, so
|
||||
/// user-set options survive alongside the computed ones.
|
||||
#[test]
|
||||
fn merge_prepends_inherited_options() {
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some("terse"), "parallel=16"),
|
||||
"terse parallel=16"
|
||||
);
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some("nocheck terse"), "parallel=4"),
|
||||
"nocheck terse parallel=4"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unset, empty or blank inherited value yields just the computed
|
||||
/// options.
|
||||
#[test]
|
||||
fn merge_skips_empty_inherited() {
|
||||
assert_eq!(merge_deb_build_options(None, "parallel=8"), "parallel=8");
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some(""), "parallel=8"),
|
||||
"parallel=8"
|
||||
);
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some(" "), "parallel=8"),
|
||||
"parallel=8"
|
||||
);
|
||||
}
|
||||
|
||||
/// Both sides are trimmed and internal whitespace runs collapsed: no
|
||||
/// leading/trailing space, no double spaces in the merged result.
|
||||
#[test]
|
||||
fn merge_normalizes_whitespace() {
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some(" terse "), "parallel=2"),
|
||||
"terse parallel=2"
|
||||
);
|
||||
assert_eq!(
|
||||
merge_deb_build_options(Some("nocheck\t terse"), "parallel=2"),
|
||||
"nocheck terse parallel=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_defaults() {
|
||||
assert!(default_build_profiles("debian").is_empty());
|
||||
assert_eq!(
|
||||
default_build_profiles("ubuntu"),
|
||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
||||
);
|
||||
// dpkg's Vendor field keeps its original casing; the distro data
|
||||
// keys are lowercase.
|
||||
assert_eq!(
|
||||
default_build_profiles("Ubuntu"),
|
||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
||||
);
|
||||
// A vendor without a distro entry gets no profiles.
|
||||
assert!(default_build_profiles("some-derivative").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_from_origins_content_prefers_vendor_then_origin() {
|
||||
assert_eq!(
|
||||
vendor_from_origins_content("Vendor: Ubuntu\nSuite: noble\n"),
|
||||
Some("Ubuntu".to_string())
|
||||
);
|
||||
// Origin fallback when no Vendor field is present.
|
||||
assert_eq!(
|
||||
vendor_from_origins_content("Origin: Debian\nSuite: stable\n"),
|
||||
Some("Debian".to_string())
|
||||
);
|
||||
// Empty Vendor falls through to Origin.
|
||||
assert_eq!(
|
||||
vendor_from_origins_content("Vendor: \nOrigin: Debian\n"),
|
||||
Some("Debian".to_string())
|
||||
);
|
||||
assert_eq!(vendor_from_origins_content("Suite: stable\n"), None);
|
||||
}
|
||||
|
||||
/// `current_vendor` must honor `DPKG_ORIGINS_DIR` (already on the
|
||||
/// `.buildinfo` allow-list) when locating the `default` origins file,
|
||||
/// falling back to `/etc/dpkg/origins/default` when unset or empty.
|
||||
#[test]
|
||||
fn origins_default_path_honors_dpkg_origins_dir() {
|
||||
assert_eq!(
|
||||
resolve_origins_default(Some("/custom/origins"), "/etc/dpkg/origins"),
|
||||
PathBuf::from("/custom/origins/default")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_origins_default(None, "/etc/dpkg/origins"),
|
||||
PathBuf::from("/etc/dpkg/origins/default")
|
||||
);
|
||||
// An empty value behaves as unset, like dpkg's `$dir || $default`.
|
||||
assert_eq!(
|
||||
resolve_origins_default(Some(""), "/etc/dpkg/origins"),
|
||||
PathBuf::from("/etc/dpkg/origins/default")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_escaping() {
|
||||
// The function reads the process env; just verify formatting helpers
|
||||
// through a controlled subprocess-free path is not possible, so check
|
||||
// the constant list contains essentials.
|
||||
assert!(ENV_ALLOWED.contains(&"SOURCE_DATE_EPOCH"));
|
||||
assert!(ENV_ALLOWED.contains(&"DEB_BUILD_OPTIONS"));
|
||||
assert!(!ENV_ALLOWED.contains(&"HOME"));
|
||||
assert!(!ENV_ALLOWED.contains(&"PATH"));
|
||||
}
|
||||
}
|
||||
+2390
File diff suppressed because it is too large
Load Diff
+1097
-152
File diff suppressed because it is too large
Load Diff
+261
-23
@@ -5,6 +5,25 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Stream from which a captured output line originates
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stream {
|
||||
/// Standard output of the subprocess
|
||||
Stdout,
|
||||
/// Standard error of the subprocess
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// Receiver for lines captured from a subprocess
|
||||
///
|
||||
/// Implementations receive every decoded line (ANSI escapes stripped,
|
||||
/// carriage-return progress fragments collapsed) as soon as it is produced,
|
||||
/// allowing live UIs to display and rewrite subprocess output while it runs.
|
||||
pub trait LineSink: Send + Sync {
|
||||
/// Called for each captured line
|
||||
fn line(&self, stream: Stream, line: &str);
|
||||
}
|
||||
|
||||
use super::local::LocalDriver;
|
||||
use super::schroot::SchrootDriver;
|
||||
use super::ssh::SshDriver;
|
||||
@@ -22,6 +41,23 @@ pub trait ContextDriver {
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
) -> io::Result<std::process::ExitStatus>;
|
||||
/// Run a command, capturing its output line by line into `sink`
|
||||
///
|
||||
/// Implementations should pipe the subprocess standard streams and forward
|
||||
/// each decoded line to `sink` instead of letting it inherit the terminal.
|
||||
/// The default implementation ignores the sink and behaves like
|
||||
/// [`ContextDriver::run`].
|
||||
fn run_captured(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
sink: Arc<dyn LineSink>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
let _ = sink;
|
||||
self.run(program, args, env, cwd)
|
||||
}
|
||||
fn run_output(
|
||||
&self,
|
||||
program: &str,
|
||||
@@ -34,6 +70,12 @@ pub trait ContextDriver {
|
||||
fn read_file(&self, path: &Path) -> io::Result<String>;
|
||||
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
|
||||
fn exists(&self, path: &Path) -> io::Result<bool>;
|
||||
/// Check if a path is a directory inside the context
|
||||
///
|
||||
/// Distinct from [`ContextDriver::exists`] because paths returned by
|
||||
/// [`ContextDriver::list_files`] are context-relative and can only be
|
||||
/// classified through the context, never with a host-side stat.
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool>;
|
||||
|
||||
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||
/// Called before the chroot directory is removed.
|
||||
@@ -81,6 +123,28 @@ pub enum ContextConfig {
|
||||
},
|
||||
}
|
||||
|
||||
impl ContextConfig {
|
||||
/// Build an SSH context configuration from an endpoint of the form
|
||||
/// `[ssh://][user@]host[:port]`.
|
||||
pub fn from_endpoint(endpoint: &str) -> Result<Self, String> {
|
||||
let re = regex::Regex::new(
|
||||
r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$",
|
||||
)
|
||||
.expect("valid endpoint regex");
|
||||
let cap = re.captures(endpoint).ok_or_else(|| {
|
||||
format!("Invalid endpoint format: '{endpoint}'. Expected [ssh://][user@]host[:port]")
|
||||
})?;
|
||||
let host = cap.name("host").unwrap().as_str().to_string();
|
||||
let user = cap.name("user").map(|m| m.as_str().to_string());
|
||||
let port = cap
|
||||
.name("port")
|
||||
.map(|m| m.as_str().parse::<u16>())
|
||||
.transpose()
|
||||
.map_err(|_| "Invalid port number".to_string())?;
|
||||
Ok(ContextConfig::Ssh { host, user, port })
|
||||
}
|
||||
}
|
||||
|
||||
/// A context, allowing to run commands, read and write files, etc
|
||||
pub struct Context {
|
||||
/// Configuration for the context
|
||||
@@ -95,8 +159,46 @@ pub struct Context {
|
||||
|
||||
impl Context {
|
||||
/// Create a context from configuration
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
let parent = match &config {
|
||||
///
|
||||
/// Parent contexts named in the configuration are resolved through the
|
||||
/// global context manager; a dangling parent name is reported as an
|
||||
/// error instead of panicking.
|
||||
///
|
||||
/// Note that this takes a read lock on the global manager's
|
||||
/// configuration: never call it while holding that lock for writing.
|
||||
/// [`crate::context::ContextManager`] itself goes through
|
||||
/// [`Context::with_lookup`] instead, which takes no locks.
|
||||
pub fn new(config: ContextConfig) -> io::Result<Self> {
|
||||
Self::with_lookup(config, &|name| {
|
||||
crate::context::manager::MANAGER
|
||||
.get_config()
|
||||
.contexts
|
||||
.get(name)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a context from configuration, resolving `parent` context names
|
||||
/// through `lookup` instead of the global context manager.
|
||||
///
|
||||
/// `lookup` must be lock-free: this is what allows
|
||||
/// [`crate::context::ContextManager`] to build contexts while holding
|
||||
/// (or before the very existence of) its configuration lock. Returns an
|
||||
/// error when a referenced parent does not exist or when the parent
|
||||
/// chain contains a cycle.
|
||||
pub(crate) fn with_lookup(
|
||||
config: ContextConfig,
|
||||
lookup: &dyn Fn(&str) -> Option<ContextConfig>,
|
||||
) -> io::Result<Self> {
|
||||
Self::with_lookup_inner(config, lookup, &mut Vec::new())
|
||||
}
|
||||
|
||||
fn with_lookup_inner(
|
||||
config: ContextConfig,
|
||||
lookup: &dyn Fn(&str) -> Option<ContextConfig>,
|
||||
chain: &mut Vec<String>,
|
||||
) -> io::Result<Self> {
|
||||
let parent_name = match &config {
|
||||
ContextConfig::Schroot {
|
||||
parent: Some(parent_name),
|
||||
..
|
||||
@@ -104,23 +206,37 @@ impl Context {
|
||||
| ContextConfig::Unshare {
|
||||
parent: Some(parent_name),
|
||||
..
|
||||
} => {
|
||||
let config_lock = crate::context::manager::MANAGER.get_config();
|
||||
let parent_config = config_lock
|
||||
.contexts
|
||||
.get(parent_name)
|
||||
.cloned()
|
||||
.expect("Parent context not found");
|
||||
Some(Arc::new(Context::new(parent_config)))
|
||||
} => parent_name.clone(),
|
||||
_ => {
|
||||
return Ok(Self {
|
||||
config,
|
||||
parent: None,
|
||||
driver: Mutex::new(None),
|
||||
});
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
parent,
|
||||
driver: Mutex::new(None),
|
||||
if chain.iter().any(|name| name == &parent_name) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Parent context cycle: '{parent_name}' appears in its own parent chain"),
|
||||
));
|
||||
}
|
||||
let parent_config = lookup(&parent_name).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Parent context '{parent_name}' not found"),
|
||||
)
|
||||
})?;
|
||||
chain.push(parent_name);
|
||||
let parent = Self::with_lookup_inner(parent_config, lookup, chain);
|
||||
chain.pop();
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
parent: Some(Arc::new(parent?)),
|
||||
driver: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a context with an explicit parent context
|
||||
@@ -133,13 +249,24 @@ impl Context {
|
||||
}
|
||||
|
||||
/// Make a command inside context
|
||||
///
|
||||
/// Build tooling must not inherit the session's locale: dpkg-family
|
||||
/// tools and perl-based packaging scripts change their output (and
|
||||
/// dpkg-buildpackage treats some of it as data) with the environment,
|
||||
/// and a translated or mixed locale leaks host state into builds. The
|
||||
/// C locale is the default; a caller can still override it by setting
|
||||
/// LANG/LC_ALL through [`ContextCommand::envs`] afterwards.
|
||||
pub fn command<S: AsRef<OsStr>>(&self, program: S) -> ContextCommand<'_> {
|
||||
ContextCommand {
|
||||
context: self,
|
||||
program: program.as_ref().to_string_lossy().to_string(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
env: vec![
|
||||
("LANG".to_string(), "C".to_string()),
|
||||
("LC_ALL".to_string(), "C".to_string()),
|
||||
],
|
||||
cwd: None,
|
||||
sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +319,15 @@ impl Context {
|
||||
self.driver().as_ref().unwrap().exists(path)
|
||||
}
|
||||
|
||||
/// Check if a path is a directory inside context
|
||||
///
|
||||
/// Paths returned by [`Context::list_files`] are context-relative
|
||||
/// (e.g. rooted inside the chroot for an unshare context): whether they
|
||||
/// are directories can only be decided through the context.
|
||||
pub fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
self.driver().as_ref().unwrap().is_dir(path)
|
||||
}
|
||||
|
||||
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||
/// Called before the chroot directory is removed.
|
||||
pub fn cleanup(&self) -> io::Result<()> {
|
||||
@@ -222,7 +358,9 @@ impl Context {
|
||||
overlay_mounts: std::sync::Mutex::new(Vec::new()),
|
||||
}),
|
||||
};
|
||||
*driver_lock = Some(driver);
|
||||
// In test runs, commands whose output would inherit the terminal
|
||||
// are captured into the per-test log file instead
|
||||
*driver_lock = Some(crate::test_support::wrap_driver(driver));
|
||||
}
|
||||
driver_lock
|
||||
}
|
||||
@@ -250,6 +388,7 @@ pub struct ContextCommand<'a> {
|
||||
args: Vec<String>,
|
||||
env: Vec<(String, String)>,
|
||||
cwd: Option<String>,
|
||||
sink: Option<Arc<dyn LineSink>>,
|
||||
}
|
||||
|
||||
impl<'a> ContextCommand<'a> {
|
||||
@@ -303,15 +442,33 @@ impl<'a> ContextCommand<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable line-wise capture of the command output into `sink`
|
||||
///
|
||||
/// When a sink is set, [`ContextCommand::status`] pipes the subprocess
|
||||
/// standard streams and forwards each decoded line to the sink instead of
|
||||
/// letting the child inherit the terminal. Without a sink, behavior is
|
||||
/// unchanged.
|
||||
pub fn capture(&mut self, sink: Arc<dyn LineSink>) -> &mut Self {
|
||||
self.sink = Some(sink);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run command and obtain exit status
|
||||
pub fn status(&mut self) -> io::Result<std::process::ExitStatus> {
|
||||
let program = self.program.clone();
|
||||
self.context
|
||||
.driver()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.run(&self.program, &self.args, &self.env, self.cwd.as_deref())
|
||||
.map_err(|e| contextualize_spawn_error(&program, e))
|
||||
let driver_guard = self.context.driver();
|
||||
let driver = driver_guard.as_ref().unwrap();
|
||||
let result = match &self.sink {
|
||||
Some(sink) => driver.run_captured(
|
||||
&self.program,
|
||||
&self.args,
|
||||
&self.env,
|
||||
self.cwd.as_deref(),
|
||||
sink.clone(),
|
||||
),
|
||||
None => driver.run(&self.program, &self.args, &self.env, self.cwd.as_deref()),
|
||||
};
|
||||
result.map_err(|e| contextualize_spawn_error(&program, e))
|
||||
}
|
||||
|
||||
/// Run command, capturing output
|
||||
@@ -345,3 +502,84 @@ fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
|
||||
io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod endpoint_tests {
|
||||
use super::*;
|
||||
|
||||
/// Every accepted endpoint spelling maps to the expected config.
|
||||
#[test]
|
||||
fn from_endpoint_parses_all_spellings() {
|
||||
assert_eq!(
|
||||
ContextConfig::from_endpoint("myhost"),
|
||||
Ok(ContextConfig::Ssh {
|
||||
host: "myhost".into(),
|
||||
user: None,
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ContextConfig::from_endpoint("admin@myhost"),
|
||||
Ok(ContextConfig::Ssh {
|
||||
host: "myhost".into(),
|
||||
user: Some("admin".into()),
|
||||
port: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ContextConfig::from_endpoint("myhost:2222"),
|
||||
Ok(ContextConfig::Ssh {
|
||||
host: "myhost".into(),
|
||||
user: None,
|
||||
port: Some(2222),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ContextConfig::from_endpoint("ssh://admin@myhost:22"),
|
||||
Ok(ContextConfig::Ssh {
|
||||
host: "myhost".into(),
|
||||
user: Some("admin".into()),
|
||||
port: Some(22),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-numeric ports and extra segments are format errors; a
|
||||
/// non-u16 numeric port is a port error.
|
||||
#[test]
|
||||
fn from_endpoint_rejects_malformed_endpoints() {
|
||||
for bad in ["", "a/b/c", "host:notaport"] {
|
||||
let err = ContextConfig::from_endpoint(bad).unwrap_err();
|
||||
assert!(err.contains("Invalid endpoint format"), "{err}");
|
||||
}
|
||||
let err = ContextConfig::from_endpoint("host:99999").unwrap_err();
|
||||
assert_eq!(err, "Invalid port number");
|
||||
}
|
||||
|
||||
/// Commands run in the C locale whatever the session environment
|
||||
/// carries: host locale variables must not leak into builds. An
|
||||
/// explicit caller override still wins.
|
||||
#[test]
|
||||
fn commands_default_to_the_c_locale() {
|
||||
let ctx = Context::new(ContextConfig::Local).unwrap();
|
||||
|
||||
let locale = ctx
|
||||
.command("sh")
|
||||
.arg("-c")
|
||||
.arg("printf '%s' \"${LC_ALL:-unset}:${LANG:-unset}\"")
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout;
|
||||
assert_eq!(String::from_utf8_lossy(&locale), "C:C");
|
||||
|
||||
let locale = ctx
|
||||
.command("sh")
|
||||
.arg("-c")
|
||||
.arg("printf '%s' \"$LC_ALL\"")
|
||||
.env("LC_ALL", "C.UTF-8")
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout;
|
||||
assert_eq!(String::from_utf8_lossy(&locale), "C.UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Shared helpers for capturing subprocess output line by line
|
||||
//!
|
||||
//! Used by the context drivers implementing
|
||||
//! [`ContextDriver::run_captured`](super::api::ContextDriver::run_captured):
|
||||
//! raw bytes are read incrementally, split into lines, cleaned up (ANSI escape
|
||||
//! stripping, carriage-return progress collapsing) and forwarded to a
|
||||
//! [`LineSink`](super::api::LineSink).
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::api::{LineSink, Stream};
|
||||
use regex::Regex;
|
||||
|
||||
/// Precompiled regex matching ANSI escape sequences (CSI and simple escapes)
|
||||
fn ansi_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])").unwrap())
|
||||
}
|
||||
|
||||
/// Strip ANSI escape sequences from a line
|
||||
pub(crate) fn strip_ansi(line: &str) -> String {
|
||||
ansi_regex().replace_all(line, "").to_string()
|
||||
}
|
||||
|
||||
/// Read `reader` to EOF, forwarding each decoded line to `sink`
|
||||
///
|
||||
/// Lines are split on `\n`; when a line contains carriage returns (e.g. apt's
|
||||
/// `0% [Working]` progress fragments), only the last `\r`-segment is kept so
|
||||
/// progress updates replace each other instead of accumulating. Empty lines
|
||||
/// are dropped.
|
||||
pub(crate) fn pump<R: Read>(mut reader: R, stream: Stream, sink: &dyn LineSink) {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(8192);
|
||||
let mut chunk = [0u8; 4096];
|
||||
|
||||
loop {
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
emit(&line[..line.len() - 1], stream, sink);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush a trailing line without newline, if any
|
||||
if !buf.is_empty() {
|
||||
emit(&buf, stream, sink);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up and forward one raw line to the sink
|
||||
fn emit(raw: &[u8], stream: Stream, sink: &dyn LineSink) {
|
||||
let mut line = String::from_utf8_lossy(raw).to_string();
|
||||
|
||||
// Strip a trailing carriage return left over from CRLF line endings
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
|
||||
// Carriage-return progress: keep only the last segment of the line
|
||||
if let Some(idx) = line.rfind('\r') {
|
||||
line = line[idx + 1..].to_string();
|
||||
}
|
||||
|
||||
let line = strip_ansi(&line);
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sink.line(stream, trimmed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CollectingSink(Mutex<Vec<(Stream, String)>>);
|
||||
|
||||
impl LineSink for CollectingSink {
|
||||
fn line(&self, stream: Stream, line: &str) {
|
||||
self.0.lock().unwrap().push((stream, line.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pump_splits_lines_and_merges_cr_fragments() {
|
||||
let sink = CollectingSink::default();
|
||||
let data =
|
||||
b"Get:1 http://x InRelease [1 kB]\r0% [Working]\r\nHit:2 http://y Release\npartial";
|
||||
pump(&data[..], Stream::Stdout, &sink);
|
||||
|
||||
let lines = sink.0.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
(Stream::Stdout, "0% [Working]".to_string()),
|
||||
(Stream::Stdout, "Hit:2 http://y Release".to_string()),
|
||||
(Stream::Stdout, "partial".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pump_strips_ansi_and_skips_empty_lines() {
|
||||
let sink = CollectingSink::default();
|
||||
let data = b"\x1b[1mSetting up foo\x1b[0m\n\n \nE: boom\n";
|
||||
pump(&data[..], Stream::Stderr, &sink);
|
||||
|
||||
let lines = sink.0.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
(Stream::Stderr, "Setting up foo".to_string()),
|
||||
(Stream::Stderr, "E: boom".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+110
-16
@@ -1,10 +1,12 @@
|
||||
/// Local context: execute commands locally
|
||||
/// Context driver: Does nothing
|
||||
use super::api::ContextDriver;
|
||||
use super::api::{ContextDriver, LineSink, Stream};
|
||||
use super::capture::pump;
|
||||
use std::io;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub struct LocalDriver;
|
||||
@@ -22,33 +24,31 @@ impl ContextDriver for LocalDriver {
|
||||
}
|
||||
|
||||
fn create_temp_dir(&self) -> io::Result<String> {
|
||||
// Generate a unique temporary directory name with random string
|
||||
// Sub-second precision and an atomic create: two concurrent
|
||||
// contexts racing on the same name must never share a directory,
|
||||
// so the loser of a create falls through to the next attempt
|
||||
// instead of probing for existence first (a probe-then-create
|
||||
// window loses exactly when two callers arrive together).
|
||||
let base_timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
.as_millis();
|
||||
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let work_dir_name = if attempt == 0 {
|
||||
format!("pkh-{}", base_timestamp)
|
||||
format!("pkh-{base_timestamp}")
|
||||
} else {
|
||||
format!("pkh-{}-{}", base_timestamp, attempt)
|
||||
format!("pkh-{base_timestamp}-{attempt}")
|
||||
};
|
||||
|
||||
let temp_dir_path = std::env::temp_dir().join(&work_dir_name);
|
||||
|
||||
// Check if directory already exists
|
||||
if temp_dir_path.exists() {
|
||||
attempt += 1;
|
||||
continue;
|
||||
match std::fs::create_dir(&temp_dir_path) {
|
||||
Ok(()) => return Ok(temp_dir_path.to_string_lossy().to_string()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => attempt += 1,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
std::fs::create_dir_all(&temp_dir_path)?;
|
||||
|
||||
// Return the path as a string
|
||||
return Ok(temp_dir_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,51 @@ impl ContextDriver for LocalDriver {
|
||||
cmd.status()
|
||||
}
|
||||
|
||||
fn run_captured(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
sink: Arc<dyn LineSink>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args).envs(env.iter().map(|(k, v)| (k, v)));
|
||||
// Best-effort: ask children not to emit ANSI colors; captured lines are
|
||||
// stripped anyway.
|
||||
cmd.env("NO_COLOR", "1");
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// One reader thread per stream; lines are forwarded to the sink as
|
||||
// they arrive so the UI stays live.
|
||||
let out_sink = sink.clone();
|
||||
let t_out =
|
||||
stdout.map(|r| std::thread::spawn(move || pump(r, Stream::Stdout, out_sink.as_ref())));
|
||||
let err_sink = sink.clone();
|
||||
let t_err =
|
||||
stderr.map(|r| std::thread::spawn(move || pump(r, Stream::Stderr, err_sink.as_ref())));
|
||||
|
||||
let status = child.wait();
|
||||
|
||||
if let Some(t) = t_out {
|
||||
let _ = t.join();
|
||||
}
|
||||
if let Some(t) = t_err {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
fn run_output(
|
||||
&self,
|
||||
program: &str,
|
||||
@@ -110,6 +155,10 @@ impl ContextDriver for LocalDriver {
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(path.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
@@ -126,6 +175,15 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
// Never ship VCS metadata into the build tree: its presence
|
||||
// flips autotools 'building from VCS' detection (see
|
||||
// is_vcs_dir_name) and activates maintainer-only regeneration
|
||||
// rules requiring undeclared tools (e.g. help2man).
|
||||
if path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false)
|
||||
&& super::is_vcs_dir_name(&entry.file_name())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
copy_dir_recursive(&path, &dest_path)?;
|
||||
}
|
||||
@@ -134,3 +192,39 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Concurrent callers must never share a temporary directory: the
|
||||
/// create is atomic, so a lost race falls through to the next name
|
||||
/// instead of both callers probing the same free name and unpacking
|
||||
/// into the same directory.
|
||||
#[test]
|
||||
fn create_temp_dir_is_unique_under_concurrency() {
|
||||
const CALLERS: usize = 8;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let handles: Vec<_> = (0..CALLERS)
|
||||
.map(|_| {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let dir = LocalDriver.create_temp_dir().unwrap();
|
||||
tx.send(dir).unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
let mut names: Vec<String> = rx.iter().collect();
|
||||
names.sort();
|
||||
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
|
||||
assert_eq!(names.len(), unique.len(), "duplicate temp dirs: {names:?}");
|
||||
for name in &unique {
|
||||
std::fs::remove_dir(name).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+149
-41
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
@@ -48,26 +48,88 @@ impl ContextManager {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
let config_path = config_dir.join("contexts.json");
|
||||
|
||||
let config = if config_path.exists() {
|
||||
// Load existing configuration file
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
|
||||
} else {
|
||||
// Create a new configuration file
|
||||
Config::default()
|
||||
let mut config = Self::load_config(&config_path);
|
||||
|
||||
// Build the initial Context against the freshly loaded map, before
|
||||
// the manager itself exists: resolution must not go through the
|
||||
// global MANAGER here, since a parented current context would
|
||||
// re-enter its own LazyLock initialization.
|
||||
let initial = match Self::make_context(&config.context, &config.contexts) {
|
||||
Ok(context) => context,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Cannot build current context '{}' from {}: {e}; falling back to 'local'",
|
||||
config.context,
|
||||
config_path.display()
|
||||
);
|
||||
config.context = "local".to_string();
|
||||
Self::make_context("local", &config.contexts).unwrap_or_else(|e| {
|
||||
// Only possible in a hand-edited configuration without
|
||||
// any 'local' entry; a plain Local context has no parent
|
||||
// and cannot fail to build.
|
||||
log::error!(
|
||||
"'local' context missing from {}: {e}",
|
||||
config_path.display()
|
||||
);
|
||||
Context::new(ContextConfig::Local).expect("Local context cannot fail")
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
context: RwLock::new(Arc::new(Self::make_context(
|
||||
config.context.as_str(),
|
||||
&config,
|
||||
))),
|
||||
context: RwLock::new(Arc::new(initial)),
|
||||
config_path,
|
||||
config: RwLock::new(config),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the configuration stored at `path`.
|
||||
///
|
||||
/// A missing file yields [`Config::default`]. A file that cannot be read
|
||||
/// or parsed must not take the whole program down: this falls back to
|
||||
/// the default (local-only) configuration and logs an error. Because a
|
||||
/// later [`ContextManager::save`] would otherwise silently overwrite the
|
||||
/// corrupt file and destroy its content, the corrupt file is first
|
||||
/// backed up to `<path>.bak` (best effort).
|
||||
pub(crate) fn load_config(path: &Path) -> Config {
|
||||
if !path.exists() {
|
||||
return Config::default();
|
||||
}
|
||||
let loaded = fs::read_to_string(path).and_then(|content| {
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
});
|
||||
match loaded {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Context configuration {} is corrupt ({e}); using the default (local-only) configuration",
|
||||
path.display()
|
||||
);
|
||||
Self::backup_corrupt_file(path);
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Back up a corrupt configuration file (best effort) so a later save
|
||||
/// cannot silently destroy its content.
|
||||
fn backup_corrupt_file(path: &Path) {
|
||||
let mut os = path.as_os_str().to_os_string();
|
||||
os.push(".bak");
|
||||
let backup_path = PathBuf::from(os);
|
||||
match fs::copy(path, &backup_path) {
|
||||
Ok(_) => log::warn!(
|
||||
"Corrupt context configuration backed up to {}",
|
||||
backup_path.display()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"Could not back up corrupt context configuration to {}: {e}",
|
||||
backup_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain current ContextManager configuration
|
||||
pub fn get_config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
|
||||
self.config.read().unwrap()
|
||||
@@ -77,7 +139,12 @@ impl ContextManager {
|
||||
pub fn with_path(path: PathBuf) -> Self {
|
||||
let config = Config::default();
|
||||
Self {
|
||||
context: RwLock::new(Arc::new(Self::make_context("local", &config))),
|
||||
// 'local' is always present in Config::default and has no
|
||||
// parent, so this cannot fail.
|
||||
context: RwLock::new(Arc::new(
|
||||
Self::make_context("local", &config.contexts)
|
||||
.expect("default 'local' context cannot fail"),
|
||||
)),
|
||||
config_path: path,
|
||||
config: RwLock::new(config),
|
||||
}
|
||||
@@ -92,13 +159,22 @@ impl ContextManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_context(name: &str, config: &Config) -> Context {
|
||||
let context_config = config
|
||||
.contexts
|
||||
.get(name)
|
||||
.cloned()
|
||||
.expect("Context not found in config");
|
||||
Context::new(context_config)
|
||||
/// Build a [`Context`] for `name` from `contexts`.
|
||||
///
|
||||
/// Lock-free by construction: parent references are resolved against
|
||||
/// `contexts` itself (see [`Context::with_lookup`]), never against the
|
||||
/// manager's configuration lock. This is what keeps [`ContextManager::new`]
|
||||
/// working before the global [`MANAGER`] exists, and what allows callers
|
||||
/// to build contexts without risking a re-entrant read on a lock they
|
||||
/// already hold for writing.
|
||||
fn make_context(name: &str, contexts: &HashMap<String, ContextConfig>) -> io::Result<Context> {
|
||||
let context_config = contexts.get(name).cloned().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{name}' not found in configuration"),
|
||||
)
|
||||
})?;
|
||||
Context::with_lookup(context_config, &|parent| contexts.get(parent).cloned())
|
||||
}
|
||||
|
||||
/// List contexts from configuration
|
||||
@@ -124,45 +200,77 @@ impl ContextManager {
|
||||
|
||||
/// Remove context from configuration
|
||||
pub fn remove_context(&self, name: &str) -> io::Result<()> {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if name == "local" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Cannot remove local context",
|
||||
));
|
||||
}
|
||||
if config.contexts.remove(name).is_some() {
|
||||
// If we are removing the current context, fallback to local
|
||||
if name == config.context {
|
||||
config.context = "local".to_string();
|
||||
self.set_current_ephemeral(Self::make_context("local", &config));
|
||||
// Mutate under the write lock, snapshotting the remaining map when
|
||||
// the removed context was current; the fallback Context is built
|
||||
// after the lock is released (same discipline as `set_current`).
|
||||
let fallback_contexts = {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if config.contexts.remove(name).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if name == config.context {
|
||||
// If we are removing the current context, fallback to local
|
||||
config.context = "local".to_string();
|
||||
Some(config.contexts.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
drop(config); // Drop write lock before saving
|
||||
self.save()?;
|
||||
if let Some(contexts) = fallback_contexts {
|
||||
self.set_current_ephemeral(Self::make_context("local", &contexts)?);
|
||||
}
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set current context from name (modifying configuration)
|
||||
pub fn set_current(&self, name: &str) -> io::Result<()> {
|
||||
let mut config = self.config.write().unwrap();
|
||||
if config.contexts.contains_key(name) {
|
||||
config.context = name.to_string();
|
||||
self.set_current_ephemeral(Self::make_context(name, &config));
|
||||
drop(config); // Drop write lock before saving
|
||||
self.save()?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
// Snapshot what `make_context` needs and release the lock before
|
||||
// building the Context. Building resolves parent contexts, and this
|
||||
// code path used to hold the config write guard while re-entering
|
||||
// the same lock for a read — a guaranteed deadlock on a
|
||||
// std::sync::RwLock (same-thread write-then-read).
|
||||
let contexts = self.config.read().unwrap().contexts.clone();
|
||||
if !contexts.contains_key(name) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{}' not found", name),
|
||||
))
|
||||
format!("Context '{name}' not found"),
|
||||
));
|
||||
}
|
||||
let context = Self::make_context(name, &contexts)?;
|
||||
|
||||
// Re-take the write lock briefly to commit. The name may have been
|
||||
// removed between snapshot and commit; report the same NotFound
|
||||
// error instead of persisting a dangling current-context reference.
|
||||
let mut config = self.config.write().unwrap();
|
||||
if !config.contexts.contains_key(name) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Context '{name}' not found"),
|
||||
));
|
||||
}
|
||||
config.context = name.to_string();
|
||||
drop(config); // Drop write lock before saving
|
||||
self.set_current_ephemeral(context);
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set current context, without modifying configuration
|
||||
pub fn set_current_ephemeral(&self, context: Context) {
|
||||
///
|
||||
/// Accepts either an owned [`Context`] or an already-shared
|
||||
/// `Arc<Context>`: callers that keep their own handle to the context
|
||||
/// they install (e.g. [`crate::deb::ephemeral::EphemeralContextGuard`])
|
||||
/// pass the Arc so they can restore exactly this context afterwards
|
||||
/// instead of relying on whatever happens to be current at that time.
|
||||
pub fn set_current_ephemeral(&self, context: impl Into<Arc<Context>>) {
|
||||
*self.context.write().unwrap() = context.into();
|
||||
}
|
||||
|
||||
|
||||
+263
-4
@@ -1,11 +1,17 @@
|
||||
mod api;
|
||||
pub(crate) mod capture;
|
||||
mod local;
|
||||
mod manager;
|
||||
mod schroot;
|
||||
pub(crate) mod shell;
|
||||
mod ssh;
|
||||
mod unshare;
|
||||
|
||||
pub use api::{Context, ContextCommand, ContextConfig};
|
||||
pub use api::{Context, ContextCommand, ContextConfig, LineSink, Stream};
|
||||
// The driver trait is implementation detail of the context API; it is only
|
||||
// needed crate-internally (test-run capture wrapper), so keep it out of the
|
||||
// public surface (and its documentation requirement).
|
||||
pub(crate) use api::ContextDriver;
|
||||
pub use manager::ContextManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,6 +25,49 @@ pub fn current() -> Arc<Context> {
|
||||
manager::MANAGER.current()
|
||||
}
|
||||
|
||||
/// Version-control metadata directories that must never leak into a
|
||||
/// prepared build tree.
|
||||
///
|
||||
/// Their presence flips autotools' "building from VCS" detection (e.g. GNU
|
||||
/// hello's `BUILD_FROM_GIT`, triggered by a `.git` directory next to
|
||||
/// `configure.ac`), which activates maintainer-only regeneration rules
|
||||
/// requiring tools that are deliberately not declared as build-dependencies
|
||||
/// (e.g. `help2man`). Source packages produced by dpkg-source never contain
|
||||
/// them, so package builds must not see them either.
|
||||
pub(crate) fn is_vcs_dir_name(name: &std::ffi::OsStr) -> bool {
|
||||
matches!(
|
||||
name.to_str(),
|
||||
Some(".git") | Some(".hg") | Some(".svn") | Some(".bzr") | Some("CVS")
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively remove version-control metadata directories below `root`.
|
||||
///
|
||||
/// Used after an overlay mount, where the source tree is exposed verbatim
|
||||
/// and entries cannot be filtered during the copy.
|
||||
pub(crate) fn prune_vcs_dirs(root: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
// symlink_metadata: never follow symlinks while pruning.
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
if is_vcs_dir_name(&entry.file_name()) {
|
||||
log::debug!("Removing VCS metadata from build tree: {}", path.display());
|
||||
std::fs::remove_dir_all(&path)?;
|
||||
} else {
|
||||
pending.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -31,7 +80,7 @@ mod tests {
|
||||
let src_file = temp_dir.path().join("src.txt");
|
||||
fs::write(&src_file, "local").unwrap();
|
||||
|
||||
let ctx = Context::new(ContextConfig::Local);
|
||||
let ctx = Context::new(ContextConfig::Local).unwrap();
|
||||
let dest = ctx.ensure_available(&src_file, "/tmp").unwrap();
|
||||
|
||||
// Should return a path that exists and has the same content
|
||||
@@ -110,10 +159,147 @@ mod tests {
|
||||
assert!(mgr.list_contexts().contains(&"local".to_string()));
|
||||
}
|
||||
|
||||
/// `set_current` on a context whose configuration carries a `parent`
|
||||
/// must complete without deadlocking: building the Context resolves the
|
||||
/// parent chain, which used to re-enter the config lock while
|
||||
/// `set_current` still held it for writing (a guaranteed deadlock on a
|
||||
/// std::sync::RwLock, same-thread write-then-read).
|
||||
#[test]
|
||||
fn test_set_current_parented_context_no_deadlock() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let mgr = Arc::new(ContextManager::with_path(temp_file.path().to_path_buf()));
|
||||
|
||||
mgr.add_context("base", ContextConfig::Local).unwrap();
|
||||
mgr.add_context(
|
||||
"child",
|
||||
ContextConfig::Schroot {
|
||||
name: "testchroot".to_string(),
|
||||
parent: Some("base".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run with a timeout so a regression fails fast instead of hanging
|
||||
// the test binary forever.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = {
|
||||
let mgr = mgr.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = mgr.set_current("child");
|
||||
tx.send(()).expect("receiver still waiting");
|
||||
result
|
||||
})
|
||||
};
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||
Ok(()) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
panic!("set_current() deadlocked building a parented context");
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
panic!("set_current() thread panicked before completing");
|
||||
}
|
||||
}
|
||||
worker.join().unwrap().unwrap();
|
||||
assert_eq!(mgr.current_name(), "child");
|
||||
}
|
||||
|
||||
/// A context referencing a missing parent must produce an error, not a
|
||||
/// panic (the parent lookup used to `.expect()`).
|
||||
#[test]
|
||||
fn test_set_current_dangling_parent_errors() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
|
||||
mgr.add_context(
|
||||
"orphan",
|
||||
ContextConfig::Unshare {
|
||||
path: "/some/chroot".to_string(),
|
||||
parent: Some("missing".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = mgr.set_current("orphan").unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
|
||||
// Nothing was committed: the current context is unchanged.
|
||||
assert_eq!(mgr.current_name(), "local");
|
||||
}
|
||||
|
||||
/// A parent cycle in a hand-edited configuration must be rejected with
|
||||
/// an error instead of recursing until the stack overflows.
|
||||
#[test]
|
||||
fn test_set_current_parent_cycle_errors() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let mgr = ContextManager::with_path(temp_file.path().to_path_buf());
|
||||
mgr.add_context(
|
||||
"a",
|
||||
ContextConfig::Schroot {
|
||||
name: "schroot-a".to_string(),
|
||||
parent: Some("b".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
mgr.add_context(
|
||||
"b",
|
||||
ContextConfig::Unshare {
|
||||
path: "/chroot-b".to_string(),
|
||||
parent: Some("a".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = mgr.set_current("a").unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// A corrupt contexts.json must not take the manager down:
|
||||
/// `load_config` falls back to the default (local-only) configuration,
|
||||
/// keeps the corrupt file in place and backs it up to contexts.json.bak
|
||||
/// so a later save cannot silently destroy its content.
|
||||
#[test]
|
||||
fn test_load_config_corrupt_file_falls_back_and_backs_up() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("contexts.json");
|
||||
let garbage = "{ this is definitely not valid json";
|
||||
fs::write(&config_path, garbage).unwrap();
|
||||
|
||||
let config = ContextManager::load_config(&config_path);
|
||||
|
||||
// Falls back to the default (local-only) configuration...
|
||||
assert_eq!(config.context, "local");
|
||||
assert!(config.contexts.contains_key("local"));
|
||||
|
||||
// ...preserving the corrupt file via the backup, original untouched.
|
||||
let backup_path = temp_dir.path().join("contexts.json.bak");
|
||||
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
|
||||
assert_eq!(fs::read_to_string(&config_path).unwrap(), garbage);
|
||||
|
||||
// A subsequent save replaces only the original, never the backup.
|
||||
let mgr = ContextManager::with_path(config_path.clone());
|
||||
mgr.add_context("newctx", ContextConfig::Local).unwrap();
|
||||
let rewritten = fs::read_to_string(&config_path).unwrap();
|
||||
serde_json::from_str::<super::manager::Config>(&rewritten).unwrap();
|
||||
assert_eq!(fs::read_to_string(&backup_path).unwrap(), garbage);
|
||||
}
|
||||
|
||||
/// A missing contexts.json yields the default configuration and writes
|
||||
/// nothing (no file, no backup) until an explicit save.
|
||||
#[test]
|
||||
fn test_load_config_missing_file_defaults() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("contexts.json");
|
||||
|
||||
let config = ContextManager::load_config(&config_path);
|
||||
|
||||
assert_eq!(config.context, "local");
|
||||
assert!(config.contexts.contains_key("local"));
|
||||
assert!(!config_path.exists());
|
||||
assert!(!temp_dir.path().join("contexts.json.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_file_ops() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let ctx = Context::new(ContextConfig::Local);
|
||||
let ctx = Context::new(ContextConfig::Local).unwrap();
|
||||
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
let content = "hello world";
|
||||
@@ -155,7 +341,7 @@ mod tests {
|
||||
fn test_context_copy_preserves_dangling_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let ctx = Context::new(ContextConfig::Local);
|
||||
let ctx = Context::new(ContextConfig::Local).unwrap();
|
||||
|
||||
let src_dir = temp_dir.path().join("src");
|
||||
std::fs::create_dir_all(&src_dir).unwrap();
|
||||
@@ -177,4 +363,77 @@ mod tests {
|
||||
std::path::Path::new("/nonexistent/target")
|
||||
);
|
||||
}
|
||||
|
||||
/// Copying a source tree into a build context must strip version-control
|
||||
/// metadata directories at any depth: their presence flips autotools
|
||||
/// "building from git" detection and activates maintainer-only rules
|
||||
/// needing undeclared tools (e.g. help2man for GNU hello's man page).
|
||||
#[test]
|
||||
fn test_ensure_available_strips_vcs_metadata() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let src_root = temp_dir.path().join("pkg");
|
||||
fs::create_dir_all(src_root.join(".git/objects")).unwrap();
|
||||
fs::write(src_root.join(".git/HEAD"), "ref: refs/heads/main").unwrap();
|
||||
fs::create_dir_all(src_root.join("src/.svn")).unwrap();
|
||||
fs::write(src_root.join("src/hello.c"), "int main() {}").unwrap();
|
||||
|
||||
let ctx = Context::new(ContextConfig::Local).unwrap();
|
||||
let dest = ctx.ensure_available(&src_root, "/tmp").unwrap();
|
||||
|
||||
assert!(dest.join("src/hello.c").exists());
|
||||
assert!(!dest.join(".git").exists());
|
||||
assert!(!dest.join("src/.svn").exists());
|
||||
}
|
||||
|
||||
/// The unshare driver maps context-relative paths onto the chroot root
|
||||
/// on the host: `is_dir` must answer through that mapping (a host-side
|
||||
/// stat of the unmapped path sees nothing), which is what lets the deb
|
||||
/// package-directory search classify staged entries.
|
||||
#[test]
|
||||
fn test_unshare_is_dir_maps_through_the_chroot_root() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(chroot.path().join("tmp/work/tree/debian")).unwrap();
|
||||
fs::write(chroot.path().join("tmp/work/orig.tar.xz"), "tar").unwrap();
|
||||
|
||||
let base = Context::new(ContextConfig::Local).unwrap();
|
||||
let ctx = Context::with_parent(
|
||||
ContextConfig::Unshare {
|
||||
path: chroot.path().to_string_lossy().to_string(),
|
||||
parent: None,
|
||||
},
|
||||
Arc::new(base),
|
||||
);
|
||||
|
||||
assert!(ctx.is_dir(std::path::Path::new("/tmp/work/tree")).unwrap());
|
||||
assert!(
|
||||
ctx.exists(std::path::Path::new("/tmp/work/tree/debian"))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!ctx.is_dir(std::path::Path::new("/tmp/work/orig.tar.xz"))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!ctx.exists(std::path::Path::new("/tmp/work/missing"))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// The overlay-mount path exposes the tree verbatim, so pruning happens
|
||||
/// after the fact: nested VCS metadata must be removed recursively.
|
||||
#[test]
|
||||
fn test_prune_vcs_dirs_removes_nested_metadata() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let root = temp_dir.path().join("tree");
|
||||
fs::create_dir_all(root.join("a/.git/objects")).unwrap();
|
||||
fs::create_dir_all(root.join("b/c/CVS")).unwrap();
|
||||
fs::write(root.join("a/.git/HEAD"), "ref").unwrap();
|
||||
fs::write(root.join("b/keep.txt"), "x").unwrap();
|
||||
|
||||
prune_vcs_dirs(&root).unwrap();
|
||||
|
||||
assert!(!root.join("a/.git").exists());
|
||||
assert!(!root.join("b/c/CVS").exists());
|
||||
assert!(root.join("b/keep.txt").exists());
|
||||
}
|
||||
}
|
||||
|
||||
+189
-84
@@ -1,6 +1,7 @@
|
||||
/// Schroot context: execute commands in a schroot session
|
||||
/// Not tested, will need more work!
|
||||
use super::api::ContextDriver;
|
||||
use super::api::{Context, ContextConfig, ContextDriver, LineSink};
|
||||
use super::shell::shell_quote;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -11,13 +12,12 @@ pub struct SchrootDriver {
|
||||
pub parent: Option<Arc<super::api::Context>>,
|
||||
}
|
||||
|
||||
use super::api::{Context, ContextConfig};
|
||||
|
||||
impl SchrootDriver {
|
||||
fn parent(&self) -> Arc<Context> {
|
||||
self.parent
|
||||
.clone()
|
||||
.unwrap_or_else(|| Arc::new(Context::new(ContextConfig::Local)))
|
||||
self.parent.clone().unwrap_or_else(|| {
|
||||
// ContextConfig::Local has no parent, so this cannot fail.
|
||||
Arc::new(Context::new(ContextConfig::Local).expect("Local context cannot fail"))
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_session(&self) -> io::Result<String> {
|
||||
@@ -72,6 +72,80 @@ impl SchrootDriver {
|
||||
.trim()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Build the `schroot` argument list executing `(program, args)` inside
|
||||
/// the given session.
|
||||
///
|
||||
/// When `preserve_env` is set, `-p` is passed so the host environment is
|
||||
/// preserved (historical behavior of `run()`, while `run_output()` does
|
||||
/// not preserve it).
|
||||
fn schroot_args(
|
||||
session_id: &str,
|
||||
preserve_env: bool,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let mut command_args = Vec::new();
|
||||
if preserve_env {
|
||||
command_args.push("-p".to_string());
|
||||
}
|
||||
command_args.extend([
|
||||
"-r".to_string(),
|
||||
"-c".to_string(),
|
||||
session_id.to_string(),
|
||||
"--".to_string(),
|
||||
]);
|
||||
|
||||
let (actual_program, actual_args) = Self::wrap_command(program, args, env, cwd);
|
||||
command_args.push(actual_program);
|
||||
command_args.extend(actual_args);
|
||||
command_args
|
||||
}
|
||||
|
||||
/// Wrap `(program, args)` in `sh -c` when a working directory or
|
||||
/// environment variables are needed.
|
||||
///
|
||||
/// Everything interpolated into the resulting shell string — the `cd`
|
||||
/// target, env keys and values, the program and each argument — is
|
||||
/// POSIX-shell-quoted (see [`shell_quote`]), so metacharacters (spaces,
|
||||
/// quotes, `$`, ...) can neither split words nor trigger expansion.
|
||||
fn wrap_command(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
) -> (String, Vec<String>) {
|
||||
let mut actual_program = program.to_string();
|
||||
let mut actual_args = args.to_vec();
|
||||
|
||||
if cwd.is_some() || !env.is_empty() {
|
||||
let mut shell_cmd = String::new();
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
shell_cmd.push_str(&format!("cd {} && ", shell_quote(dir)));
|
||||
}
|
||||
|
||||
if !env.is_empty() {
|
||||
shell_cmd.push_str("env ");
|
||||
for (k, v) in env {
|
||||
shell_cmd.push_str(&format!("{}={} ", shell_quote(k), shell_quote(v)));
|
||||
}
|
||||
}
|
||||
|
||||
shell_cmd.push_str(&shell_quote(program));
|
||||
for arg in args {
|
||||
shell_cmd.push(' ');
|
||||
shell_cmd.push_str(&shell_quote(arg));
|
||||
}
|
||||
|
||||
actual_program = "sh".to_string();
|
||||
actual_args = vec!["-c".to_string(), shell_cmd];
|
||||
}
|
||||
|
||||
(actual_program, actual_args)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextDriver for SchrootDriver {
|
||||
@@ -119,47 +193,26 @@ impl ContextDriver for SchrootDriver {
|
||||
cwd: Option<&str>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
let session_id = self.ensure_session()?;
|
||||
let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd);
|
||||
self.parent().command("schroot").args(cmd_args).status()
|
||||
}
|
||||
|
||||
// Construct the schroot command
|
||||
// schroot -p -r -c session_id -- program args...
|
||||
// If cwd is specified, we wrap in sh -c "cd cwd && ..."
|
||||
|
||||
let mut command_args = vec![
|
||||
"-p".to_string(),
|
||||
"-r".to_string(),
|
||||
"-c".to_string(),
|
||||
session_id,
|
||||
"--".to_string(),
|
||||
];
|
||||
|
||||
let mut actual_program = program.to_string();
|
||||
let mut actual_args = args.to_vec();
|
||||
|
||||
// Simplest: Wrap everything in `sh -c` if CWD or ENV is needed.
|
||||
if cwd.is_some() || !env.is_empty() {
|
||||
let mut shell_cmd = String::new();
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
shell_cmd.push_str(&format!("cd {} && ", dir));
|
||||
}
|
||||
|
||||
if !env.is_empty() {
|
||||
shell_cmd.push_str("env ");
|
||||
for (k, v) in env {
|
||||
shell_cmd.push_str(&format!("{}={} ", k, v));
|
||||
}
|
||||
}
|
||||
|
||||
shell_cmd.push_str(&format!("{} {}", program, args.join(" ")));
|
||||
|
||||
actual_program = "sh".to_string();
|
||||
actual_args = vec!["-c".to_string(), shell_cmd];
|
||||
}
|
||||
|
||||
command_args.push(actual_program);
|
||||
command_args.extend(actual_args);
|
||||
|
||||
self.parent().command("schroot").args(command_args).status()
|
||||
fn run_captured(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
sink: Arc<dyn LineSink>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
let session_id = self.ensure_session()?;
|
||||
let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd);
|
||||
// Forward the sink to the wrapping command so capture chains through
|
||||
// the parent context driver (e.g. schroot over ssh).
|
||||
let parent = self.parent();
|
||||
let mut cmd = parent.command("schroot");
|
||||
cmd.args(cmd_args).capture(sink);
|
||||
cmd.status()
|
||||
}
|
||||
|
||||
fn run_output(
|
||||
@@ -170,41 +223,8 @@ impl ContextDriver for SchrootDriver {
|
||||
cwd: Option<&str>,
|
||||
) -> io::Result<std::process::Output> {
|
||||
let session_id = self.ensure_session()?;
|
||||
|
||||
let mut command_args = vec![
|
||||
"-r".to_string(),
|
||||
"-c".to_string(),
|
||||
session_id,
|
||||
"--".to_string(),
|
||||
];
|
||||
|
||||
let mut actual_program = program.to_string();
|
||||
let mut actual_args = args.to_vec();
|
||||
|
||||
if cwd.is_some() || !env.is_empty() {
|
||||
let mut shell_cmd = String::new();
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
shell_cmd.push_str(&format!("cd {} && ", dir));
|
||||
}
|
||||
|
||||
if !env.is_empty() {
|
||||
shell_cmd.push_str("env ");
|
||||
for (k, v) in env {
|
||||
shell_cmd.push_str(&format!("{}={} ", k, v));
|
||||
}
|
||||
}
|
||||
|
||||
shell_cmd.push_str(&format!("{} {}", program, args.join(" ")));
|
||||
|
||||
actual_program = "sh".to_string();
|
||||
actual_args = vec!["-c".to_string(), shell_cmd];
|
||||
}
|
||||
|
||||
command_args.push(actual_program);
|
||||
command_args.extend(actual_args);
|
||||
|
||||
self.parent().command("schroot").args(command_args).output()
|
||||
let cmd_args = Self::schroot_args(&session_id, false, program, args, env, cwd);
|
||||
self.parent().command("schroot").args(cmd_args).output()
|
||||
}
|
||||
|
||||
fn create_temp_dir(&self) -> io::Result<String> {
|
||||
@@ -249,9 +269,13 @@ impl ContextDriver for SchrootDriver {
|
||||
&[
|
||||
"-c".to_string(),
|
||||
format!(
|
||||
"echo -ne '{}' > '{}'",
|
||||
content.replace("'", "'\\''"),
|
||||
path.to_string_lossy()
|
||||
// `printf '%s'` writes the content verbatim (the previous
|
||||
// `echo -ne` mangled backslashes, and dash's echo prints
|
||||
// "-ne" literally). Content and path are shell-quoted so
|
||||
// metacharacters in either cannot break out.
|
||||
"printf '%s' {} > {}",
|
||||
shell_quote(content),
|
||||
shell_quote(&path.to_string_lossy())
|
||||
),
|
||||
],
|
||||
&[],
|
||||
@@ -272,4 +296,85 @@ impl ContextDriver for SchrootDriver {
|
||||
)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let status = self.run(
|
||||
"test",
|
||||
&["-d".to_string(), path.to_string_lossy().to_string()],
|
||||
&[],
|
||||
None,
|
||||
)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SchrootDriver;
|
||||
|
||||
/// Without cwd/env the program and args go to schroot as direct argv
|
||||
/// (no shell involved), so they must pass through untouched.
|
||||
#[test]
|
||||
fn wrap_command_passthrough_without_env_or_cwd() {
|
||||
let (prog, args) = SchrootDriver::wrap_command(
|
||||
"make",
|
||||
&["install".to_string(), "DEST=x y".to_string()],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(prog, "make");
|
||||
assert_eq!(args, vec!["install".to_string(), "DEST=x y".to_string()]);
|
||||
}
|
||||
|
||||
/// A value with a space must stay a single env assignment: previously
|
||||
/// DEB_BUILD_OPTIONS="parallel=4 nocheck" made sh treat `nocheck` as
|
||||
/// the command to run.
|
||||
#[test]
|
||||
fn wrap_command_quotes_env_values_cwd_and_args() {
|
||||
let (prog, args) = SchrootDriver::wrap_command(
|
||||
"dpkg-buildpackage",
|
||||
&["-us".to_string(), "-uc".to_string()],
|
||||
&[(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
"parallel=4 nocheck".to_string(),
|
||||
)],
|
||||
Some("/build/pkg 1.0"),
|
||||
);
|
||||
assert_eq!(prog, "sh");
|
||||
assert_eq!(args[0], "-c");
|
||||
assert_eq!(
|
||||
args[1],
|
||||
"cd '/build/pkg 1.0' && env 'DEB_BUILD_OPTIONS'='parallel=4 nocheck' \
|
||||
'dpkg-buildpackage' '-us' '-uc'"
|
||||
);
|
||||
}
|
||||
|
||||
/// The definitive check: a real shell must execute the wrapped command
|
||||
/// exactly as intended — cwd applied, env set verbatim, the inner
|
||||
/// program invoked with its argument — despite quotes in the values.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn wrapped_command_survives_shell_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (prog, args) = SchrootDriver::wrap_command(
|
||||
"printenv",
|
||||
&["SOME_OPT".to_string()],
|
||||
&[(
|
||||
"SOME_OPT".to_string(),
|
||||
"parallel=4 noch'eck \"x\"".to_string(),
|
||||
)],
|
||||
Some(dir.path().to_str().unwrap()),
|
||||
);
|
||||
let output = std::process::Command::new(&prog)
|
||||
.arg(&args[0])
|
||||
.arg(&args[1])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
// printenv's output ends with a newline.
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"parallel=4 noch'eck \"x\"\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! POSIX-shell quoting for command strings assembled by the remote/chroot
|
||||
//! execution contexts.
|
||||
//!
|
||||
//! Unlike [`super::local`] — which spawns programs directly through
|
||||
//! `std::process::Command`, with no shell in between — the SSH, schroot and
|
||||
//! unshare drivers ultimately hand a *string* to a shell (`ssh
|
||||
//! channel.exec`, `sh -c`, `bash -c`). Every program name, argument,
|
||||
//! path or environment value interpolated into such a string must be
|
||||
//! quoted, or shell metacharacters (`;`, `|`, `&`, quotes, `$`, backticks,
|
||||
//! globs, whitespace, ...) are reinterpreted by the shell: at best the
|
||||
//! command breaks, at worst it executes injected input.
|
||||
|
||||
/// Quote `s` for safe interpolation into a POSIX shell command line.
|
||||
///
|
||||
/// The result is `s` wrapped in single quotes, with every embedded single
|
||||
/// quote replaced by the standard `'\''` sequence (close the quoting, an
|
||||
/// escaped literal quote, reopen). Whatever the input contains — spaces,
|
||||
/// newlines, `"`, `'`, `$`, backticks, globs, `;` — the shell parses the
|
||||
/// result back into exactly `s` as a single word. The empty string becomes
|
||||
/// `''` (one empty argument, not zero arguments).
|
||||
///
|
||||
/// Use this for *every* value interpolated into a shell command string:
|
||||
/// programs, arguments, `cd` targets, `env` assignments (both key and
|
||||
/// value) and paths. It is safe (though redundant) to quote values that are
|
||||
/// known to need no quoting.
|
||||
pub(crate) fn shell_quote(s: &str) -> String {
|
||||
let mut quoted = String::with_capacity(s.len() + 2);
|
||||
quoted.push('\'');
|
||||
for c in s.chars() {
|
||||
if c == '\'' {
|
||||
quoted.push_str("'\\''");
|
||||
} else {
|
||||
quoted.push(c);
|
||||
}
|
||||
}
|
||||
quoted.push('\'');
|
||||
quoted
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shell_quote;
|
||||
|
||||
#[test]
|
||||
fn plain_word() {
|
||||
assert_eq!(shell_quote("plain"), "'plain'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_stay_one_word() {
|
||||
assert_eq!(shell_quote("parallel=4 nocheck"), "'parallel=4 nocheck'");
|
||||
assert_eq!(
|
||||
shell_quote(" leading and trailing "),
|
||||
"' leading and trailing '"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_single_quotes() {
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
assert_eq!(shell_quote("''"), r"''\'''\'''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quotes_and_metacharacters() {
|
||||
assert_eq!(
|
||||
shell_quote("say \"hi\" $HOME `id` ; | & * ?"),
|
||||
"'say \"hi\" $HOME `id` ; | & * ?'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_and_backtick_do_not_expand() {
|
||||
assert_eq!(shell_quote("$HOME"), "'$HOME'");
|
||||
assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
|
||||
assert_eq!(shell_quote("`touch /tmp/pwned`"), "'`touch /tmp/pwned`'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string() {
|
||||
assert_eq!(shell_quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_preserved() {
|
||||
assert_eq!(shell_quote("héllo→wörld ✓"), "'héllo→wörld ✓'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newlines_preserved() {
|
||||
assert_eq!(shell_quote("a\nb"), "'a\nb'");
|
||||
}
|
||||
|
||||
/// The definitive check: a real shell must parse the quoted string back
|
||||
/// into the original value as a single argument, without expanding or
|
||||
/// executing anything inside it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trips_through_sh() {
|
||||
let tricky = "a'b\"c $HOME `echo pwned` ; | & \n x*y";
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("printf '%s' {}", shell_quote(tricky)))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), tricky);
|
||||
}
|
||||
}
|
||||
+178
-35
@@ -1,6 +1,8 @@
|
||||
/// SSH context: execute commands over an SSH connection
|
||||
/// Context driver: Copies over SFTP with ssh2, executes commands over ssh2 channels
|
||||
use super::api::ContextDriver;
|
||||
use super::api::{ContextDriver, LineSink, Stream};
|
||||
use super::capture::pump;
|
||||
use super::shell::shell_quote;
|
||||
use log::debug;
|
||||
use ssh2;
|
||||
use std::fs;
|
||||
@@ -11,6 +13,7 @@ use std::net::TcpStream;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn connect_ssh(host: &str, user: Option<&str>, port: Option<u16>) -> io::Result<ssh2::Session> {
|
||||
let port = port.unwrap_or(22);
|
||||
@@ -51,6 +54,41 @@ pub struct SshDriver {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl SshDriver {
|
||||
/// Build the remote shell command line: `export` assignments for `env`,
|
||||
/// an optional `cd` to `cwd`, then `program` with its `args`.
|
||||
///
|
||||
/// The line is executed verbatim by the remote login shell through
|
||||
/// `channel.exec`, so every component is POSIX-shell-quoted (see
|
||||
/// [`shell_quote`]): metacharacters in arguments, paths or environment
|
||||
/// values can neither break out of their word nor be expanded by the
|
||||
/// remote shell.
|
||||
fn build_command_line(
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
) -> String {
|
||||
let mut cmd_line = String::new();
|
||||
for (key, value) in env {
|
||||
cmd_line.push_str(&format!(
|
||||
"export {}={}; ",
|
||||
shell_quote(key),
|
||||
shell_quote(value)
|
||||
));
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
cmd_line.push_str(&format!("cd {} && ", shell_quote(dir)));
|
||||
}
|
||||
cmd_line.push_str(&shell_quote(program));
|
||||
for arg in args {
|
||||
cmd_line.push(' ');
|
||||
cmd_line.push_str(&shell_quote(arg));
|
||||
}
|
||||
cmd_line
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextDriver for SshDriver {
|
||||
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf> {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
@@ -104,22 +142,7 @@ impl ContextDriver for SshDriver {
|
||||
|
||||
// Construct command line with env vars
|
||||
// TODO: No, use ssh2 channel.set_env
|
||||
let mut cmd_line = String::new();
|
||||
for (key, value) in env {
|
||||
cmd_line.push_str(&format!(
|
||||
"export {}='{}'; ",
|
||||
key,
|
||||
value.replace("'", "'\\''")
|
||||
));
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
cmd_line.push_str(&format!("cd {} && ", dir));
|
||||
}
|
||||
cmd_line.push_str(program);
|
||||
for arg in args {
|
||||
cmd_line.push(' ');
|
||||
cmd_line.push_str(arg); // TODO: escape
|
||||
}
|
||||
let cmd_line = Self::build_command_line(env, cwd, program, args);
|
||||
|
||||
debug!("Executing SSH command: {}", cmd_line);
|
||||
|
||||
@@ -139,6 +162,40 @@ impl ContextDriver for SshDriver {
|
||||
Ok(ExitStatus::from_raw(code))
|
||||
}
|
||||
|
||||
fn run_captured(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
sink: Arc<dyn LineSink>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let mut channel = sess.channel_session().map_err(io::Error::other)?;
|
||||
|
||||
// Construct command line with env vars (same quoting as `run`)
|
||||
let cmd_line = Self::build_command_line(env, cwd, program, args);
|
||||
|
||||
debug!("Executing SSH command (captured): {}", cmd_line);
|
||||
|
||||
// Keep the PTY: it keeps tool output line-buffered (better for live
|
||||
// display) and merges stdout/stderr into a single ordered stream,
|
||||
// which ssh2 cannot read concurrently anyway.
|
||||
channel
|
||||
.request_pty("xterm", None, None)
|
||||
.map_err(|e| io::Error::other(format!("Failed to request PTY: {}", e)))?;
|
||||
|
||||
channel.exec(&cmd_line).map_err(io::Error::other)?;
|
||||
|
||||
let mut stdout_stream = channel.stream(0);
|
||||
pump(&mut stdout_stream, Stream::Stdout, sink.as_ref());
|
||||
|
||||
channel.wait_close().map_err(io::Error::other)?;
|
||||
|
||||
let code = channel.exit_status().unwrap_or(-1);
|
||||
Ok(ExitStatus::from_raw(code))
|
||||
}
|
||||
|
||||
fn run_output(
|
||||
&self,
|
||||
program: &str,
|
||||
@@ -149,23 +206,8 @@ impl ContextDriver for SshDriver {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let mut channel = sess.channel_session().map_err(io::Error::other)?;
|
||||
|
||||
// Construct command line with env vars
|
||||
let mut cmd_line = String::new();
|
||||
for (key, value) in env {
|
||||
cmd_line.push_str(&format!(
|
||||
"export {}='{}'; ",
|
||||
key,
|
||||
value.replace("'", "'\\''")
|
||||
));
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
cmd_line.push_str(&format!("cd {} && ", dir));
|
||||
}
|
||||
cmd_line.push_str(program);
|
||||
for arg in args {
|
||||
cmd_line.push(' ');
|
||||
cmd_line.push_str(arg); // TODO: escape
|
||||
}
|
||||
// Construct command line with env vars (same quoting as `run`)
|
||||
let cmd_line = Self::build_command_line(env, cwd, program, args);
|
||||
|
||||
channel.exec(&cmd_line).map_err(io::Error::other)?;
|
||||
|
||||
@@ -215,7 +257,11 @@ impl ContextDriver for SshDriver {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let mut channel = sess.channel_session().map_err(io::Error::other)?;
|
||||
// TODO: use sftp
|
||||
let cmd = format!("cp -a {:?} {:?}", src, dest);
|
||||
let cmd = format!(
|
||||
"cp -a {} {}",
|
||||
shell_quote(&src.to_string_lossy()),
|
||||
shell_quote(&dest.to_string_lossy())
|
||||
);
|
||||
debug!("Executing remote copy: {}", cmd);
|
||||
channel.exec(&cmd).map_err(io::Error::other)?;
|
||||
channel.wait_close().map_err(io::Error::other)?;
|
||||
@@ -242,6 +288,13 @@ impl ContextDriver for SshDriver {
|
||||
}
|
||||
let mut remote_file = sftp.create(path).map_err(io::Error::other)?;
|
||||
remote_file.write_all(content.as_bytes())?;
|
||||
// Close explicitly: the `Drop` impl of `ssh2::File` discards a
|
||||
// close-time error ("too late to recover"), silently truncating the
|
||||
// remote file. Writes are unbuffered (`Write::flush` is a no-op), so
|
||||
// no flush is needed before closing.
|
||||
remote_file.close().map_err(|e| {
|
||||
io::Error::other(format!("Failed to close remote file {:?}: {}", path, e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -253,6 +306,14 @@ impl ContextDriver for SshDriver {
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let sftp = sess.sftp().map_err(io::Error::other)?;
|
||||
// Same error tolerance as `exists`: an unreachable path is not a
|
||||
// directory, and the caller decides what absence means.
|
||||
Ok(sftp.stat(path).map(|stat| stat.is_dir()).unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
impl SshDriver {
|
||||
@@ -264,6 +325,14 @@ impl SshDriver {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
// Skip VCS metadata (see is_vcs_dir_name): shipping it into
|
||||
// the remote build tree would flip autotools 'building from
|
||||
// VCS' detection and activate maintainer-only rules.
|
||||
if path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false)
|
||||
&& super::is_vcs_dir_name(&name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dest_path = dest.join(name);
|
||||
Self::upload_recursive(sftp, &path, &dest_path)?;
|
||||
}
|
||||
@@ -273,6 +342,18 @@ impl SshDriver {
|
||||
io::Error::other(format!("Failed to create remote file {:?}: {}", dest, e))
|
||||
})?;
|
||||
io::copy(&mut file, &mut remote_file)?;
|
||||
// Close explicitly: quota-exceeded and similar failures only
|
||||
// surface in the final ACKs and the close handshake, and the
|
||||
// `Drop` impl of `ssh2::File` discards that error ("too late to
|
||||
// recover"), leaving a truncated remote file behind. Writes are
|
||||
// unbuffered (`ssh2::File`'s `Write::flush` is a no-op), so no
|
||||
// flush is needed before closing.
|
||||
remote_file.close().map_err(|e| {
|
||||
io::Error::other(format!(
|
||||
"Failed to close remote file {:?} after upload: {}",
|
||||
dest, e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -301,3 +382,65 @@ impl SshDriver {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SshDriver;
|
||||
|
||||
/// Program, arguments and the `cd` target must each be a single,
|
||||
/// quoted word; `$` in the cwd must not be expanded.
|
||||
#[test]
|
||||
fn command_line_quotes_program_args_and_cwd() {
|
||||
let line = SshDriver::build_command_line(
|
||||
&[],
|
||||
Some("/tmp/some dir/$HOST"),
|
||||
"make",
|
||||
&["install".to_string(), "PREFIX=/opt/my app".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
line,
|
||||
"cd '/tmp/some dir/$HOST' && 'make' 'install' 'PREFIX=/opt/my app'"
|
||||
);
|
||||
}
|
||||
|
||||
/// Env keys and values are quoted too (values used to be escaped by
|
||||
/// hand, keys and everything else not at all).
|
||||
#[test]
|
||||
fn command_line_quotes_env_keys_and_values() {
|
||||
let line = SshDriver::build_command_line(
|
||||
&[(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
"parallel=4 nocheck".to_string(),
|
||||
)],
|
||||
None,
|
||||
"dpkg-buildpackage",
|
||||
&["-us".to_string(), "-uc".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
line,
|
||||
"export 'DEB_BUILD_OPTIONS'='parallel=4 nocheck'; 'dpkg-buildpackage' '-us' '-uc'"
|
||||
);
|
||||
}
|
||||
|
||||
/// The definitive check: a real shell must execute the assembled line
|
||||
/// exactly as intended — one argument through, one env value verbatim —
|
||||
/// even when both contain quotes, spaces and `$`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_line_survives_shell_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let line = SshDriver::build_command_line(
|
||||
&[("OPT".to_string(), "a b'c \"$d\"".to_string())],
|
||||
Some(dir.path().to_str().unwrap()),
|
||||
"printenv",
|
||||
&["OPT".to_string()],
|
||||
);
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&line)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "a b'c \"$d\"\n");
|
||||
}
|
||||
}
|
||||
|
||||
+141
-29
@@ -1,4 +1,5 @@
|
||||
use super::api::{Context, ContextCommand, ContextDriver};
|
||||
use super::api::{Context, ContextCommand, ContextDriver, LineSink};
|
||||
use super::shell::shell_quote;
|
||||
use log::debug;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -55,6 +56,14 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// Never ship VCS metadata into the build tree: its presence flips
|
||||
// autotools 'building from VCS' detection (see is_vcs_dir_name) and
|
||||
// activates maintainer-only regeneration rules requiring tools that
|
||||
// are not declared build-dependencies (e.g. help2man).
|
||||
if metadata.is_dir() && super::is_vcs_dir_name(&entry.file_name()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.file_type().is_symlink() {
|
||||
let target = std::fs::read_link(&src_path).map_err(|e| {
|
||||
io::Error::new(
|
||||
@@ -259,6 +268,22 @@ impl ContextDriver for UnshareDriver {
|
||||
self.command(program, args, env, cwd).status()
|
||||
}
|
||||
|
||||
fn run_captured(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[String],
|
||||
env: &[(String, String)],
|
||||
cwd: Option<&str>,
|
||||
sink: Arc<dyn LineSink>,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
// Forward the sink to the wrapping command: the parent context driver
|
||||
// is responsible for the actual capture (and may chain further, e.g.
|
||||
// unshare over ssh).
|
||||
let mut cmd = self.command(program, args, env, cwd);
|
||||
cmd.capture(sink);
|
||||
cmd.status()
|
||||
}
|
||||
|
||||
fn run_output(
|
||||
&self,
|
||||
program: &str,
|
||||
@@ -271,37 +296,41 @@ impl ContextDriver for UnshareDriver {
|
||||
|
||||
fn create_temp_dir(&self) -> io::Result<String> {
|
||||
// Create a temporary directory inside the chroot with unique naming
|
||||
// Sub-second precision and an atomic create, like the local
|
||||
// driver: concurrent callers racing on the same name must not
|
||||
// share a directory, so an existing target falls through to the
|
||||
// next attempt instead of a probe-then-create window.
|
||||
let base_timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
.as_millis();
|
||||
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let work_dir_name = if attempt == 0 {
|
||||
format!("pkh-build-{}", base_timestamp)
|
||||
format!("pkh-build-{base_timestamp}")
|
||||
} else {
|
||||
format!("pkh-build-{}-{}", base_timestamp, attempt)
|
||||
format!("pkh-build-{base_timestamp}-{attempt}")
|
||||
};
|
||||
|
||||
let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name);
|
||||
let work_dir_inside_chroot = format!("/tmp/{work_dir_name}");
|
||||
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
|
||||
|
||||
// Check if directory already exists
|
||||
if host_path.exists() {
|
||||
attempt += 1;
|
||||
continue;
|
||||
match std::fs::create_dir(&host_path) {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Created work directory: {} (host: {})",
|
||||
work_dir_inside_chroot,
|
||||
host_path.display()
|
||||
);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Create the directory on the host filesystem
|
||||
std::fs::create_dir_all(&host_path)?;
|
||||
|
||||
debug!(
|
||||
"Created work directory: {} (host: {})",
|
||||
work_dir_inside_chroot,
|
||||
host_path.display()
|
||||
);
|
||||
|
||||
// Return the path as it appears inside the chroot
|
||||
return Ok(work_dir_inside_chroot);
|
||||
}
|
||||
@@ -327,6 +356,11 @@ impl ContextDriver for UnshareDriver {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
self.parent().exists(&host_path)
|
||||
}
|
||||
|
||||
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
Ok(host_path.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
impl UnshareDriver {
|
||||
@@ -412,6 +446,20 @@ impl UnshareDriver {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(dest_path.to_path_buf());
|
||||
|
||||
// The overlay exposes the source tree verbatim: strip VCS metadata
|
||||
// (e.g. '.git') whose presence would flip autotools 'building from
|
||||
// git' detection and activate maintainer-only regeneration rules
|
||||
// (see is_vcs_dir_name). Removals land as whiteouts in the upper
|
||||
// layer, leaving the lowerdir untouched.
|
||||
if let Err(e) = super::prune_vcs_dirs(dest_path) {
|
||||
log::warn!(
|
||||
"Failed to prune VCS metadata from '{}': {}",
|
||||
dest_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -446,21 +494,85 @@ impl UnshareDriver {
|
||||
|
||||
// Build the bash command: set up /dev/pts and run the program
|
||||
// /proc should already be bind-mounted from the host before entering the namespace
|
||||
let program_args = args
|
||||
.iter()
|
||||
.map(|a| format!("\"{a}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
cmd.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"mkdir -p /dev/pts; mount -t devpts devpts /dev/pts 2>/dev/null || true; touch /dev/ptmx; mount --bind /dev/pts/ptmx /dev/ptmx 2>/dev/null || true; {} {}",
|
||||
program,
|
||||
program_args
|
||||
));
|
||||
.arg(build_namespace_script(program, args));
|
||||
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shell script executed by `bash -c` inside the user namespace:
|
||||
/// bring up `/dev/pts`, then run `program` with `args`.
|
||||
///
|
||||
/// The script is parsed by bash, so the program and every argument are
|
||||
/// POSIX-shell-quoted (see [`shell_quote`]): quotes, `$`, backticks or
|
||||
/// whitespace inside them can neither split the command into different
|
||||
/// words nor trigger expansion. (Previously arguments were wrapped in
|
||||
/// unescaped double quotes, so a `"` in an argument broke out and
|
||||
/// `$`/backticks still expanded.)
|
||||
fn build_namespace_script(program: &str, args: &[String]) -> String {
|
||||
let mut script = String::from(
|
||||
"mkdir -p /dev/pts; mount -t devpts devpts /dev/pts 2>/dev/null || true; touch /dev/ptmx; mount --bind /dev/pts/ptmx /dev/ptmx 2>/dev/null || true; ",
|
||||
);
|
||||
script.push_str(&shell_quote(program));
|
||||
for arg in args {
|
||||
script.push(' ');
|
||||
script.push_str(&shell_quote(arg));
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_namespace_script;
|
||||
|
||||
fn tail_after_devpts_setup(script: &str) -> &str {
|
||||
script
|
||||
.rsplit_once("|| true; ")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap()
|
||||
.trim_end()
|
||||
}
|
||||
|
||||
/// Program and arguments must each be a single, quoted word at the end
|
||||
/// of the `/dev/pts` setup script.
|
||||
#[test]
|
||||
fn script_quotes_program_and_args() {
|
||||
let script = build_namespace_script(
|
||||
"make",
|
||||
&[
|
||||
"install".to_string(),
|
||||
"a b".to_string(),
|
||||
"PREFIX=/opt/my app".to_string(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
tail_after_devpts_setup(&script),
|
||||
"'make' 'install' 'a b' 'PREFIX=/opt/my app'"
|
||||
);
|
||||
}
|
||||
|
||||
/// An argument containing a double quote must not break out of the
|
||||
/// script (arguments used to be wrapped in unescaped `"`), and `$`/
|
||||
/// backticks must stay literal for bash.
|
||||
#[test]
|
||||
fn script_neutralizes_quotes_and_expansions() {
|
||||
let script = build_namespace_script(
|
||||
"echo",
|
||||
&["$(touch /tmp/pwned) `id` \"; rm -rf /\"".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
tail_after_devpts_setup(&script),
|
||||
"'echo' '$(touch /tmp/pwned) `id` \"; rm -rf /\"'"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty arguments must survive as one empty word (`''`), not vanish.
|
||||
#[test]
|
||||
fn script_preserves_empty_args() {
|
||||
let script = build_namespace_script("prog", &[String::new(), "x".to_string()]);
|
||||
assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'");
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
//! Embedding convention for the static reference data files (`data/*.yml`)
|
||||
//!
|
||||
//! Reference data that changes independently of the code — distro series
|
||||
//! pointers, pinned SSH host keys, package quirks — lives in YAML files
|
||||
//! under `data/` at the repo root instead of hardcoded in the source, so
|
||||
//! it is updatable in one reviewable place.
|
||||
//!
|
||||
//! This module is deliberately not a central registry: each file is
|
||||
//! embedded by the module that owns it (distro_info.rs owns
|
||||
//! data/distro_info.yml, launchpad.rs owns data/launchpad.yml,
|
||||
//! apt/keyring.rs owns data/keyserver.yml, new/origin.rs owns
|
||||
//! data/forges.yml, put/ssh.rs owns data/host_keys.yml, quirks.rs owns
|
||||
//! data/quirks.yml) through the [`embed_data!`] macro below, so data
|
||||
//! and its accessors stay together and a diff touching one domain cannot
|
||||
//! half-touch another. The macro embeds the file at compile time and
|
||||
//! parses it once into a `lazy_static` on first use; since the data ships
|
||||
//! inside the binary, a parse failure is a build-time bug that cannot be
|
||||
//! recovered from at runtime, and the macro panics on it.
|
||||
//!
|
||||
//! Paths and URLs in the data files carry their variable parts as `{name}`
|
||||
//! placeholders, substituted with `str::replace` at the use site — no
|
||||
//! template engine.
|
||||
|
||||
/// Embed one YAML data file as a lazily-parsed static, following the
|
||||
/// convention documented at the module level.
|
||||
///
|
||||
/// Takes the visibility of the generated static (none for private, `pub` or
|
||||
/// `pub(crate)`-style), its name, its struct type (which stays defined in
|
||||
/// the owning module, next to its accessors) and the file path relative to
|
||||
/// the invoking source file (`"../data/distro_info.yml"` from
|
||||
/// `src/distro_info.rs`, `"../../data/host_keys.yml"` from
|
||||
/// `src/put/ssh.rs`, ...), and expands to the house `include_str!` →
|
||||
/// `lazy_static` → parse pattern — only the embed+parse boilerplate is
|
||||
/// generated.
|
||||
///
|
||||
/// ```ignore
|
||||
/// embed_data! {
|
||||
/// static ref MY_DATA: MyData = "../data/my_data.yml"
|
||||
/// }
|
||||
/// ```
|
||||
macro_rules! embed_data {
|
||||
// Internal arm: the visibility arrives wrapped in parentheses (empty for
|
||||
// private statics) because `lazy_static!` only re-matches literal
|
||||
// `pub`/`pub(...)` token sequences, not an opaque forwarded `vis`.
|
||||
(@expand ($($vis:tt)*) static ref $name:ident : $ty:ty = $path:literal) => {
|
||||
lazy_static::lazy_static! {
|
||||
// The YAML is include_str!'d at compile time and statically
|
||||
// valid; if it ever failed to parse it would be a build-time bug
|
||||
// that cannot be recovered from at runtime, so panicking here is
|
||||
// acceptable.
|
||||
$($vis)* static ref $name: $ty = serde_yaml::from_str(include_str!($path))
|
||||
.expect(concat!(
|
||||
"built-in ",
|
||||
$path,
|
||||
" data is statically valid and must parse"
|
||||
));
|
||||
}
|
||||
};
|
||||
(static ref $name:ident : $ty:ty = $path:literal) => {
|
||||
$crate::data::embed_data!(@expand () static ref $name : $ty = $path);
|
||||
};
|
||||
(pub static ref $name:ident : $ty:ty = $path:literal) => {
|
||||
$crate::data::embed_data!(@expand (pub) static ref $name : $ty = $path);
|
||||
};
|
||||
(pub ($($vis:tt)+) static ref $name:ident : $ty:ty = $path:literal) => {
|
||||
$crate::data::embed_data!(@expand (pub ($($vis)+)) static ref $name : $ty = $path);
|
||||
};
|
||||
}
|
||||
|
||||
/// Makes the macro available through the module path
|
||||
/// (`use crate::data::embed_data;`)
|
||||
pub(crate) use embed_data;
|
||||
+250
-107
@@ -1,24 +1,19 @@
|
||||
use crate::context::Context;
|
||||
use log::debug;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Set environment variables for cross-compilation
|
||||
pub fn setup_environment(
|
||||
env: &mut HashMap<String, String>,
|
||||
arch: &str,
|
||||
ctx: Arc<Context>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let dpkg_architecture = String::from_utf8(
|
||||
ctx.command("dpkg-architecture")
|
||||
.arg("-a")
|
||||
.arg(arch)
|
||||
.output()?
|
||||
.stdout,
|
||||
)?;
|
||||
/// Parse 'dpkg-architecture' output (KEY=value lines) into a set of
|
||||
/// environment variables. Unexpected lines (e.g. warnings on stderr leaking
|
||||
/// into stdout) are skipped instead of causing a failure.
|
||||
fn parse_dpkg_architecture_output(output: &str, env: &mut HashMap<String, String>) {
|
||||
let env_var_regex = regex::Regex::new(r"(?<key>.*)=(?<value>.*)").unwrap();
|
||||
for l in dpkg_architecture.lines() {
|
||||
let capture = env_var_regex.captures(l).unwrap();
|
||||
for l in output.lines() {
|
||||
let Some(capture) = env_var_regex.captures(l) else {
|
||||
debug!("Skipping unexpected dpkg-architecture output line: '{l}'");
|
||||
continue;
|
||||
};
|
||||
let key = capture.name("key").unwrap().as_str().to_string();
|
||||
let value = capture.name("value").unwrap().as_str().to_string();
|
||||
|
||||
@@ -28,13 +23,85 @@ pub fn setup_environment(
|
||||
env.insert("CROSS_COMPILE".to_string(), format!("{value}-"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set environment variables for cross-compilation
|
||||
pub fn setup_environment(
|
||||
env: &mut HashMap<String, String>,
|
||||
arch: &str,
|
||||
ctx: Arc<Context>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let output = ctx
|
||||
.command("dpkg-architecture")
|
||||
.arg("-a")
|
||||
.arg(arch)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to run 'dpkg-architecture -a {arch}': {e}. \
|
||||
Is 'dpkg-dev' installed?"
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"'dpkg-architecture -a {}' failed with status: {}.{}",
|
||||
arch,
|
||||
output.status,
|
||||
if stderr.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\ndpkg-architecture output:\n{}", stderr.trim())
|
||||
}
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let dpkg_architecture = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
|
||||
parse_dpkg_architecture_output(&dpkg_architecture, env);
|
||||
|
||||
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The suites a cross-build environment enables for `series`: the series
|
||||
/// itself plus the distro data's cross pockets (`<series>-updates`,
|
||||
/// `<series>-backports`, `<series>-security`), plus the explicitly
|
||||
/// requested `pocket` when one is given ('proposed' stays opt-in exactly
|
||||
/// this way — it is not a cross pocket). Shared by the source-adjusting
|
||||
/// pass and the added mirror entry, which used to duplicate the list.
|
||||
fn cross_suites(
|
||||
series: &str,
|
||||
pocket: Option<&str>,
|
||||
dist: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let mut suites = vec![series.to_string()];
|
||||
for p in crate::distro_info::get_cross_pockets(dist)? {
|
||||
suites.push(format!("{series}-{p}"));
|
||||
}
|
||||
if let Some(p) = pocket {
|
||||
let pocket_suite = format!("{series}-{p}");
|
||||
if !suites.contains(&pocket_suite) {
|
||||
suites.push(pocket_suite);
|
||||
}
|
||||
}
|
||||
Ok(suites)
|
||||
}
|
||||
|
||||
/// Ensure that repositories for target architecture are available
|
||||
/// This also handles the 'ports.ubuntu.com' vs 'archive.ubuntu.com' on Ubuntu
|
||||
///
|
||||
/// On Ubuntu hosts, driven by the bundled distro data
|
||||
/// (`data/distro_info.yml`): the official sources served by the mirror of
|
||||
/// the local architecture (the primary archive and its security sibling)
|
||||
/// are scoped to it and carry every component and cross-build suite, and
|
||||
/// the mirror serving the target architecture (ports, for the non-local
|
||||
/// ones) is added when no existing source serves the arch from it yet.
|
||||
/// Debian hosts are left alone: one mirror serves every architecture, so
|
||||
/// the host's own sources already cover the target — the os-release gate
|
||||
/// below is what makes that a data conclusion instead of hardcoding.
|
||||
pub fn ensure_repositories(
|
||||
arch: &str,
|
||||
series: &str,
|
||||
@@ -44,124 +111,200 @@ pub fn ensure_repositories(
|
||||
let local_arch = crate::get_current_arch();
|
||||
|
||||
// Add target ('host') architecture
|
||||
ctx.command("dpkg")
|
||||
let status = ctx
|
||||
.command("dpkg")
|
||||
.arg("--add-architecture")
|
||||
.arg(arch)
|
||||
.status()?;
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to run 'dpkg --add-architecture {arch}': {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"'dpkg --add-architecture {}' failed with status: {}",
|
||||
arch, status
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Check if we are on Ubuntu
|
||||
let os_release = String::from_utf8(ctx.command("cat").arg("/etc/os-release").output()?.stdout)?;
|
||||
if !os_release.contains("ID=ubuntu") {
|
||||
return Ok(());
|
||||
}
|
||||
let dist = "ubuntu";
|
||||
|
||||
// Load existing sources
|
||||
let mut sources = crate::apt::sources::load(Some(ctx.clone()))?;
|
||||
|
||||
// The mirrors serving each side of the cross build (primary for the
|
||||
// local architectures, ports for the others) and the distro data's
|
||||
// components and suites
|
||||
let local_mirror = crate::distro_info::mirror_for_arch(dist, &local_arch)?;
|
||||
let target_mirror = crate::distro_info::mirror_for_arch(dist, arch)?;
|
||||
let components = crate::distro_info::get_dist_components(dist)?;
|
||||
let required_suites = cross_suites(series, pocket, dist)?;
|
||||
|
||||
// Ensure all components are enabled for the primary architecture
|
||||
for source in &mut sources {
|
||||
if source.uri.contains("archive.ubuntu.com") || source.uri.contains("security.ubuntu.com") {
|
||||
// Scope to local_arch if not already scoped
|
||||
if source.architectures.is_empty() {
|
||||
source.architectures.push(local_arch.clone());
|
||||
}
|
||||
// Official sources served by the local mirror (the primary archive
|
||||
// and its security sibling); ports serves the other architectures
|
||||
// and is configured below instead
|
||||
if !crate::distro_info::is_mirror_source(local_mirror, &source.uri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure all components are present
|
||||
let required_components = ["main", "restricted", "universe", "multiverse"];
|
||||
for &comp in &required_components {
|
||||
if !source.components.contains(&comp.to_string()) {
|
||||
source.components.push(comp.to_string());
|
||||
}
|
||||
}
|
||||
// Scope to local_arch if not already scoped
|
||||
if source.architectures.is_empty() {
|
||||
source.architectures.push(local_arch.clone());
|
||||
}
|
||||
|
||||
// Ensure all suites (pockets) are enabled, excluding 'proposed'
|
||||
// unless explicitly requested through the 'pocket' option
|
||||
let mut required_suites = vec![
|
||||
series.to_string(),
|
||||
format!("{}-updates", series),
|
||||
format!("{}-backports", series),
|
||||
format!("{}-security", series),
|
||||
];
|
||||
if let Some(p) = pocket {
|
||||
let pocket_suite = format!("{series}-{p}");
|
||||
if !required_suites.contains(&pocket_suite) {
|
||||
required_suites.push(pocket_suite);
|
||||
}
|
||||
// Ensure all components are present
|
||||
for comp in &components {
|
||||
if !source.components.contains(comp) {
|
||||
source.components.push(comp.clone());
|
||||
}
|
||||
for suite in required_suites {
|
||||
if !source.suite.contains(&suite) {
|
||||
source.suite.push(suite);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all suites (pockets) are enabled
|
||||
for suite in &required_suites {
|
||||
if !source.suite.contains(suite) {
|
||||
source.suite.push(suite.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ports repository already exists for the target architecture
|
||||
let has_ports = sources
|
||||
.iter()
|
||||
.any(|s| s.uri.contains("ports.ubuntu.com") && s.architectures.contains(&arch.to_string()));
|
||||
// Check whether an existing source already serves the target
|
||||
// architecture from its mirror (e.g. the ports mirror for a
|
||||
// non-local arch); when cross-building for the local architecture,
|
||||
// the primary sources above already do
|
||||
let has_target = sources.iter().any(|s| {
|
||||
crate::distro_info::is_mirror_source(target_mirror, &s.uri)
|
||||
&& s.architectures.contains(&arch.to_string())
|
||||
});
|
||||
|
||||
if !has_ports {
|
||||
// Add ports repository for the target architecture
|
||||
let mut ports_suites = vec![
|
||||
format!("{series}"),
|
||||
format!("{series}-updates"),
|
||||
format!("{series}-backports"),
|
||||
format!("{series}-security"),
|
||||
];
|
||||
if let Some(p) = pocket {
|
||||
let pocket_suite = format!("{series}-{p}");
|
||||
if !ports_suites.contains(&pocket_suite) {
|
||||
ports_suites.push(pocket_suite);
|
||||
}
|
||||
}
|
||||
let ports_entry = crate::apt::sources::SourceEntry {
|
||||
if !has_target {
|
||||
// Add the target architecture's mirror (ports for the non-local
|
||||
// architectures on Ubuntu)
|
||||
let mirror_entry = crate::apt::sources::SourceEntry {
|
||||
enabled: true,
|
||||
components: vec![
|
||||
"main".to_string(),
|
||||
"restricted".to_string(),
|
||||
"universe".to_string(),
|
||||
"multiverse".to_string(),
|
||||
],
|
||||
kind: crate::apt::sources::SourceKind::Deb,
|
||||
components: components.clone(),
|
||||
architectures: vec![arch.to_string()],
|
||||
uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(),
|
||||
suite: ports_suites,
|
||||
uri: target_mirror.url.clone(),
|
||||
signed_by: None,
|
||||
trusted: None,
|
||||
suite: required_suites.clone(),
|
||||
// No origin: saved to the pkh-owned added-sources file
|
||||
origin: None,
|
||||
};
|
||||
sources.push(ports_entry);
|
||||
sources.push(mirror_entry);
|
||||
}
|
||||
|
||||
// Save the updated sources
|
||||
// Try to save in DEB822 format first, fall back to legacy format
|
||||
let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources";
|
||||
if ctx
|
||||
.command("test")
|
||||
.arg("-f")
|
||||
.arg(deb822_path)
|
||||
.status()?
|
||||
.success()
|
||||
{
|
||||
// For DEB822 format, we need to reconstruct the file content
|
||||
let mut content = String::new();
|
||||
for source in &sources {
|
||||
if !source.enabled {
|
||||
continue;
|
||||
}
|
||||
content.push_str("Types: deb\n");
|
||||
content.push_str(&format!("URIs: {}\n", source.uri));
|
||||
content.push_str(&format!("Suites: {}\n", source.suite.join(" ")));
|
||||
content.push_str(&format!("Components: {}\n", source.components.join(" ")));
|
||||
content.push_str("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n");
|
||||
content.push_str(&format!(
|
||||
"Architectures: {}\n",
|
||||
source.architectures.join(" ")
|
||||
));
|
||||
content.push('\n');
|
||||
}
|
||||
ctx.write_file(std::path::Path::new(deb822_path), &content)?;
|
||||
} else {
|
||||
// Fall back to legacy format
|
||||
crate::apt::sources::save_legacy(Some(ctx.clone()), sources, "/etc/apt/sources.list")?;
|
||||
}
|
||||
// Save the updated sources: each entry is written back to its origin
|
||||
// file in its own format (keeping its own Signed-By and Enabled state),
|
||||
// and the new mirror entry goes to the pkh-owned added-sources file
|
||||
crate::apt::sources::save(Some(ctx.clone()), sources)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_dpkg_architecture_output() {
|
||||
let output = "DEB_BUILD_ARCH=amd64\n\
|
||||
DEB_HOST_ARCH=arm64\n\
|
||||
DEB_HOST_GNU_TYPE=aarch64-linux-gnu\n";
|
||||
|
||||
let mut env = HashMap::new();
|
||||
parse_dpkg_architecture_output(output, &mut env);
|
||||
|
||||
assert_eq!(env.get("DEB_BUILD_ARCH").map(String::as_str), Some("amd64"));
|
||||
assert_eq!(env.get("DEB_HOST_ARCH").map(String::as_str), Some("arm64"));
|
||||
assert_eq!(
|
||||
env.get("DEB_HOST_GNU_TYPE").map(String::as_str),
|
||||
Some("aarch64-linux-gnu")
|
||||
);
|
||||
// Derived variable for the GNU type
|
||||
assert_eq!(
|
||||
env.get("CROSS_COMPILE").map(String::as_str),
|
||||
Some("aarch64-linux-gnu-")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dpkg_architecture_output_skips_unexpected_lines() {
|
||||
// Unexpected lines (warnings on stdout, empty lines) must be skipped
|
||||
// instead of panicking
|
||||
let output = "dpkg-architecture: warning: something odd happened\n\
|
||||
\n\
|
||||
DEB_HOST_GNU_TYPE=arm-linux-gnueabihf\n\
|
||||
not an environment variable assignment\n";
|
||||
|
||||
let mut env = HashMap::new();
|
||||
parse_dpkg_architecture_output(output, &mut env);
|
||||
|
||||
assert_eq!(
|
||||
env.get("DEB_HOST_GNU_TYPE").map(String::as_str),
|
||||
Some("arm-linux-gnueabihf")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("CROSS_COMPILE").map(String::as_str),
|
||||
Some("arm-linux-gnueabihf-")
|
||||
);
|
||||
assert_eq!(env.len(), 2);
|
||||
}
|
||||
|
||||
/// The suite list a cross-build environment enables: the series, its
|
||||
/// cross pockets from the distro data (updates, backports, security),
|
||||
/// and the explicitly requested pocket — which is the only way
|
||||
/// 'proposed' gets in.
|
||||
#[test]
|
||||
fn test_cross_suites_from_distro_data() {
|
||||
assert_eq!(
|
||||
cross_suites("noble", None, "ubuntu").unwrap(),
|
||||
vec![
|
||||
"noble".to_string(),
|
||||
"noble-updates".to_string(),
|
||||
"noble-backports".to_string(),
|
||||
"noble-security".to_string()
|
||||
]
|
||||
);
|
||||
// An explicitly requested pocket is added (not duplicated when it
|
||||
// is already a cross pocket).
|
||||
assert_eq!(
|
||||
cross_suites("noble", Some("proposed"), "ubuntu").unwrap(),
|
||||
vec![
|
||||
"noble".to_string(),
|
||||
"noble-updates".to_string(),
|
||||
"noble-backports".to_string(),
|
||||
"noble-security".to_string(),
|
||||
"noble-proposed".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cross_suites("noble", Some("updates"), "ubuntu").unwrap(),
|
||||
cross_suites("noble", None, "ubuntu").unwrap()
|
||||
);
|
||||
assert!(cross_suites("noble", None, "not-a-distro").is_err());
|
||||
}
|
||||
|
||||
/// setup_environment exports the dpkg cross variables and the 'cross'
|
||||
/// build profile, and nothing beyond what dpkg-buildpackage exports:
|
||||
/// no pkg-config redirection (the environment is the package's to
|
||||
/// set, and target-arch pkgconfig paths would also poison the
|
||||
/// host-side tools of the same build).
|
||||
#[test]
|
||||
fn test_setup_environment_exports_dpkg_cross_variables_only() {
|
||||
let mut env = HashMap::new();
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
setup_environment(&mut env, "riscv64", ctx).unwrap();
|
||||
|
||||
assert!(!env.contains_key("PKG_CONFIG_LIBDIR"));
|
||||
assert_eq!(
|
||||
env.get("DEB_BUILD_PROFILES").map(String::as_str),
|
||||
Some("cross")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+389
-23
@@ -1,19 +1,206 @@
|
||||
use crate::context::{self, Context, ContextConfig};
|
||||
use crate::deb::{Phase, enter_phase};
|
||||
use crate::interrupt::CleanupHookGuard;
|
||||
use crate::report::BuildView;
|
||||
use directories::ProjectDirs;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tar::Archive;
|
||||
use xz2::read::XzDecoder;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interrupt-time chroot cleanup
|
||||
//
|
||||
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
|
||||
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
|
||||
// interrupt sequence skips all destructors, which would otherwise leak the
|
||||
// freshly bootstrapped chroot under /tmp together with its bind-mounted
|
||||
// /proc and any overlayfs mounts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
|
||||
/// mount at or below `chroot_path` (the /proc bind mount, any overlay mounts)
|
||||
/// and then remove the directory tree.
|
||||
///
|
||||
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
|
||||
/// through the context manager, the ephemeral context's driver (whose
|
||||
/// `cleanup()` unmounts the tracked overlays) or the base context's command
|
||||
/// builder: interrupt-time hooks must be self-contained, and those
|
||||
/// machineries may be mid-mutation on the interrupted thread. Instead it
|
||||
/// only reads /proc/mounts and spawns umount/rm directly.
|
||||
///
|
||||
/// It also differs from `drop` in that it removes the chroot regardless of
|
||||
/// the build result: the build was aborted, and leaving a still-mounted
|
||||
/// chroot behind is exactly the leak this hook exists to prevent.
|
||||
///
|
||||
/// Best-effort by design: if a child process still holds a mount busy or
|
||||
/// privilege escalation is unavailable, individual steps fail; failures are
|
||||
/// logged (pointing at `pkh prune` for the leftovers) and never panic.
|
||||
fn sigint_cleanup_chroot(chroot_path: &Path) {
|
||||
let is_root = unsafe { libc::geteuid() } == 0;
|
||||
|
||||
// Unmount children before parents: /proc/mounts lists mounts roughly in
|
||||
// creation order, so walk it in reverse
|
||||
let mounts = host_mounts_under(chroot_path);
|
||||
for mount_point in mounts.into_iter().rev() {
|
||||
if unmount_path(&mount_point, is_root) {
|
||||
log::debug!(
|
||||
"Unmounted {} during interrupt cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"Failed to unmount {} during interrupt cleanup; \
|
||||
run `pkh prune` once the mount is free",
|
||||
mount_point.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the chroot tree itself (tolerates a missing directory). A
|
||||
// child the Ctrl+C interrupted may still be finishing its writeout —
|
||||
// dpkg defers SIGINT until it reaches a safe state — so retry while rm
|
||||
// reports the tree non-empty instead of leaving it half-removed.
|
||||
const RETRIES: usize = 10;
|
||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
let mut last = None;
|
||||
for attempt in 0..=RETRIES {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
last = Some(
|
||||
privileged_command("rm", is_root)
|
||||
.arg("-rf")
|
||||
.arg(chroot_path)
|
||||
.status(),
|
||||
);
|
||||
if matches!(&last, Some(Ok(status)) if status.success()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(Ok(status)) if status.success() => {
|
||||
log::debug!(
|
||||
"Removed chroot {} during interrupt cleanup",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
Some(Ok(status)) => {
|
||||
log::error!(
|
||||
"Failed to remove chroot {} during interrupt cleanup \
|
||||
(rm exited with {status}); run `pkh prune`",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!(
|
||||
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
|
||||
chroot_path.display()
|
||||
);
|
||||
}
|
||||
None => unreachable!("at least one rm attempt ran"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Command` for `program`, wrapped in non-interactive sudo when not
|
||||
/// running as root: interrupt cleanup must never block on a password prompt,
|
||||
/// so without cached credentials the command fails fast and is logged instead
|
||||
fn privileged_command(program: &str, is_root: bool) -> Command {
|
||||
if is_root {
|
||||
Command::new(program)
|
||||
} else {
|
||||
let mut cmd = Command::new("sudo");
|
||||
cmd.arg("-n").arg(program);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Unmount `path`, falling back to a lazy unmount if the first attempt fails
|
||||
/// because something still holds the mount busy (e.g. an interrupted child
|
||||
/// that has not exited yet); returns whether the mount is gone
|
||||
fn unmount_path(path: &Path, is_root: bool) -> bool {
|
||||
if privileged_command("umount", is_root)
|
||||
.arg(path)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
privileged_command("umount", is_root)
|
||||
.arg("-l")
|
||||
.arg(path)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
/// Collect the host-side mount points at or below `base`, in /proc/mounts
|
||||
/// order (empty if /proc/mounts cannot be read)
|
||||
fn host_mounts_under(base: &Path) -> Vec<PathBuf> {
|
||||
let mut mounts = Vec::new();
|
||||
let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else {
|
||||
return mounts;
|
||||
};
|
||||
// Compare against the canonical path: /proc/mounts shows resolved paths,
|
||||
// while the chroot path may go through a symlinked TMPDIR
|
||||
let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
|
||||
for line in mounts_text.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(_device), Some(mount_point)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
let path = PathBuf::from(unescape_mount_field(mount_point));
|
||||
if path.starts_with(&base) && !mounts.contains(&path) {
|
||||
mounts.push(path);
|
||||
}
|
||||
}
|
||||
mounts
|
||||
}
|
||||
|
||||
/// Decode the octal escapes /proc/mounts uses in its path fields
|
||||
/// (`\040` for space, `\011` for tab, `\012` for newline, `\134` for backslash)
|
||||
fn unescape_mount_field(field: &str) -> String {
|
||||
let bytes = field.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\'
|
||||
&& i + 4 <= bytes.len()
|
||||
&& bytes[i + 1..i + 4]
|
||||
.iter()
|
||||
.all(|b| (b'0'..=b'7').contains(b))
|
||||
&& let Ok(value) = u8::from_str_radix(&field[i + 1..i + 4], 8)
|
||||
{
|
||||
out.push(value);
|
||||
i += 4;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// An ephemeral unshare context guard that creates and manages a temporary chroot environment
|
||||
/// for building packages with unshare permissions.
|
||||
pub struct EphemeralContextGuard {
|
||||
previous_context: String,
|
||||
/// The ephemeral build context this guard created (an unshare context
|
||||
/// bound to the chroot, parented on the base context). Held explicitly so
|
||||
/// cleanup and the build itself never depend on the process-global
|
||||
/// "current" context, which concurrent builds swap for their own.
|
||||
ephemeral_ctx: Arc<Context>,
|
||||
/// The context that was current (globally) when this guard was created,
|
||||
/// restored on drop. Saving the handle instead of a config name is what
|
||||
/// keeps concurrent builds from restoring over each other.
|
||||
previous_context: Arc<Context>,
|
||||
chroot_path: PathBuf,
|
||||
build_succeeded: bool,
|
||||
base_ctx: Arc<Context>,
|
||||
/// Registration of the interrupt-time cleanup hook; deregistered when
|
||||
/// this guard drops, so the hook can never fire after the normal cleanup
|
||||
cleanup_hook: Option<CleanupHookGuard>,
|
||||
}
|
||||
|
||||
impl EphemeralContextGuard {
|
||||
@@ -28,8 +215,13 @@ impl EphemeralContextGuard {
|
||||
series: &str,
|
||||
arch: Option<&str>,
|
||||
base_ctx: Arc<Context>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let current_context_name = context::manager().current_name();
|
||||
// Save the globally-installed context so Drop can restore exactly
|
||||
// this handle: concurrent builds install their own ephemeral
|
||||
// overrides, so the only safe restoration value is the one observed
|
||||
// before this guard swapped anything in.
|
||||
let previous_context = context::current();
|
||||
|
||||
// Create a temporary directory for the chroot
|
||||
let chroot_path_str = base_ctx.create_temp_dir()?;
|
||||
@@ -42,28 +234,91 @@ impl EphemeralContextGuard {
|
||||
chroot_path.display()
|
||||
);
|
||||
|
||||
// Download and extract the chroot tarball
|
||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone()).await?;
|
||||
// Register the interrupt-time cleanup hook before any heavy work: if
|
||||
// the user hits Ctrl-C during bootstrap or the build itself, the
|
||||
// interrupt watchdog unmounts and removes the chroot through this
|
||||
// hook (see `sigint_cleanup_chroot`). This only works for a local
|
||||
// base context: the hook must be self-contained (stored path +
|
||||
// direct umount/rm subprocesses) and cannot go through `base_ctx`.
|
||||
// For remote or nested bases the chroot lives elsewhere, and
|
||||
// leftovers stay handled by `pkh prune` as before.
|
||||
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
|
||||
Some(crate::interrupt::register_cleanup_hook(Box::new({
|
||||
let chroot_path = chroot_path.clone();
|
||||
move || sigint_cleanup_chroot(&chroot_path)
|
||||
})))
|
||||
} else {
|
||||
log::debug!(
|
||||
"Base context is not local; skipping interrupt-time cleanup registration for {}",
|
||||
chroot_path.display()
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// Switch to an ephemeral context to build the package in the chroot
|
||||
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
|
||||
path: chroot_path.to_string_lossy().to_string(),
|
||||
parent: Some(current_context_name.clone()),
|
||||
}));
|
||||
// Download and extract the chroot tarball
|
||||
if let Err(e) =
|
||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
|
||||
.await
|
||||
{
|
||||
// On a Ctrl+C the interrupt watchdog owns the tree: keep the
|
||||
// hook registered (forgetting the guard) so it removes the
|
||||
// partial directory, instead of the historical behavior of
|
||||
// leaving it in place. Without an interrupt this is a plain
|
||||
// bootstrap failure and the partial directory stays, as before.
|
||||
if crate::interrupt::interrupted()
|
||||
&& let Some(hook) = cleanup_hook
|
||||
{
|
||||
std::mem::forget(hook);
|
||||
} else {
|
||||
drop(cleanup_hook);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Switch to an ephemeral context to build the package in the chroot.
|
||||
// The parent is the base context itself (the one that bootstrapped
|
||||
// the chroot), wired through `with_parent` instead of a config-name
|
||||
// lookup, so an explicit non-current base (e.g. ssh) is used for
|
||||
// everything that runs inside the chroot. The Arc stays in the
|
||||
// guard: the build and the cleanup use it directly.
|
||||
let ephemeral_ctx = Arc::new(Context::with_parent(
|
||||
ContextConfig::Unshare {
|
||||
path: chroot_path.to_string_lossy().to_string(),
|
||||
// The real parent is bound below via `with_parent`; the
|
||||
// config field is only used for contexts read from the
|
||||
// persisted configuration.
|
||||
parent: None,
|
||||
},
|
||||
base_ctx.clone(),
|
||||
));
|
||||
context::manager().set_current_ephemeral(ephemeral_ctx.clone());
|
||||
|
||||
Ok(Self {
|
||||
previous_context: current_context_name,
|
||||
previous_context,
|
||||
ephemeral_ctx,
|
||||
chroot_path,
|
||||
build_succeeded: false,
|
||||
base_ctx,
|
||||
cleanup_hook,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ephemeral build context created by this guard
|
||||
///
|
||||
/// Callers must take the context from here rather than from
|
||||
/// [`crate::context::current()`]: the process-global is a shared swap
|
||||
/// slot that another concurrent build may have re-pointed at its own
|
||||
/// chroot, while this handle is guaranteed to be this guard's context.
|
||||
pub fn context(&self) -> Arc<Context> {
|
||||
Arc::clone(&self.ephemeral_ctx)
|
||||
}
|
||||
|
||||
async fn download_and_extract_chroot(
|
||||
series: &str,
|
||||
arch: Option<&str>,
|
||||
chroot_path: &PathBuf,
|
||||
ctx: Arc<context::Context>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
||||
let ctx_for_devices = ctx.clone();
|
||||
@@ -91,6 +346,11 @@ impl EphemeralContextGuard {
|
||||
let poll_interval = 5; // Check every 5 seconds
|
||||
|
||||
while ctx.exists(&lockfile_path)? {
|
||||
// Stop waiting on a Ctrl+C: the interrupt watchdog removes the
|
||||
// (yet empty) chroot and exits without waiting for the poll
|
||||
if crate::interrupt::interrupted() {
|
||||
return Err("Interrupted while waiting for the chroot tarball".into());
|
||||
}
|
||||
if wait_time >= timeout {
|
||||
log::warn!(
|
||||
"Lockfile {} exists and has been present for more than {} seconds. \
|
||||
@@ -119,7 +379,8 @@ impl EphemeralContextGuard {
|
||||
series,
|
||||
arch
|
||||
);
|
||||
Self::download_chroot_tarball(series, arch, &tarball_path, ctx).await?;
|
||||
enter_phase(view, Phase::PreparingChroot);
|
||||
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
|
||||
} else {
|
||||
log::debug!(
|
||||
"Using cached chroot tarball for {} (arch: {:?})",
|
||||
@@ -130,10 +391,12 @@ impl EphemeralContextGuard {
|
||||
|
||||
// Extract tarball to chroot directory
|
||||
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
||||
Self::extract_tarball(&tarball_path, chroot_path)?;
|
||||
enter_phase(view, Phase::ExtractingChroot);
|
||||
Self::extract_tarball(&tarball_path, chroot_path, view)?;
|
||||
|
||||
// Create device nodes in the chroot
|
||||
log::debug!("Creating device nodes in chroot...");
|
||||
enter_phase(view, Phase::FinalizingChroot);
|
||||
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
||||
|
||||
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
||||
@@ -149,6 +412,7 @@ impl EphemeralContextGuard {
|
||||
arch: Option<&str>,
|
||||
tarball_path: &Path,
|
||||
ctx: Arc<context::Context>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
||||
let lockfile_path = tarball_path.with_extension("lock");
|
||||
@@ -182,6 +446,10 @@ impl EphemeralContextGuard {
|
||||
cmd.arg(series)
|
||||
.arg(tarball_path.to_string_lossy().to_string());
|
||||
|
||||
if let Some(s) = view.sink() {
|
||||
cmd.capture(s);
|
||||
}
|
||||
|
||||
let status = cmd.status()?;
|
||||
|
||||
if !status.success() {
|
||||
@@ -216,6 +484,7 @@ impl EphemeralContextGuard {
|
||||
fn extract_tarball(
|
||||
tarball_path: &PathBuf,
|
||||
chroot_path: &PathBuf,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
// Create the chroot directory
|
||||
fs::create_dir_all(chroot_path)?;
|
||||
@@ -225,8 +494,24 @@ impl EphemeralContextGuard {
|
||||
let xz_decoder = XzDecoder::new(tarball_file);
|
||||
let mut archive = Archive::new(xz_decoder);
|
||||
|
||||
// Extract all files to the chroot directory
|
||||
archive.unpack(chroot_path)?;
|
||||
// Extract entries one by one so progress can be reported (a full
|
||||
// second decompression pass just to count entries upfront would be
|
||||
// too expensive for multi-hundred-MB chroot tarballs)
|
||||
let mut count = 0usize;
|
||||
for entry in archive.entries()? {
|
||||
// Bail on a Ctrl+C before the interrupt watchdog's rm -rf races
|
||||
// this loop writing entries into the tree being removed
|
||||
if crate::interrupt::interrupted() {
|
||||
return Err("Interrupted while extracting the chroot".into());
|
||||
}
|
||||
let mut entry = entry?;
|
||||
entry.unpack_in(chroot_path)?;
|
||||
count += 1;
|
||||
if count.is_multiple_of(100) {
|
||||
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||
}
|
||||
}
|
||||
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,21 +621,47 @@ impl EphemeralContextGuard {
|
||||
|
||||
impl Drop for EphemeralContextGuard {
|
||||
fn drop(&mut self) {
|
||||
// On Ctrl+C the interrupt watchdog owns the chroot teardown through
|
||||
// the registered hook: duplicating it here would race the hook's
|
||||
// umount/rm (mounts vanish under each other). Dropping this guard
|
||||
// would normally deregister the hook, so while the watchdog runs it
|
||||
// must be leaked instead to keep it registered (if it was already
|
||||
// drained, forgetting is a harmless no-op).
|
||||
if crate::interrupt::interrupted() {
|
||||
context::manager().set_current_ephemeral(self.previous_context.clone());
|
||||
if let Some(hook) = self.cleanup_hook.take() {
|
||||
std::mem::forget(hook);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Deregister the interrupt-time cleanup hook first: the normal
|
||||
// cleanup below takes care of the chroot, so the hook must not fire
|
||||
// afterwards. (If a SIGINT arrived mid-drop and the hook is already
|
||||
// running concurrently, deregistration simply does not find it —
|
||||
// both paths are individually idempotent and failure-tolerant.)
|
||||
if let Some(mut cleanup_hook) = self.cleanup_hook.take() {
|
||||
cleanup_hook.deregister();
|
||||
}
|
||||
|
||||
log::debug!("Cleaning up ephemeral context ({:?})...", self.chroot_path);
|
||||
|
||||
// Clean up any overlay mounts before resetting the context.
|
||||
// This must happen while the ephemeral context is still current so its
|
||||
// driver is accessible. The actual unmount commands run via the parent
|
||||
// Clean up any overlay mounts before resetting the context. This
|
||||
// explicitly targets the context this guard created — never
|
||||
// `context::current()`, which a concurrent build may have re-pointed
|
||||
// at its own chroot. The actual unmount commands run via the parent
|
||||
// (base) context, so they work regardless.
|
||||
let ephemeral_ctx = context::current();
|
||||
if let Err(e) = ephemeral_ctx.cleanup() {
|
||||
if let Err(e) = self.ephemeral_ctx.cleanup() {
|
||||
log::warn!("Failed to clean up overlay mounts: {}", e);
|
||||
}
|
||||
|
||||
// Reset to normal context
|
||||
if let Err(e) = context::manager().set_current(&self.previous_context) {
|
||||
log::error!("Failed to restore context {}: {}", self.previous_context, e);
|
||||
}
|
||||
// Restore the context that was current when this guard was created,
|
||||
// not whatever is globally current at drop time (another concurrent
|
||||
// build's override may be installed there). This only swaps the
|
||||
// in-memory handle: the persisted configuration still names the
|
||||
// context selected by the user, as `set_current_ephemeral` never
|
||||
// touches it.
|
||||
context::manager().set_current_ephemeral(self.previous_context.clone());
|
||||
|
||||
// Remove chroot directory only if build succeeded
|
||||
if self.build_succeeded {
|
||||
@@ -419,3 +730,58 @@ impl Drop for EphemeralContextGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chroot_cleanup_tests {
|
||||
use super::*;
|
||||
|
||||
/// /proc/mounts path fields use octal escapes for whitespace and
|
||||
/// backslashes; anything else must be kept verbatim.
|
||||
#[test]
|
||||
fn mount_field_unescaping_decodes_octal_escapes() {
|
||||
assert_eq!(unescape_mount_field("/mnt/plain"), "/mnt/plain");
|
||||
assert_eq!(
|
||||
unescape_mount_field("/mnt/with\\040space"),
|
||||
"/mnt/with space"
|
||||
);
|
||||
assert_eq!(unescape_mount_field("/mnt/with\\011tab"), "/mnt/with\ttab");
|
||||
assert_eq!(unescape_mount_field("back\\134slash"), "back\\slash");
|
||||
// Not an escape sequence: kept verbatim
|
||||
assert_eq!(unescape_mount_field("back\\9slash"), "back\\9slash");
|
||||
assert_eq!(unescape_mount_field("trailing\\"), "trailing\\");
|
||||
}
|
||||
|
||||
/// Interrupt cleanup of a path that has no mounts and does not exist must
|
||||
/// be a harmless no-op (no panic, nothing left behind).
|
||||
#[test]
|
||||
fn sigint_cleanup_of_missing_chroot_is_a_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("no-such-chroot");
|
||||
sigint_cleanup_chroot(&missing);
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
/// A real directory with no mounts under it is simply removed. Skipped
|
||||
/// when non-root without working non-interactive sudo, since removal then
|
||||
/// legitimately fails (and is only logged).
|
||||
#[test]
|
||||
fn sigint_cleanup_removes_an_unmounted_directory() {
|
||||
let is_root = unsafe { libc::geteuid() } == 0;
|
||||
if !is_root
|
||||
&& !privileged_command("true", false)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let chroot = dir.path().join("chroot");
|
||||
std::fs::create_dir_all(chroot.join("rootfs")).unwrap();
|
||||
std::fs::write(chroot.join("rootfs").join("file.txt"), "data").unwrap();
|
||||
|
||||
sigint_cleanup_chroot(&chroot);
|
||||
|
||||
assert!(!chroot.exists());
|
||||
}
|
||||
}
|
||||
|
||||
+1159
-137
File diff suppressed because it is too large
Load Diff
+745
-89
@@ -1,8 +1,15 @@
|
||||
mod cross;
|
||||
mod ephemeral;
|
||||
/// Ephemeral (per-build) unshare contexts, including the process-global
|
||||
/// cleanup-hook registry drained by the SIGINT handler
|
||||
pub(crate) mod ephemeral;
|
||||
mod local;
|
||||
|
||||
use crate::context::{self, Context};
|
||||
use crate::logfmt::{
|
||||
AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier,
|
||||
MmdebstrapClassifier, QuiltClassifier,
|
||||
};
|
||||
use crate::report::{BuildTarget, BuildView};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -14,59 +21,225 @@ pub enum BuildMode {
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Phases of a binary build, announced to the [`BuildView`]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Downloading the chroot tarball (mmdebstrap)
|
||||
PreparingChroot,
|
||||
/// Extracting the chroot tarball
|
||||
ExtractingChroot,
|
||||
/// Device nodes, /proc bind mount, etc.
|
||||
FinalizingChroot,
|
||||
/// apt-get update
|
||||
UpdatingPackageLists,
|
||||
/// Installing build-essential & co
|
||||
InstallingEssentials,
|
||||
/// quilt push -a
|
||||
ApplyingPatches,
|
||||
/// --inject packages
|
||||
InjectingPackages,
|
||||
/// apt-get build-dep
|
||||
InstallingBuildDeps,
|
||||
/// debian/rules build
|
||||
Building,
|
||||
/// fakeroot debian/rules binary
|
||||
ProducingBinaries,
|
||||
/// Retrieving produced .deb files
|
||||
RetrievingArtifacts,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
/// Human-readable label displayed in the status bar
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Phase::PreparingChroot => "Preparing chroot",
|
||||
Phase::ExtractingChroot => "Extracting chroot",
|
||||
Phase::FinalizingChroot => "Finalizing chroot",
|
||||
Phase::UpdatingPackageLists => "Updating package lists",
|
||||
Phase::InstallingEssentials => "Installing essential packages",
|
||||
Phase::ApplyingPatches => "Applying patches",
|
||||
Phase::InjectingPackages => "Injecting packages",
|
||||
Phase::InstallingBuildDeps => "Installing build dependencies",
|
||||
Phase::Building => "Building package",
|
||||
Phase::ProducingBinaries => "Producing binary packages",
|
||||
Phase::RetrievingArtifacts => "Retrieving artifacts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default line classifier rewriting a phase's subprocess output
|
||||
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
|
||||
match phase {
|
||||
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
|
||||
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
|
||||
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
|
||||
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
|
||||
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
|
||||
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
|
||||
Phase::InstallingBuildDeps => {
|
||||
Box::new(AptInstallClassifier::new("Installing build dependencies"))
|
||||
}
|
||||
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
|
||||
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter `phase` on the view with its default line classifier
|
||||
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) {
|
||||
view.phase(phase.label(), default_classifier(phase));
|
||||
}
|
||||
|
||||
/// Parameters of one [`build_binary_package`] call.
|
||||
pub struct DebBuildOptions<'a> {
|
||||
/// Target architecture; defaults to the host architecture.
|
||||
pub arch: Option<String>,
|
||||
/// Target distribution series; defaults to the changelog series
|
||||
/// (UNRELEASED resolves to the vendor's development series).
|
||||
pub series: Option<String>,
|
||||
/// Distribution pocket to resolve build-dependencies from.
|
||||
pub pocket: Option<String>,
|
||||
/// Source tree to build; defaults to the process working directory.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Cross-compile for the target architecture instead of using
|
||||
/// qemu-binfmt.
|
||||
pub cross: bool,
|
||||
/// Build mode; defaults to [`BuildMode::Local`].
|
||||
pub mode: Option<BuildMode>,
|
||||
/// PPAs to add for build-dependencies (`user/ppa_name`).
|
||||
pub ppa: Vec<String>,
|
||||
/// Packages to inject into the build environment before build-dep
|
||||
/// (.deb paths, archive names or PPA packages).
|
||||
pub inject: Vec<String>,
|
||||
/// Parallel build jobs; defaults to the core count available in the
|
||||
/// build context.
|
||||
pub jobs: Option<usize>,
|
||||
/// Explicit build context; defaults to the current context.
|
||||
pub ctx: Option<Arc<Context>>,
|
||||
/// Where build events (phases, progress, outcome) are reported.
|
||||
pub view: &'a dyn BuildView,
|
||||
}
|
||||
|
||||
impl Default for DebBuildOptions<'_> {
|
||||
fn default() -> Self {
|
||||
static QUIET: crate::report::Quiet = crate::report::Quiet;
|
||||
DebBuildOptions {
|
||||
arch: None,
|
||||
series: None,
|
||||
pocket: None,
|
||||
cwd: None,
|
||||
cross: false,
|
||||
mode: None,
|
||||
ppa: Vec::new(),
|
||||
inject: Vec::new(),
|
||||
jobs: None,
|
||||
ctx: None,
|
||||
view: &QUIET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build package in 'cwd' to a .deb
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
///
|
||||
/// Returns the list of produced artifacts (.deb files plus the upload
|
||||
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
|
||||
/// `debian/files` and the native metadata generation rather than by
|
||||
/// globbing the build root (which would surface stale files). Subprocess
|
||||
/// output is captured through the view's sink (live view + tee log for the
|
||||
/// terminal adapter); on failure the view is cleared and prints a summary
|
||||
/// of captured errors.
|
||||
pub async fn build_binary_package(
|
||||
arch: Option<&str>,
|
||||
series: Option<&str>,
|
||||
pocket: Option<&str>,
|
||||
cwd: Option<&Path>,
|
||||
cross: bool,
|
||||
mode: Option<BuildMode>,
|
||||
ppa: Option<&[&str]>,
|
||||
inject_packages: Option<&[&str]>,
|
||||
ctx: Option<Arc<Context>>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||
opts: DebBuildOptions<'_>,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
let view = opts.view;
|
||||
let result = build_binary_package_impl(opts).await;
|
||||
|
||||
if result.is_err() {
|
||||
view.finish_failure();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Implementation of [`build_binary_package`], without failure handling
|
||||
async fn build_binary_package_impl(
|
||||
opts: DebBuildOptions<'_>,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
let DebBuildOptions {
|
||||
ref arch,
|
||||
ref series,
|
||||
ref pocket,
|
||||
ref cwd,
|
||||
cross,
|
||||
ref mode,
|
||||
ref ppa,
|
||||
ref inject,
|
||||
ref jobs,
|
||||
ref ctx,
|
||||
view,
|
||||
} = opts;
|
||||
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Parse changelog to get package name, version and series
|
||||
let changelog_path = cwd.join("debian/changelog");
|
||||
let (package, version, package_series) =
|
||||
crate::changelog::parse_changelog_header(&changelog_path)?;
|
||||
// UNRELEASED is not a real archive series: without an explicit --series,
|
||||
// build against the development series of the host vendor's distribution
|
||||
// instead. An explicit --series always wins.
|
||||
let resolved_series;
|
||||
let series = if let Some(s) = series {
|
||||
s
|
||||
} else if crate::distro_info::is_unreleased(&package_series) {
|
||||
let dist = crate::build::env::current_vendor();
|
||||
resolved_series = crate::distro_info::effective_series(&package_series, &dist).await?;
|
||||
log::info!(
|
||||
"Changelog is UNRELEASED, building against series {}",
|
||||
resolved_series
|
||||
);
|
||||
resolved_series.as_str()
|
||||
} else {
|
||||
&package_series
|
||||
};
|
||||
let current_arch = crate::get_current_arch();
|
||||
let arch = arch.unwrap_or(¤t_arch);
|
||||
let arch = arch.as_deref().unwrap_or(¤t_arch);
|
||||
|
||||
// Make sure we select a specific mode, either using user-requested
|
||||
// or by using default for user-supplied parameters
|
||||
let mode = if let Some(m) = mode {
|
||||
m
|
||||
} else {
|
||||
// By default, we use local build
|
||||
BuildMode::Local
|
||||
};
|
||||
let default_mode = BuildMode::Local;
|
||||
let mode = mode.as_ref().unwrap_or(&default_mode);
|
||||
|
||||
// Create an ephemeral unshare context for all Local builds
|
||||
// Use qemu_binfmt when target architecture differs from host and cross is not requested
|
||||
let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross {
|
||||
let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross {
|
||||
Some(arch)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Use provided context or get current
|
||||
let base_ctx = ctx.unwrap_or_else(context::current);
|
||||
let base_ctx = ctx.clone().unwrap_or_else(context::current);
|
||||
|
||||
let mut guard = if mode == BuildMode::Local {
|
||||
// Identify the target in the live view once the changelog is parsed, so
|
||||
// even the chroot download output is attributed and tee'd
|
||||
view.target(BuildTarget {
|
||||
package: &package,
|
||||
version: &version,
|
||||
target: &format!("{series}/{arch}"),
|
||||
display: format!("Building {package} ({version}) for {series}/{arch}"),
|
||||
source_only: false,
|
||||
tee_log: true,
|
||||
});
|
||||
|
||||
// Create an ephemeral unshare context for all Local builds. It is kept in
|
||||
// this scope so it outlives the guarded section below and is only dropped
|
||||
// once the live view has been cleared.
|
||||
let mut guard = if *mode == BuildMode::Local {
|
||||
Some(
|
||||
ephemeral::EphemeralContextGuard::new_with_context(
|
||||
series,
|
||||
chroot_arch,
|
||||
base_ctx.clone(),
|
||||
view,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
@@ -74,59 +247,132 @@ pub async fn build_binary_package(
|
||||
None
|
||||
};
|
||||
|
||||
// Get the build context - either the ephemeral context or the base context
|
||||
let build_ctx = if mode == BuildMode::Local {
|
||||
context::current()
|
||||
} else {
|
||||
base_ctx.clone()
|
||||
// Determine the build context explicitly: for Local builds it is the
|
||||
// ephemeral context the guard just created (taken from the guard itself,
|
||||
// never from the process-global, which concurrent builds may have
|
||||
// re-pointed at their own chroot); otherwise the base context is used
|
||||
// directly.
|
||||
let build_ctx = match guard.as_ref() {
|
||||
Some(g) => g.context(),
|
||||
None => base_ctx.clone(),
|
||||
};
|
||||
|
||||
// Prepare build directory
|
||||
let build_root = build_ctx.create_temp_dir()?;
|
||||
let result = async {
|
||||
// Prepare build directory
|
||||
let build_root = build_ctx.create_temp_dir()?;
|
||||
|
||||
// Ensure availability of all needed files for the build
|
||||
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
|
||||
build_ctx.ensure_available(parent_dir, &build_root)?;
|
||||
let parent_dir_name = parent_dir
|
||||
.file_name()
|
||||
.ok_or("Cannot find parent directory name")?;
|
||||
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
|
||||
// Ensure availability of all needed files for the build
|
||||
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
|
||||
build_ctx.ensure_available(parent_dir, &build_root)?;
|
||||
let parent_dir_name = parent_dir
|
||||
.file_name()
|
||||
.ok_or("Cannot find parent directory name")?;
|
||||
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
|
||||
|
||||
// Run the build using target build mode
|
||||
match mode {
|
||||
BuildMode::Local => {
|
||||
local::build(
|
||||
&package,
|
||||
&version,
|
||||
arch,
|
||||
series,
|
||||
pocket,
|
||||
&build_root,
|
||||
cross,
|
||||
ppa,
|
||||
inject_packages,
|
||||
build_ctx.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
// Resolve the package directory inside the staging area. The tree
|
||||
// the caller pointed at is authoritative (its changelog defined the
|
||||
// package/version/series above), so its staged copy wins; the
|
||||
// name-pattern search only runs as a fallback.
|
||||
let package_dir = resolve_package_directory(
|
||||
Path::new(&build_root),
|
||||
cwd,
|
||||
&package,
|
||||
&version,
|
||||
series,
|
||||
&build_ctx,
|
||||
)?;
|
||||
|
||||
// Retrieve produced .deb files
|
||||
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
|
||||
for remote_file in remote_files {
|
||||
if remote_file.extension().is_some_and(|ext| ext == "deb") {
|
||||
// Run the build using target build mode. It returns the exact set of
|
||||
// artifacts produced by this build (binary packages registered in
|
||||
// debian/files plus the generated .buildinfo/.changes), as paths
|
||||
// inside the build context.
|
||||
let remote_files: Vec<PathBuf> = match mode {
|
||||
BuildMode::Local => {
|
||||
local::build(
|
||||
&package,
|
||||
&version,
|
||||
arch,
|
||||
series,
|
||||
pocket.as_deref(),
|
||||
&build_root,
|
||||
&package_dir,
|
||||
cross,
|
||||
ppa,
|
||||
inject,
|
||||
build_ctx.clone(),
|
||||
view,
|
||||
*jobs,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve the produced artifacts (binary packages plus the upload
|
||||
// metadata) to the parent directory.
|
||||
enter_phase(view, Phase::RetrievingArtifacts);
|
||||
let total_debs = remote_files.len();
|
||||
|
||||
let mut artifacts = Vec::with_capacity(total_debs);
|
||||
for (idx, remote_file) in remote_files.iter().enumerate() {
|
||||
let file_name = remote_file.file_name().ok_or("Invalid remote filename")?;
|
||||
let local_dest = parent_dir.join(file_name);
|
||||
build_ctx.retrieve_path(&remote_file, &local_dest)?;
|
||||
build_ctx.retrieve_path(remote_file, &local_dest)?;
|
||||
artifacts.push(local_dest);
|
||||
|
||||
view.progress("Retrieving artifacts", idx + 1, total_debs);
|
||||
}
|
||||
|
||||
view.finish_success(&artifacts);
|
||||
|
||||
Ok(artifacts)
|
||||
}
|
||||
.await;
|
||||
|
||||
// Clear the live view before returning: the ephemeral guard is dropped at
|
||||
// the end of this function and its cleanup commands (umount, rm -rf of
|
||||
// the chroot) inherit the terminal, so they must not fight the widget.
|
||||
view.suspend();
|
||||
|
||||
// Mark build as successful to trigger chroot cleanup
|
||||
if let Some(ref mut g) = guard {
|
||||
if result.is_ok()
|
||||
&& let Some(ref mut g) = guard
|
||||
{
|
||||
g.mark_build_successful();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve the package directory for a build inside the staged build root.
|
||||
///
|
||||
/// The tree the caller pointed at is authoritative: `cwd`'s changelog
|
||||
/// already defined the package, version and series for this build, so its
|
||||
/// staged copy is used outright when it carries a `debian/` tree. The
|
||||
/// name-pattern search ([`find_package_directory`], including the quirks
|
||||
/// overrides) only runs when that copy cannot be resolved — a default `.`
|
||||
/// cwd has no basename, and the pointed-at tree may live outside the staged
|
||||
/// parent. Embedded callers are the motivation: their working directory
|
||||
/// names (`tree`, `checkout`, ...) match none of the search patterns.
|
||||
pub(crate) fn resolve_package_directory(
|
||||
build_root: &Path,
|
||||
cwd: &Path,
|
||||
package: &str,
|
||||
version: &str,
|
||||
series: &str,
|
||||
ctx: &context::Context,
|
||||
) -> Result<PathBuf, Box<dyn Error>> {
|
||||
if let Some(tree_name) = cwd.file_name() {
|
||||
let staged_tree = build_root.join(tree_name);
|
||||
if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? {
|
||||
log::debug!(
|
||||
"Using the staged copy of {} at {}",
|
||||
cwd.display(),
|
||||
staged_tree.display()
|
||||
);
|
||||
return Ok(staged_tree);
|
||||
}
|
||||
}
|
||||
find_package_directory(build_root, package, version, series, ctx)
|
||||
}
|
||||
|
||||
/// Find the current package directory by trying both patterns:
|
||||
@@ -137,10 +383,11 @@ pub(crate) fn find_package_directory(
|
||||
parent_dir: &Path,
|
||||
package: &str,
|
||||
version: &str,
|
||||
series: &str,
|
||||
ctx: &context::Context,
|
||||
) -> Result<PathBuf, Box<dyn Error>> {
|
||||
// Check quirks first for custom package directories
|
||||
let custom_dirs = crate::quirks::get_package_directories(package);
|
||||
let custom_dirs = crate::quirks::get_package_directories(package, series);
|
||||
for custom_dir in custom_dirs {
|
||||
let package_dir = parent_dir.join(&custom_dir);
|
||||
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
|
||||
@@ -212,11 +459,13 @@ pub(crate) fn find_package_directory(
|
||||
let entries = ctx.list_files(package_parent)?;
|
||||
let mut found_dirs = Vec::new();
|
||||
for entry in entries {
|
||||
if entry.is_dir() {
|
||||
if let Some(file_name) = entry.file_name() {
|
||||
found_dirs.push(file_name.to_string_lossy().into_owned());
|
||||
}
|
||||
log::debug!(" - {}", entry.display());
|
||||
// list_files yields context-relative paths (e.g. rooted inside
|
||||
// the chroot for an unshare context): classify through the
|
||||
// context, a host-side stat would miss every entry.
|
||||
let is_dir = ctx.is_dir(&entry)?;
|
||||
log::debug!(" - {}", entry.display());
|
||||
if is_dir && let Some(file_name) = entry.file_name() {
|
||||
found_dirs.push(file_name.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,12 +508,15 @@ fn find_dsc_file(
|
||||
}
|
||||
|
||||
/// Check whether an apt source URI points to a distribution archive
|
||||
/// (as opposed to a PPA or another third-party repository)
|
||||
/// (as opposed to a PPA or another third-party repository): a thin,
|
||||
/// dist-agnostic wrapper over [`crate::distro_info::is_official_source`],
|
||||
/// unioned across every known distro. Where the distribution is known,
|
||||
/// the dist-scoped check is preferred (it cannot misfire on another
|
||||
/// distro's mirror); this fallback stays for the sites that cannot know.
|
||||
pub(crate) fn is_archive_source(uri: &str) -> bool {
|
||||
uri.contains("archive.ubuntu.com")
|
||||
|| uri.contains("security.ubuntu.com")
|
||||
|| uri.contains("ports.ubuntu.com")
|
||||
|| uri.contains("deb.debian.org")
|
||||
crate::distro_info::supported_dists()
|
||||
.iter()
|
||||
.any(|dist| crate::distro_info::is_official_source(dist, uri))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -272,6 +524,126 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The archive check is dist-agnostic (any distro's official mirror
|
||||
/// counts) and host-based: the country mirrors the old substring
|
||||
/// checks matched (`fr.archive.ubuntu.com`) still count, look-alike
|
||||
/// hosts and PPAs do not.
|
||||
#[test]
|
||||
fn archive_sources_are_official_mirrors_of_any_distro() {
|
||||
for uri in [
|
||||
"https://archive.ubuntu.com/ubuntu",
|
||||
// Country mirrors front the Ubuntu archive.
|
||||
"http://fr.archive.ubuntu.com/ubuntu",
|
||||
"http://security.ubuntu.com/ubuntu",
|
||||
"http://ports.ubuntu.com/ubuntu-ports",
|
||||
"https://deb.debian.org/debian",
|
||||
] {
|
||||
assert!(is_archive_source(uri), "{uri}");
|
||||
}
|
||||
for uri in [
|
||||
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
|
||||
"http://notarchive.ubuntu.com/ubuntu",
|
||||
"https://example.com/debian",
|
||||
] {
|
||||
assert!(!is_archive_source(uri), "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unshare context mapped over `chroot_root`, parented on a local
|
||||
/// context like the ephemeral build contexts are: exists/list_files/
|
||||
/// is_dir answer through the path mapping, no namespace privileges
|
||||
/// needed.
|
||||
fn unshare_test_context(chroot_root: &Path) -> Context {
|
||||
let base = Context::new(crate::context::ContextConfig::Local).unwrap();
|
||||
Context::with_parent(
|
||||
crate::context::ContextConfig::Unshare {
|
||||
path: chroot_root.to_string_lossy().to_string(),
|
||||
parent: None,
|
||||
},
|
||||
Arc::new(base),
|
||||
)
|
||||
}
|
||||
|
||||
/// The staging-area listing must classify entries through the context:
|
||||
/// an unshare context returns build-root-relative paths that a host-side
|
||||
/// stat never sees (they live under the chroot root on the host), which
|
||||
/// used to silently empty the 'Found directories' list of the search
|
||||
/// failure message — and with it every hint about the actual layout.
|
||||
#[test]
|
||||
fn find_package_directory_lists_staged_directories_through_the_context() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
// Staged parent holding a single tree whose name matches none of
|
||||
// the search patterns (the embedded-caller layout: <job>/tree)
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
std::fs::create_dir_all(staged_parent.join("tree/debian")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let err = find_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect_err("no candidate matches a tree named 'tree'");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("Found directories: tree"),
|
||||
"error should list the staged directories through the context: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit cwd must resolve to its staged copy even when its name
|
||||
/// matches none of the search patterns: the pointed-at tree is what the
|
||||
/// parsed changelog came from.
|
||||
#[test]
|
||||
fn resolve_package_directory_prefers_the_pointed_tree() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let resolved = resolve_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
Path::new("/work/jobs/j-42/tree"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect("the staged copy of the pointed-at tree must resolve");
|
||||
|
||||
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree"));
|
||||
}
|
||||
|
||||
/// When the pointed-at tree is not in the staging area under its own
|
||||
/// name, resolution falls back to the name-pattern search.
|
||||
#[test]
|
||||
fn resolve_package_directory_falls_back_to_the_name_search() {
|
||||
let chroot = tempfile::tempdir().unwrap();
|
||||
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||
// Staged copy of a pulled tree: <pkg>/<pkg>-<origversion>
|
||||
std::fs::create_dir_all(staged_parent.join("bc/bc-1.07.1/debian")).unwrap();
|
||||
|
||||
let ctx = unshare_test_context(chroot.path());
|
||||
let resolved = resolve_package_directory(
|
||||
Path::new("/tmp/pkh-build-1/j-42"),
|
||||
// A tree never staged under that name
|
||||
Path::new("/work/other/checkout"),
|
||||
"bc",
|
||||
"1.07.1-1ubuntu1",
|
||||
"questing",
|
||||
&ctx,
|
||||
)
|
||||
.expect("the pulled-tree layout must resolve via the name search");
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/tmp/pkh-build-1/j-42/bc/bc-1.07.1")
|
||||
);
|
||||
}
|
||||
|
||||
async fn test_build_end_to_end(
|
||||
package: &str,
|
||||
series: &str,
|
||||
@@ -293,7 +665,7 @@ mod tests {
|
||||
|
||||
log::info!("Pulling package {} from {}...", package, series);
|
||||
let package_info =
|
||||
crate::package_info::lookup(package, None, Some(series), "", dist, None, None)
|
||||
crate::package_info::lookup(package, None, Some(series), "", dist, None, None, None)
|
||||
.await
|
||||
.expect("Cannot lookup package information");
|
||||
crate::pull::pull(&package_info, Some(cwd), None, true)
|
||||
@@ -302,26 +674,28 @@ mod tests {
|
||||
log::info!("Successfully pulled package {}", package);
|
||||
|
||||
// Create a fresh local context for this test
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local));
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
|
||||
// Change directory to the package directory
|
||||
let cwd =
|
||||
crate::deb::find_package_directory(cwd, package, &package_info.stanza.version, &ctx)
|
||||
.expect("Cannot find package directory");
|
||||
let cwd = crate::deb::find_package_directory(
|
||||
cwd,
|
||||
package,
|
||||
&package_info.stanza.version,
|
||||
series,
|
||||
&ctx,
|
||||
)
|
||||
.expect("Cannot find package directory");
|
||||
log::debug!("Package directory: {}", cwd.display());
|
||||
|
||||
log::info!("Starting binary package build...");
|
||||
crate::deb::build_binary_package(
|
||||
arch,
|
||||
Some(series),
|
||||
None,
|
||||
Some(&cwd),
|
||||
crate::deb::build_binary_package(DebBuildOptions {
|
||||
arch: arch.map(str::to_string),
|
||||
series: Some(series.to_string()),
|
||||
cross,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(ctx),
|
||||
)
|
||||
cwd: Some(cwd.to_path_buf()),
|
||||
ctx: Some(ctx),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("Cannot build binary package (deb)");
|
||||
log::info!("Successfully built binary package");
|
||||
@@ -378,12 +752,34 @@ mod tests {
|
||||
/// NOTE: Ideally, we want to run this in CI, but it takes more than 1h
|
||||
/// to fully build the linux-riscv package on an amd64 builder, which is too
|
||||
/// much time
|
||||
/// The series is the current LTS (26.04) rather than an interim one:
|
||||
/// interim series vanish from the mirrors a few months after their EOL
|
||||
/// (questing is already unreachable), an LTS stays pullable for years.
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() {
|
||||
test_build_end_to_end("linux-riscv", "questing", None, Some("riscv64"), true).await;
|
||||
test_build_end_to_end("linux-riscv", "resolute", None, Some("riscv64"), true).await;
|
||||
}
|
||||
|
||||
/// KNOWN-BROKEN cross build of the noble-era kernel, kept as an
|
||||
/// ignored fixture to work from. Noble controls declare their build
|
||||
/// tools unqualified (the `:native` idiom landed later), so exact dpkg
|
||||
/// semantics demand host-architecture instances of them
|
||||
/// (python3:riscv64, gcc-13:riscv64, clang-17:riscv64, ...) and the
|
||||
/// resulting two-architecture install set is unsolvable: t64
|
||||
/// libraries (libclang1-17t64) conflict with their own foreign-arch
|
||||
/// variant, and the riscv64 toolchain instances drag depends chains
|
||||
/// (gcc:riscv64) that do not resolve from the chroot sources. A real
|
||||
/// run fails at the apt transaction with 'Unable to correct problems'.
|
||||
/// Resolute-era controls declare :native properly; see the test above.
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
async fn test_deb_linux_riscv_noble_cross_end_to_end() {
|
||||
test_build_end_to_end("linux-riscv", "noble", None, Some("riscv64"), true).await;
|
||||
}
|
||||
|
||||
/// This is a specific test case for the latest gcc package on Debian
|
||||
@@ -400,4 +796,264 @@ mod tests {
|
||||
async fn test_deb_gcc_debian_end_to_end() {
|
||||
test_build_end_to_end("gcc-15", "sid", None, None, false).await;
|
||||
}
|
||||
|
||||
/// Create a synthetic source package that discriminates which architecture
|
||||
/// is used to resolve Build-Depends-Indep during cross builds:
|
||||
///
|
||||
/// - 'libdb-dev' is an arch:any package that is not Multi-Arch: same, so an
|
||||
/// amd64 copy can only be installed by replacing the arm64 one
|
||||
/// - the arch-specific binary links against libdb for the host
|
||||
/// architecture, so the build only succeeds if the arm64 libdb-dev was
|
||||
/// left in place by the arch-independant build-dep pass
|
||||
fn create_indep_cross_test_source(parent: &Path) -> PathBuf {
|
||||
let pkg_dir = parent.join("pkh-crosstest");
|
||||
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/changelog"),
|
||||
"pkh-crosstest (1.0) noble; urgency=medium\n\n \
|
||||
* Synthetic package exercising Build-Depends-Indep in cross builds.\n\n \
|
||||
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/control"),
|
||||
"Source: pkh-crosstest\n\
|
||||
Section: devel\n\
|
||||
Priority: optional\n\
|
||||
Maintainer: pkh tests <pkh@example.com>\n\
|
||||
Standards-Version: 4.7.4\n\
|
||||
Build-Depends: debhelper-compat (= 13), libdb-dev\n\
|
||||
Build-Depends-Indep: libdb-dev\n\
|
||||
Architecture: any all\n\
|
||||
\n\
|
||||
Package: pkh-crosstest\n\
|
||||
Architecture: any\n\
|
||||
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||
Description: Cross-build regression package for build-dep resolution\n \
|
||||
Builds a host-architecture binary against libdb to detect a cross\n \
|
||||
build environment damaged by a wrongly-scoped build-dep pass.\n\
|
||||
\n\
|
||||
Package: pkh-crosstest-data\n\
|
||||
Architecture: all\n\
|
||||
Description: Cross-build regression package data (arch-indep)\n \
|
||||
Arch-indep binary so the indep build path is exercised.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/rules"),
|
||||
"#!/usr/bin/make -f\n\
|
||||
%:\n\
|
||||
\tdh $@\n\
|
||||
\n\
|
||||
override_dh_auto_build:\n\
|
||||
\tprintf '#include <db.h>\\nint main(void){DB *d; return db_create(&d, NULL, 0);}\\n' > main.c\n\
|
||||
\t$(DEB_HOST_GNU_TYPE)-gcc main.c -ldb -o pkh-crosstest\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let rules = pkg_dir.join("debian/rules");
|
||||
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&rules, perms).unwrap();
|
||||
|
||||
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||
|
||||
pkg_dir
|
||||
}
|
||||
|
||||
/// This ensures the arch-independant build-dep pass of a cross build
|
||||
/// resolves dependencies for the host architecture, like dpkg-checkbuilddeps
|
||||
/// does, instead of re-resolving the whole Build-Depends field for the
|
||||
/// native architecture, which swaps host-arch -dev packages for native ones
|
||||
/// and breaks the cross build environment.
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
async fn test_deb_cross_indep_host_arch_end_to_end() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let pkg_dir = create_indep_cross_test_source(temp_dir.path());
|
||||
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
|
||||
crate::deb::build_binary_package(DebBuildOptions {
|
||||
arch: Some("arm64".to_string()),
|
||||
series: Some("noble".to_string()),
|
||||
cwd: Some(pkg_dir),
|
||||
cross: true,
|
||||
ctx: Some(ctx),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("Cannot cross-build package declaring Build-Depends-Indep");
|
||||
|
||||
// Both binary packages must have been produced, including the
|
||||
// arch-independant one
|
||||
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
deb_files
|
||||
.iter()
|
||||
.any(|f| f.starts_with("pkh-crosstest_1.0_arm64.deb")),
|
||||
"arch-specific .deb not produced, got: {deb_files:?}"
|
||||
);
|
||||
assert!(
|
||||
deb_files
|
||||
.iter()
|
||||
.any(|f| f.starts_with("pkh-crosstest-data_1.0_all.deb")),
|
||||
"arch-independant .deb not produced, got: {deb_files:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cross build whose build-dependencies cannot be satisfied under
|
||||
/// dpkg's cross semantics must abort at the install step, naming the
|
||||
/// unsatisfied dependency: the expected-failure counterpart of the
|
||||
/// cross builds above.
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
async fn test_deb_cross_unsatisfiable_build_dep_fails_end_to_end() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let pkg_dir = temp_dir.path().join("pkh-crosstest");
|
||||
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/changelog"),
|
||||
"pkh-crosstest (1.0) noble; urgency=medium\n\n \
|
||||
* Synthetic package declaring an unsatisfiable build-dependency.\n\n \
|
||||
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/control"),
|
||||
"Source: pkh-crosstest\n\
|
||||
Section: devel\n\
|
||||
Priority: optional\n\
|
||||
Maintainer: pkh tests <pkh@example.com>\n\
|
||||
Standards-Version: 4.7.4\n\
|
||||
Build-Depends: pkh-no-such-package-xyz\n\
|
||||
Architecture: any\n\
|
||||
\n\
|
||||
Package: pkh-crosstest\n\
|
||||
Architecture: any\n\
|
||||
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||
Description: Cross-build negative regression package\n \
|
||||
Declares a build-dependency absent from the archive.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/rules"),
|
||||
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
|
||||
)
|
||||
.unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let rules = pkg_dir.join("debian/rules");
|
||||
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&rules, perms).unwrap();
|
||||
|
||||
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
|
||||
let err = crate::deb::build_binary_package(DebBuildOptions {
|
||||
arch: Some("arm64".to_string()),
|
||||
series: Some("noble".to_string()),
|
||||
cwd: Some(pkg_dir),
|
||||
cross: true,
|
||||
ctx: Some(ctx),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("an unsatisfiable build-dependency must fail the cross build");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("pkh-no-such-package-xyz"),
|
||||
"error should name the unsatisfied dependency: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An embedded-caller layout — the tree checked out at <job>/tree, a
|
||||
/// name matching none of the search patterns — must build: the staged
|
||||
/// copy of the tree the caller pointed at is resolved directly instead
|
||||
/// of being re-derived from package/version names (which used to fail
|
||||
/// with 'Could not find package directory').
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
async fn test_deb_builds_a_tree_named_directory_end_to_end() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let pkg_dir = temp_dir.path().join("j-42/tree");
|
||||
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/changelog"),
|
||||
"pkh-treetest (1.0) noble; urgency=medium\n\n \
|
||||
* Synthetic package built from a directory named 'tree'.\n\n \
|
||||
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/control"),
|
||||
"Source: pkh-treetest\n\
|
||||
Section: devel\n\
|
||||
Priority: optional\n\
|
||||
Maintainer: pkh tests <pkh@example.com>\n\
|
||||
Standards-Version: 4.7.4\n\
|
||||
Build-Depends: debhelper-compat (= 13)\n\
|
||||
Architecture: any\n\
|
||||
\n\
|
||||
Package: pkh-treetest\n\
|
||||
Architecture: any\n\
|
||||
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||
Description: Package-directory resolution regression package\n \
|
||||
Its tree lives in a directory whose name matches none of the\n \
|
||||
package-directory search patterns.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
pkg_dir.join("debian/rules"),
|
||||
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
|
||||
)
|
||||
.unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let rules = pkg_dir.join("debian/rules");
|
||||
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&rules, perms).unwrap();
|
||||
|
||||
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||
|
||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||
|
||||
crate::deb::build_binary_package(DebBuildOptions {
|
||||
series: Some("noble".to_string()),
|
||||
cwd: Some(pkg_dir),
|
||||
ctx: Some(ctx),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("a tree named 'tree' must build");
|
||||
|
||||
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path().join("j-42"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
deb_files
|
||||
.iter()
|
||||
.any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")),
|
||||
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
//! Debian changelog entry parsing (`debian/changelog`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
use regex::Regex;
|
||||
|
||||
use super::version::DebianVersion;
|
||||
|
||||
/// A parsed `debian/changelog` entry (the most recent one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Source package name.
|
||||
pub source: String,
|
||||
/// Parsed version.
|
||||
pub version: DebianVersion,
|
||||
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
|
||||
pub distribution: String,
|
||||
/// Urgency value, e.g. `"medium"`.
|
||||
pub urgency: String,
|
||||
/// True for binNMU-style entries (`binary-only=yes` header parameter).
|
||||
pub binary_only: bool,
|
||||
/// Maintainer name from the trailer line.
|
||||
pub maintainer_name: String,
|
||||
/// Maintainer email from the trailer line.
|
||||
pub maintainer_email: String,
|
||||
/// Verbatim trailer date string (RFC2822-ish).
|
||||
pub date_raw: String,
|
||||
/// Trailer date parsed as a Unix timestamp.
|
||||
pub timestamp: i64,
|
||||
/// Value for the `.changes` `Changes` field: header line, blank lines
|
||||
/// converted to `.`, body lines verbatim; without the trailer line.
|
||||
pub changes_field: String,
|
||||
/// Bug numbers collected from `(Closes: #NNN)` mentions in the body,
|
||||
/// sorted numerically and de-duplicated (like dpkg's `find_closes`).
|
||||
pub closes: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse up to `limit` entries of a Debian changelog file, newest first
|
||||
/// (`None` parses the whole file).
|
||||
pub fn parse_changelog_entries(
|
||||
path: &Path,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read changelog '{}': {}. Make sure you are running \
|
||||
from the root of a source package.",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
parse_changelog_entries_from_str(&content, limit)
|
||||
}
|
||||
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
parse_changelog_entries(path, Some(1)).map(|mut entries| entries.remove(0))
|
||||
}
|
||||
|
||||
/// Parse the most recent changelog entry from its textual content.
|
||||
pub fn parse_changelog_entry_from_str(
|
||||
content: &str,
|
||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
parse_changelog_entries_from_str(content, Some(1)).map(|mut entries| entries.remove(0))
|
||||
}
|
||||
|
||||
/// Parse changelog entries from their textual content, newest first.
|
||||
///
|
||||
/// `limit` bounds the number of parsed entries (`None` parses the whole
|
||||
/// file). Content below the last entry that is not another entry header
|
||||
/// (e.g. an older changelog kept in a non-Debian format) is ignored.
|
||||
pub fn parse_changelog_entries_from_str(
|
||||
content: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
|
||||
let origin = "changelog";
|
||||
let mut lines = content.lines().peekable();
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
if limit.is_some_and(|n| entries.len() >= n) {
|
||||
break;
|
||||
}
|
||||
// Blank separators between entries.
|
||||
while lines.peek().is_some_and(|l| l.trim().is_empty()) {
|
||||
lines.next();
|
||||
}
|
||||
let Some(next) = lines.peek() else {
|
||||
break;
|
||||
};
|
||||
if !entries.is_empty() && !looks_like_header(next.trim_end()) {
|
||||
break;
|
||||
}
|
||||
entries.push(parse_one_entry(&mut lines, origin)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Parse one entry: header line, body, maintainer trailer. Parsing stops
|
||||
/// without consuming the first line that is a trailer terminator, an emacs
|
||||
/// local-variables block, or the next entry's header — the stream can then
|
||||
/// be resumed for the following entry.
|
||||
fn parse_one_entry(
|
||||
lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
|
||||
origin: &str,
|
||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||
let header = loop {
|
||||
match lines.next() {
|
||||
Some(l) if l.trim().is_empty() => continue,
|
||||
Some(l) => break l.trim_end(),
|
||||
None => {
|
||||
return Err(format!("changelog '{origin}' is empty").into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let open = header
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header in '{origin}': {header}"))?;
|
||||
let close = header[open..]
|
||||
.find(')')
|
||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
|
||||
let source = header[..open].trim().to_string();
|
||||
if source.is_empty() || source.contains(' ') {
|
||||
return Err(format!("invalid source name in changelog header '{}'", header).into());
|
||||
}
|
||||
let version = DebianVersion::parse(&header[open + 1..open + close])?;
|
||||
|
||||
let after_version = &header[open + close + 1..];
|
||||
let (distributions_part, params_part) = match after_version.split_once(';') {
|
||||
Some((d, p)) => (d, p),
|
||||
None => (after_version, ""),
|
||||
};
|
||||
let distribution = distributions_part.trim().to_string();
|
||||
if distribution.is_empty() {
|
||||
return Err(format!("missing distribution in changelog header '{}'", header).into());
|
||||
}
|
||||
|
||||
let mut urgency = String::from("unknown");
|
||||
let mut binary_only = false;
|
||||
for param in params_part.split(',') {
|
||||
let param = param.trim();
|
||||
if let Some(value) = param.strip_prefix("urgency=") {
|
||||
urgency = value.trim().to_string();
|
||||
} else if param == "binary-only=yes" || param == "binary-only=yes," {
|
||||
binary_only = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Body until trailer line ` -- Name <email> Date`
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
let mut trailer: Option<String> = None;
|
||||
while let Some(line) = lines.peek().copied() {
|
||||
let line = line.trim_end();
|
||||
if line.starts_with(" -- ") {
|
||||
trailer = lines.next().map(|l| l.trim_end().to_string());
|
||||
break;
|
||||
}
|
||||
// Stop at an emacs local-variables block or a new entry header
|
||||
// (both peeked, not consumed).
|
||||
if line.starts_with("Local variables:") {
|
||||
break;
|
||||
}
|
||||
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
||||
break;
|
||||
}
|
||||
lines.next();
|
||||
// Blank lines become "." like dpkg does for the Changes field.
|
||||
if line.trim().is_empty() {
|
||||
body_lines.push(".".to_string());
|
||||
} else {
|
||||
body_lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let trailer_line = trailer.ok_or_else(|| {
|
||||
format!(
|
||||
"no maintainer trailer found in '{origin}': expected a line of the form \
|
||||
' -- Name <email> Date'"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Strip trailing "." lines left over from blank lines before the trailer.
|
||||
while body_lines.last().map(|l| l == ".").unwrap_or(false) {
|
||||
body_lines.pop();
|
||||
}
|
||||
|
||||
let trailer_body = trailer_line.strip_prefix(" -- ").unwrap_or(&trailer_line);
|
||||
let lt = trailer_body
|
||||
.find('<')
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let gt = trailer_body[lt..]
|
||||
.find('>')
|
||||
.map(|i| i + lt)
|
||||
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
|
||||
let maintainer_name = trailer_body[..lt].trim().to_string();
|
||||
let maintainer_email = trailer_body[lt + 1..gt].trim().to_string();
|
||||
let date_raw = trailer_body[gt + 1..].trim().to_string();
|
||||
|
||||
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
|
||||
.map_err(|e| format!("cannot parse changelog date '{date_raw}' in '{origin}': {e}"))?
|
||||
.timestamp();
|
||||
|
||||
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
|
||||
// field, like dpkg's own representation): header + blank-as-dot + body,
|
||||
// without the trailer line.
|
||||
let mut changes_field = String::from("\n");
|
||||
changes_field.push_str(header);
|
||||
if !body_lines.is_empty() {
|
||||
changes_field.push('\n');
|
||||
changes_field.push_str(&body_lines.join("\n"));
|
||||
}
|
||||
|
||||
let closes = find_closes(&body_lines);
|
||||
|
||||
Ok(ChangelogEntry {
|
||||
source,
|
||||
version,
|
||||
distribution,
|
||||
urgency,
|
||||
binary_only,
|
||||
maintainer_name,
|
||||
maintainer_email,
|
||||
date_raw,
|
||||
timestamp,
|
||||
changes_field,
|
||||
closes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract bug numbers from `(Closes: #NNN)` mentions in changelog body
|
||||
/// lines, sorted numerically and de-duplicated (a lenient port of dpkg's
|
||||
/// `find_closes`).
|
||||
fn find_closes(body_lines: &[String]) -> Option<String> {
|
||||
let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?;
|
||||
let mut numbers: Vec<u64> = Vec::new();
|
||||
for line in body_lines {
|
||||
for capture in re.captures_iter(line) {
|
||||
if let Some(inner) = capture.get(1) {
|
||||
for token in inner.as_str().split(|c: char| !c.is_ascii_digit()) {
|
||||
if let Ok(n) = token.parse::<u64>() {
|
||||
numbers.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if numbers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
numbers.sort_unstable();
|
||||
numbers.dedup();
|
||||
Some(
|
||||
numbers
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
/// Heuristic check for a changelog entry header line
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
// Headers are never indented.
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return false;
|
||||
}
|
||||
match line.find('(') {
|
||||
Some(open) => {
|
||||
let name = line[..open].trim();
|
||||
!name.is_empty() && !name.contains(' ')
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changelog_parsing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkh-hello (0.1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Second change line.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert_eq!(entry.source, "pkh-hello");
|
||||
assert_eq!(entry.version.full(), "0.1");
|
||||
assert_eq!(entry.distribution, "unstable");
|
||||
assert_eq!(entry.urgency, "medium");
|
||||
assert!(!entry.binary_only);
|
||||
assert_eq!(entry.maintainer_name, "Pkh Tester");
|
||||
assert_eq!(entry.maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entry.timestamp, 1787392800);
|
||||
assert_eq!(
|
||||
entry.changes_field,
|
||||
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bin_nmu() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
let content = "\
|
||||
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
|
||||
* Binary-only non-maintainer upload.
|
||||
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
|
||||
";
|
||||
std::fs::write(&path, content).unwrap();
|
||||
|
||||
let entry = parse_changelog_entry(&path).unwrap();
|
||||
assert!(entry.binary_only);
|
||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
||||
}
|
||||
|
||||
const THREE_ENTRIES: &str = "\
|
||||
pkg (2.0-1) unstable; urgency=low
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
pkg (1.4-2) unstable; urgency=medium
|
||||
|
||||
* Revision bump.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Wed, 01 Jan 2025 00:00:00 +0000
|
||||
|
||||
pkg (1.4-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 01 Mar 2025 00:00:00 +0000
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn entries_parse_newest_first_with_limits() {
|
||||
// Whole file.
|
||||
let all = parse_changelog_entries_from_str(THREE_ENTRIES, None).unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].version.full(), "2.0-1");
|
||||
assert_eq!(all[1].version.full(), "1.4-2");
|
||||
assert_eq!(all[2].version.full(), "1.4-1");
|
||||
|
||||
// Bounded limits.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(THREE_ENTRIES, Some(1))
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let two = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
|
||||
assert_eq!(two.len(), 2);
|
||||
assert_eq!(two[0].version.full(), "2.0-1");
|
||||
assert_eq!(two[1].version.full(), "1.4-2");
|
||||
|
||||
// A limit beyond the entry count yields everything.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(THREE_ENTRIES, Some(10))
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
|
||||
// The single-entry helpers agree with a limit of 1.
|
||||
let one = parse_changelog_entries_from_str(THREE_ENTRIES, Some(1)).unwrap();
|
||||
let via_helper = parse_changelog_entry_from_str(THREE_ENTRIES).unwrap();
|
||||
assert_eq!(one[0].version.full(), via_helper.version.full());
|
||||
assert_eq!(one[0].source, via_helper.source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_ignore_trailing_foreign_content() {
|
||||
let content = "\
|
||||
pkg (1.0) unstable; urgency=medium
|
||||
|
||||
* Something.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
older changelog kept in an ad-hoc format:
|
||||
version 0.9 - some text, not a Debian entry
|
||||
version 0.8 - more text
|
||||
";
|
||||
let entries = parse_changelog_entries_from_str(content, None).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].version.full(), "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_parse_body_of_later_entries() {
|
||||
let entries = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
|
||||
// The second entry's body and trailer are fully parsed, not merely
|
||||
// its header line.
|
||||
assert_eq!(
|
||||
entries[1].changes_field,
|
||||
"\npkg (1.4-2) unstable; urgency=medium\n.\n * Revision bump."
|
||||
);
|
||||
assert_eq!(entries[1].maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entries[1].urgency, "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_reject_malformed_later_entry() {
|
||||
let content = "\
|
||||
pkg (1.0) unstable; urgency=medium
|
||||
|
||||
* Something.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
pkg (0.9) unstable; urgency=medium
|
||||
|
||||
* No trailer below.
|
||||
";
|
||||
assert!(parse_changelog_entries_from_str(content, None).is_err());
|
||||
// Not parsed when not requested.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(content, Some(1))
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
//! File checksum computation and formatting for `.changes` / `.buildinfo`
|
||||
//! fields (MD5, SHA-1, SHA-256, SHA-512 + size), mirroring `Dpkg::Checksums`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use md5::Md5;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Checksums and size of a single file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Lowercase hexadecimal MD5 digest.
|
||||
pub md5: String,
|
||||
/// Lowercase hexadecimal SHA-1 digest.
|
||||
pub sha1: String,
|
||||
/// Lowercase hexadecimal SHA-256 digest.
|
||||
pub sha256: String,
|
||||
/// Lowercase hexadecimal SHA-512 digest.
|
||||
pub sha512: String,
|
||||
}
|
||||
|
||||
/// The checksum algorithm carried by a `Checksums-*` field body, as handled
|
||||
/// by [`FileChecksums::parse_field`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChecksumKind {
|
||||
/// SHA-1 (`Checksums-Sha1` field).
|
||||
Sha1,
|
||||
/// SHA-256 (`Checksums-Sha256` field).
|
||||
Sha256,
|
||||
/// SHA-512 (`Checksums-Sha512` field).
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl ChecksumKind {
|
||||
/// The `Checksums-*` field name carrying this digest.
|
||||
pub fn field_name(self) -> &'static str {
|
||||
match self {
|
||||
ChecksumKind::Sha1 => "Checksums-Sha1",
|
||||
ChecksumKind::Sha256 => "Checksums-Sha256",
|
||||
ChecksumKind::Sha512 => "Checksums-Sha512",
|
||||
}
|
||||
}
|
||||
|
||||
/// Length in lowercase hex characters of one digest of this kind.
|
||||
fn digest_len(self) -> usize {
|
||||
match self {
|
||||
ChecksumKind::Sha1 => 40,
|
||||
ChecksumKind::Sha256 => 64,
|
||||
ChecksumKind::Sha512 => 128,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute all supported checksums of a file.
|
||||
fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
|
||||
let mut file = std::fs::File::open(path)
|
||||
.map_err(|e| format!("cannot open '{}' for checksumming: {}", path.display(), e))?;
|
||||
|
||||
let mut md5_hasher = Md5::new();
|
||||
let mut sha1_hasher = Sha1::new();
|
||||
let mut sha256_hasher = Sha256::new();
|
||||
let mut sha512_hasher = Sha512::new();
|
||||
let mut size: u64 = 0;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
md5_hasher.update(&buf[..n]);
|
||||
sha1_hasher.update(&buf[..n]);
|
||||
sha256_hasher.update(&buf[..n]);
|
||||
sha512_hasher.update(&buf[..n]);
|
||||
size += n as u64;
|
||||
}
|
||||
|
||||
Ok(Entry {
|
||||
size,
|
||||
md5: hex::encode(md5_hasher.finalize()),
|
||||
sha1: hex::encode(sha1_hasher.finalize()),
|
||||
sha256: hex::encode(sha256_hasher.finalize()),
|
||||
sha512: hex::encode(sha512_hasher.finalize()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A registry of checksummed files, keyed by the name they are distributed
|
||||
/// under (which may differ from the on-disk path).
|
||||
///
|
||||
/// Insertion order is preserved, matching the order in which
|
||||
/// `dpkg-genchanges` accumulates artifacts (dsc, tarballs, debs, buildinfo).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileChecksums {
|
||||
entries: Vec<(String, Entry)>,
|
||||
index: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl FileChecksums {
|
||||
/// Create an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a file, registering it under its own file name.
|
||||
pub fn add_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let key = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| format!("invalid file name: {}", path.display()))?
|
||||
.to_string();
|
||||
self.add_file_as(path, &key)
|
||||
}
|
||||
|
||||
/// Add a file, registering it under an explicit distribution key.
|
||||
pub fn add_file_as(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let entry = compute(path)?;
|
||||
self.insert_entry(key, entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a pre-computed entry (e.g. taken from a `.dsc` checksum field).
|
||||
/// Re-inserting an existing key updates it in place, keeping its position.
|
||||
pub fn insert_entry(&mut self, key: &str, entry: Entry) {
|
||||
if let Some(&pos) = self.index.get(key) {
|
||||
self.entries[pos].1 = entry;
|
||||
return;
|
||||
}
|
||||
self.index.insert(key.to_string(), self.entries.len());
|
||||
self.entries.push((key.to_string(), entry));
|
||||
}
|
||||
|
||||
/// Remove a file from the registry. Returns true if it was present.
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
match self.index.remove(key) {
|
||||
Some(pos) => {
|
||||
self.entries.remove(pos);
|
||||
// Reindex the shifted tail.
|
||||
for (i, (k, _)) in self.entries.iter().enumerate().skip(pos) {
|
||||
self.index.insert(k.clone(), i);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the entry for a given key.
|
||||
pub fn get(&self, key: &str) -> Option<&Entry> {
|
||||
self.index.get(key).map(|&pos| &self.entries[pos].1)
|
||||
}
|
||||
|
||||
/// Iterate over `(key, entry)` pairs in insertion order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
|
||||
self.entries.iter().map(|(k, e)| (k, e))
|
||||
}
|
||||
|
||||
/// Number of registered files.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True if no file is registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Format a `Checksums-*` style field value: one `\n`-separated line per
|
||||
/// file of the form `" <hash> <size> <key>"`.
|
||||
fn format_field<F>(&self, hash_of: F) -> String
|
||||
where
|
||||
F: Fn(&Entry) -> &str,
|
||||
{
|
||||
let mut out = String::new();
|
||||
for (key, e) in self.iter() {
|
||||
out.push('\n');
|
||||
out.push_str(hash_of(e));
|
||||
out.push(' ');
|
||||
out.push_str(&e.size.to_string());
|
||||
out.push(' ');
|
||||
out.push_str(key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Md5` field (empty string if no file).
|
||||
pub fn field_md5(&self) -> String {
|
||||
self.format_field(|e| &e.md5)
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Sha1` field (empty string if no file).
|
||||
pub fn field_sha1(&self) -> String {
|
||||
self.format_field(|e| &e.sha1)
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Sha256` field (empty string if no file).
|
||||
pub fn field_sha256(&self) -> String {
|
||||
self.format_field(|e| &e.sha256)
|
||||
}
|
||||
|
||||
/// Value for the `Checksums-Sha512` field, or `None` when any registered
|
||||
/// file has no SHA-512 digest (e.g. entries merged from a `.dsc`, which
|
||||
/// dpkg only writes with sha1/sha256 checksums): renderers omit the
|
||||
/// field instead of writing an incomplete checksum list.
|
||||
pub fn field_sha512(&self) -> Option<String> {
|
||||
if self.iter().any(|(_, e)| e.sha512.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
Some(self.format_field(|e| &e.sha512))
|
||||
}
|
||||
|
||||
/// Parse the body of a `Checksums-Sha1` / `Checksums-Sha256` /
|
||||
/// `Checksums-Sha512` field (as rendered by [`FileChecksums::field_sha1`],
|
||||
/// [`FileChecksums::field_sha256`] or [`FileChecksums::field_sha512`])
|
||||
/// into `(name, entry)` pairs, ready to be fed into
|
||||
/// [`FileChecksums::insert_entry`] (e.g. when consuming a `.dsc`).
|
||||
///
|
||||
/// Each non-blank line holds `"<hex digest> <size> <name>"`; blank lines
|
||||
/// are tolerated and anything else is a malformed line, reported as an
|
||||
/// error naming [`ChecksumKind::field_name`] and the offending line. Only
|
||||
/// the digest selected by `kind` is filled in the returned entries: the
|
||||
/// other digest fields are left empty and must be completed from the
|
||||
/// remaining `Checksums-*` fields (or by recomputation) before rendering.
|
||||
///
|
||||
/// Note: this deliberately re-implements the line grammar of the private
|
||||
/// `build::parse_checksum_field` helper (which additionally accepts the
|
||||
/// legacy 5-column `Files` layout); the two are intentionally not unified
|
||||
/// across modules.
|
||||
pub fn parse_field(kind: ChecksumKind, value: &str) -> Result<Vec<(String, Entry)>, String> {
|
||||
let mut entries = Vec::new();
|
||||
for line in value.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
let [digest, size, name] = tokens.as_slice() else {
|
||||
return Err(format!(
|
||||
"malformed '{}' line (expected 'checksum size name', got {} \
|
||||
columns): '{line}'",
|
||||
kind.field_name(),
|
||||
tokens.len()
|
||||
));
|
||||
};
|
||||
let size: u64 = size.parse().map_err(|_| {
|
||||
format!(
|
||||
"malformed '{}' line (size '{size}' is not a number): '{line}'",
|
||||
kind.field_name()
|
||||
)
|
||||
})?;
|
||||
let digest_ok = digest.len() == kind.digest_len()
|
||||
&& digest
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
|
||||
if !digest_ok {
|
||||
return Err(format!(
|
||||
"malformed '{}' line (digest '{digest}' is not {} lowercase \
|
||||
hex characters): '{line}'",
|
||||
kind.field_name(),
|
||||
kind.digest_len()
|
||||
));
|
||||
}
|
||||
let mut entry = Entry {
|
||||
size,
|
||||
md5: String::new(),
|
||||
sha1: String::new(),
|
||||
sha256: String::new(),
|
||||
sha512: String::new(),
|
||||
};
|
||||
match kind {
|
||||
ChecksumKind::Sha1 => entry.sha1 = digest.to_string(),
|
||||
ChecksumKind::Sha256 => entry.sha256 = digest.to_string(),
|
||||
ChecksumKind::Sha512 => entry.sha512 = digest.to_string(),
|
||||
}
|
||||
entries.push((name.to_string(), entry));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("sample.txt");
|
||||
std::fs::write(&p, b"hello world\n").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&p).unwrap();
|
||||
|
||||
let e = cs.get("sample.txt").unwrap();
|
||||
// Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum / sha512sum
|
||||
assert_eq!(e.md5, "6f5902ac237024bdd0c176cb93063dc4");
|
||||
assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511");
|
||||
assert_eq!(
|
||||
e.sha256,
|
||||
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
|
||||
);
|
||||
assert_eq!(
|
||||
e.sha512,
|
||||
"db3974a97f2407b7cae1ae637c0030687a11913274d578492558e39c16c017de\
|
||||
84eacdc8c62fe34ee4e12b4b1428817f09b6a2760c3f8a664ceae94d2434a593"
|
||||
);
|
||||
assert_eq!(e.size, 12);
|
||||
}
|
||||
|
||||
/// SHA-512 of the empty input is a well-known constant: a zero-size file
|
||||
/// must still carry it (never an empty digest string, which is reserved
|
||||
/// for "digest unknown", e.g. entries merged from a `.dsc`).
|
||||
#[test]
|
||||
fn sha512_of_empty_file_is_the_known_constant() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("empty.txt");
|
||||
std::fs::write(&p, b"").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&p).unwrap();
|
||||
|
||||
let e = cs.get("empty.txt").unwrap();
|
||||
assert_eq!(e.size, 0);
|
||||
assert_eq!(
|
||||
e.sha512,
|
||||
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\
|
||||
47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
|
||||
);
|
||||
}
|
||||
|
||||
/// All four digests render; the `Checksums-Sha512` field round-trips
|
||||
/// through [`FileChecksums::parse_field`] back into a registry with
|
||||
/// identical names (insertion order), sizes and SHA-512 digests.
|
||||
#[test]
|
||||
fn sha512_field_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
std::fs::write(&b, b"bb").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&b).unwrap();
|
||||
cs.add_file(&a).unwrap();
|
||||
|
||||
// Every digest kind must be populated and render a full field.
|
||||
assert!(!cs.field_md5().is_empty());
|
||||
assert!(!cs.field_sha1().is_empty());
|
||||
assert!(!cs.field_sha256().is_empty());
|
||||
let sha512_field = cs.field_sha512().expect("all entries have sha512");
|
||||
|
||||
// Round-trip the Checksums-Sha512 field through the parser.
|
||||
let mut reparsed = FileChecksums::new();
|
||||
for (key, entry) in FileChecksums::parse_field(ChecksumKind::Sha512, &sha512_field).unwrap()
|
||||
{
|
||||
reparsed.insert_entry(&key, entry);
|
||||
}
|
||||
let keys: Vec<&str> = reparsed.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, vec!["b.txt", "a.txt"], "insertion order preserved");
|
||||
for (key, e) in cs.iter() {
|
||||
let got = reparsed.get(key).unwrap();
|
||||
assert_eq!(got.size, e.size, "{key}");
|
||||
assert_eq!(got.sha512, e.sha512, "{key}");
|
||||
}
|
||||
// Rendering the re-parsed registry yields the same field value.
|
||||
assert_eq!(reparsed.field_sha512().unwrap(), sha512_field);
|
||||
|
||||
// The parser dispatches on `kind`: a Checksums-Sha256 body fills the
|
||||
// sha256 column, leaving the others (including sha512) unknown.
|
||||
let (key, entry) =
|
||||
&FileChecksums::parse_field(ChecksumKind::Sha256, &cs.field_sha256()).unwrap()[0];
|
||||
assert_eq!(entry.sha256, cs.get(key).unwrap().sha256);
|
||||
assert!(entry.sha512.is_empty());
|
||||
let (_, entry) =
|
||||
&FileChecksums::parse_field(ChecksumKind::Sha512, &sha512_field).unwrap()[0];
|
||||
assert!(entry.md5.is_empty() && entry.sha1.is_empty() && entry.sha256.is_empty());
|
||||
assert!(!entry.sha512.is_empty());
|
||||
}
|
||||
|
||||
/// Malformed `Checksums-Sha512` bodies (wrong column count, non-numeric
|
||||
/// size, wrong digest shape) must be rejected with an error naming the
|
||||
/// field and the offending line; blank lines are tolerated.
|
||||
#[test]
|
||||
fn parse_field_rejects_malformed_lines() {
|
||||
// 128 lowercase hex characters, as rendered by field_sha512.
|
||||
let digest = "ab".repeat(64);
|
||||
|
||||
// Blank lines are skipped.
|
||||
let entries =
|
||||
FileChecksums::parse_field(ChecksumKind::Sha512, &format!("\n {digest} 12 a.txt\n\n"))
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].0, "a.txt");
|
||||
assert_eq!(entries[0].1.size, 12);
|
||||
|
||||
// 2 columns: missing the name.
|
||||
let err =
|
||||
FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12")).unwrap_err();
|
||||
assert!(err.contains("Checksums-Sha512"), "{err}");
|
||||
assert!(err.contains(&format!("{digest} 12")), "{err}");
|
||||
|
||||
// 4 columns.
|
||||
let err =
|
||||
FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} 12 bogus a.txt"))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Checksums-Sha512"), "{err}");
|
||||
assert!(err.contains("a.txt"), "{err}");
|
||||
|
||||
// Non-numeric size.
|
||||
let err =
|
||||
FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} twelve a.txt"))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("not a number"), "{err}");
|
||||
assert!(err.contains("twelve"), "{err}");
|
||||
|
||||
// Digest that is not 128 lowercase hex characters.
|
||||
let err = FileChecksums::parse_field(ChecksumKind::Sha512, " abc123 12 a.txt").unwrap_err();
|
||||
assert!(err.contains("lowercase hex"), "{err}");
|
||||
}
|
||||
|
||||
/// `field_sha512` is only-if-populated: an entry merged without a SHA-512
|
||||
/// digest (e.g. taken from a `.dsc`) suppresses the whole field instead
|
||||
/// of rendering an incomplete checksum list.
|
||||
#[test]
|
||||
fn field_sha512_omitted_when_any_digest_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&a).unwrap();
|
||||
assert!(cs.field_sha512().is_some());
|
||||
|
||||
cs.insert_entry(
|
||||
"from.dsc",
|
||||
Entry {
|
||||
size: 12,
|
||||
md5: "d41d8cd98f00b204e9800998ecf8427e".to_string(),
|
||||
sha1: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(),
|
||||
sha256: format!("e3b0{:0>62}", "0"),
|
||||
sha512: String::new(), // not recorded in .dsc files
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
cs.field_sha512().is_none(),
|
||||
"one incomplete entry must suppress Checksums-Sha512"
|
||||
);
|
||||
// The other kinds are unaffected.
|
||||
assert!(!cs.field_md5().is_empty());
|
||||
assert!(!cs.field_sha1().is_empty());
|
||||
assert!(!cs.field_sha256().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_order_preserved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
std::fs::write(&b, b"bb").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
// Insert b first: insertion order (not alphabetical) must be kept,
|
||||
// matching dpkg's artifact accumulation order.
|
||||
cs.add_file(&b).unwrap();
|
||||
cs.add_file(&a).unwrap();
|
||||
|
||||
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, vec!["b.txt", "a.txt"]);
|
||||
|
||||
assert_eq!(
|
||||
cs.field_md5(),
|
||||
"\n21ad0bd836b90d08f4cf640b4c298e7c 2 b.txt\n47bce5c74f589f4867dbd57e9ca9f808 3 a.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinsert_updates_in_place() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
std::fs::write(&a, b"aaa").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&a).unwrap();
|
||||
std::fs::write(&a, b"bbbb").unwrap();
|
||||
cs.add_file(&a).unwrap(); // updated in place, same position
|
||||
|
||||
assert_eq!(cs.len(), 1);
|
||||
assert_eq!(cs.get("a.txt").unwrap().size, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keeps_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.txt");
|
||||
let b = dir.path().join("b.txt");
|
||||
let c = dir.path().join("c.txt");
|
||||
std::fs::write(&a, b"1").unwrap();
|
||||
std::fs::write(&b, b"2").unwrap();
|
||||
std::fs::write(&c, b"3").unwrap();
|
||||
|
||||
let mut cs = FileChecksums::new();
|
||||
cs.add_file(&a).unwrap();
|
||||
cs.add_file(&b).unwrap();
|
||||
cs.add_file(&c).unwrap();
|
||||
assert!(cs.remove("b.txt"));
|
||||
assert!(!cs.remove("b.txt"));
|
||||
|
||||
let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, vec!["a.txt", "c.txt"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Debian control-file handling: a minimal deb822 paragraph parser/writer
|
||||
//! plus a `debian/control` model.
|
||||
//!
|
||||
//! Implements the subset of RFC822-ish parsing needed for `debian/control`,
|
||||
//! `debian/files`, `.dsc`, `.changes` and `.buildinfo` files: paragraphs
|
||||
//! separated by blank lines, `Field: value` entries with continuation lines
|
||||
//! starting by a single space or tab, and `#` comments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A single deb822 paragraph: an ordered list of `(field, value)` pairs.
|
||||
///
|
||||
/// The parser is lenient: duplicate field names are kept as separate entries
|
||||
/// (accessors see the first one; `set` collapses them back to a single one).
|
||||
///
|
||||
/// Values are stored with continuation-line breaks as `\n` and without the
|
||||
/// leading whitespace of continuation lines. Serialization re-adds a single
|
||||
/// leading space in front of every continuation line, matching dpkg output;
|
||||
/// blank lines inside a value are encoded as ` .` (and decoded back) so they
|
||||
/// survive a write/parse round-trip.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
/// Create an empty paragraph.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up a field value (case-insensitive field name).
|
||||
///
|
||||
/// Returns the first match. The parser is lenient and keeps duplicate
|
||||
/// field names as-is; use [`Paragraph::iter`] to reach the other
|
||||
/// occurrences. [`Paragraph::set`] collapses them.
|
||||
pub fn get(&self, field: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Set a field value, replacing all case-insensitive duplicates: after
|
||||
/// the call at most one entry with this field name remains — the updated
|
||||
/// one, kept at its original position. Appends the field at the end if
|
||||
/// no entry existed yet.
|
||||
pub fn set(&mut self, field: &str, value: &str) {
|
||||
let mut updated = false;
|
||||
self.fields.retain_mut(|(k, v)| {
|
||||
if k.eq_ignore_ascii_case(field) {
|
||||
if updated {
|
||||
return false;
|
||||
}
|
||||
*v = value.to_string();
|
||||
updated = true;
|
||||
}
|
||||
true
|
||||
});
|
||||
if !updated {
|
||||
self.fields.push((field.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a field (case-insensitive). Returns true if it was present.
|
||||
pub fn remove(&mut self, field: &str) -> bool {
|
||||
let before = self.fields.len();
|
||||
self.fields.retain(|(k, _)| !k.eq_ignore_ascii_case(field));
|
||||
self.fields.len() != before
|
||||
}
|
||||
|
||||
/// Iterate over the `(field, value)` pairs in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
}
|
||||
|
||||
/// Return true if the paragraph holds no field.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a deb822 document into a list of paragraphs.
|
||||
///
|
||||
/// Comment lines (starting with `#`) are ignored. Blank lines separate
|
||||
/// paragraphs. Continuation lines must start with a space or a tab; exactly
|
||||
/// one leading space (or tab) is stripped from the stored value, and a
|
||||
/// continuation whose content is a lone `.` decodes to an empty line
|
||||
/// (dpkg's encoding for blank lines inside field values).
|
||||
pub fn parse_paragraphs(input: &str) -> Vec<Paragraph> {
|
||||
let mut paragraphs = Vec::new();
|
||||
let mut current = Paragraph::new();
|
||||
let mut last_field: Option<String> = None;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
|
||||
// Comments and blank lines
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(std::mem::take(&mut current));
|
||||
last_field = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
// Exactly one leading space or tab is stripped.
|
||||
let content = line
|
||||
.strip_prefix(' ')
|
||||
.or_else(|| line.strip_prefix('\t'))
|
||||
.unwrap_or(line);
|
||||
// dpkg encodes a blank line inside a value as a lone `.` after
|
||||
// the leading whitespace; mirror that on read.
|
||||
let content = if content == "." { "" } else { content };
|
||||
if let Some(field) = &last_field
|
||||
&& let Some((_, v)) = current
|
||||
.fields
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(field))
|
||||
{
|
||||
v.push('\n');
|
||||
v.push_str(content);
|
||||
continue;
|
||||
}
|
||||
// Continuation without a preceding field line: skip it (malformed)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field line: `Name: value`
|
||||
if let Some(colon) = line.find(':') {
|
||||
let name = line[..colon].trim();
|
||||
let value = line[colon + 1..].trim_start();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
current.fields.push((name.to_string(), value.to_string()));
|
||||
last_field = Some(name.to_string());
|
||||
}
|
||||
// Anything else is malformed: ignore the line
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
paragraphs.push(current);
|
||||
}
|
||||
|
||||
paragraphs
|
||||
}
|
||||
|
||||
/// Serialize a paragraph to its deb822 textual representation (with a
|
||||
/// trailing newline).
|
||||
///
|
||||
/// A value starting with `\n` is rendered as a field with no inline first
|
||||
/// line (`Field:` followed by ` line` continuations), matching dpkg output
|
||||
/// for pre-wrapped values such as `Changes`, `Files` or `Environment`.
|
||||
pub fn write_paragraph(p: &Paragraph) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, value) in p.iter() {
|
||||
out.push_str(name);
|
||||
out.push(':');
|
||||
let mut lines = value.split('\n').peekable();
|
||||
// An empty first segment means: no value on the field header line;
|
||||
// discard it so it is not rendered as an empty continuation line.
|
||||
if lines.peek().is_some_and(|first| !first.is_empty()) {
|
||||
out.push(' ');
|
||||
out.push_str(lines.next().unwrap());
|
||||
} else {
|
||||
lines.next();
|
||||
}
|
||||
for line in lines {
|
||||
out.push('\n');
|
||||
if line.is_empty() {
|
||||
// dpkg encodes a blank line inside a value as ` .`; writing a
|
||||
// bare continuation line would be mistaken for a paragraph
|
||||
// separator on re-parse and silently drop the rest.
|
||||
out.push_str(" .");
|
||||
} else {
|
||||
out.push(' ');
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return the signed body of a clearsigned message, as a slice of `text`.
|
||||
///
|
||||
/// If `text` starts with the OpenPGP clearsigned-marker line, the armor
|
||||
/// header block (the `Hash: ...` line and any `Comment:` lines, up to and
|
||||
/// including the blank line that closes the header) is skipped, and the
|
||||
/// result is cut at the `-----BEGIN PGP SIGNATURE-----` marker so the
|
||||
/// signature trailer is dropped as well. This keeps the armor metadata from
|
||||
/// being parsed as deb822 fields (`Hash:` would otherwise land in the first
|
||||
/// stanza and `Comment:` in the last one).
|
||||
///
|
||||
/// Input that is not clearsigned is returned unchanged, so callers can apply
|
||||
/// this unconditionally before parsing.
|
||||
pub fn strip_clearsigned_armour(text: &str) -> &str {
|
||||
const BEGIN_SIGNED: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
|
||||
const BEGIN_SIGNATURE: &str = "-----BEGIN PGP SIGNATURE-----";
|
||||
|
||||
if !text.starts_with(BEGIN_SIGNED) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Walk past the armor headers to the blank line that precedes the body.
|
||||
let mut body = text;
|
||||
loop {
|
||||
match body.split_once('\n') {
|
||||
Some((line, remainder)) => {
|
||||
body = remainder;
|
||||
// An empty line ends the armor header block (`\r` covers a
|
||||
// CRLF-terminated blank line).
|
||||
if line.is_empty() || line == "\r" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Malformed armor: no body at all.
|
||||
None => return "",
|
||||
}
|
||||
}
|
||||
|
||||
// Cut off the signature block, if present.
|
||||
match body.find(BEGIN_SIGNATURE) {
|
||||
Some(i) => &body[..i],
|
||||
None => body,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_control() {
|
||||
let input = "Source: hello\nSection: devel\n\nPackage: hello\nDepends: libc6\n";
|
||||
let paras = parse_paragraphs(input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
assert_eq!(paras[0].get("section"), Some("devel"));
|
||||
assert_eq!(paras[1].get("Package"), Some("hello"));
|
||||
assert_eq!(paras[1].get("Depends"), Some("libc6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiline_and_comments() {
|
||||
let input = "# a comment\nDescription: short\n long description\n" //
|
||||
.to_string()
|
||||
+ " spanning lines\n\nPackage: x\n";
|
||||
let paras = parse_paragraphs(&input);
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert_eq!(
|
||||
paras[0].get("Description"),
|
||||
Some("short\nlong description\nspanning lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multiline() {
|
||||
let value = "short\nlong description\nspanning lines";
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", value);
|
||||
let text = write_paragraph(&p);
|
||||
assert_eq!(
|
||||
text,
|
||||
"Description: short\n long description\n spanning lines\n"
|
||||
);
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_survive_roundtrip() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Description", "a\n\nb");
|
||||
let text = write_paragraph(&p);
|
||||
// dpkg encoding: a blank line inside a value is written as ` .`.
|
||||
assert_eq!(text, "Description: a\n .\n b\n");
|
||||
// parse -> write -> parse must not lose data.
|
||||
let reparsed = parse_paragraphs(&text);
|
||||
assert_eq!(reparsed[0].get("Description"), Some("a\n\nb"));
|
||||
assert_eq!(
|
||||
parse_paragraphs(&write_paragraph(&reparsed[0]))[0].get("Description"),
|
||||
Some("a\n\nb")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_lone_dot_continuation_is_blank_line() {
|
||||
let paras = parse_paragraphs("Description:\n a\n .\n b\n");
|
||||
assert_eq!(paras[0].get("Description"), Some("\na\n\nb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_continuation_strips_exactly_one_tab() {
|
||||
let paras = parse_paragraphs("Description: a\n\tb\n\t\tdeep\n");
|
||||
assert_eq!(paras[0].get("Description"), Some("a\nb\n\tdeep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_armour_extracts_signed_dsc_body() {
|
||||
let signed = "\
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
Format: 3.0 (native)
|
||||
Source: hello
|
||||
Binary: hello
|
||||
Architecture: any
|
||||
Version: 1.0-1
|
||||
Checksums-Sha256:
|
||||
abc 100 hello_1.0.tar.gz
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQEcBAABCgAGBQJabcdAAoJEL abc
|
||||
-----END PGP SIGNATURE-----
|
||||
";
|
||||
let body = strip_clearsigned_armour(signed);
|
||||
assert!(body.starts_with("Format:"));
|
||||
assert!(!body.contains("SIGNATURE"));
|
||||
let paras = parse_paragraphs(body);
|
||||
assert_eq!(paras.len(), 1);
|
||||
// The armor `Hash:` header must not land in the stanza...
|
||||
assert!(paras[0].get("Hash").is_none());
|
||||
assert_eq!(paras[0].get("Source"), Some("hello"));
|
||||
// ...and the signature trailer must not contribute a `Comment:` field.
|
||||
assert!(paras[0].get("Comment").is_none());
|
||||
assert_eq!(
|
||||
paras[0].get("Checksums-Sha256"),
|
||||
Some("\nabc 100 hello_1.0.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_armour_passes_unsigned_text_through() {
|
||||
let plain = "Source: hello\nVersion: 1.0\n";
|
||||
assert_eq!(strip_clearsigned_armour(plain), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_case_insensitive() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("Source", "a");
|
||||
p.set("source", "b");
|
||||
assert_eq!(p.get("SOURCE"), Some("b"));
|
||||
assert_eq!(p.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lenient_parse_keeps_duplicate_fields() {
|
||||
let paras = parse_paragraphs("Package: hello\nDepends: a\ndepends: b\n");
|
||||
let p = ¶s[0];
|
||||
// deb822 forbids duplicate fields but the parser is lenient and keeps
|
||||
// both entries; `get` returns the first.
|
||||
let depends: Vec<_> = p
|
||||
.iter()
|
||||
.filter(|(k, _)| k.eq_ignore_ascii_case("Depends"))
|
||||
.collect();
|
||||
assert_eq!(depends, [("Depends", "a"), ("depends", "b")]);
|
||||
assert_eq!(p.get("Depends"), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_removes_case_insensitive_duplicates() {
|
||||
let mut paras = parse_paragraphs("Package: hello\nDepends: a\ndepends: b\n");
|
||||
let mut p = paras.remove(0);
|
||||
p.set("Depends", "c");
|
||||
// Exactly one depends-family entry remains, with the new value.
|
||||
let depends: Vec<_> = p
|
||||
.iter()
|
||||
.filter(|(k, _)| k.eq_ignore_ascii_case("Depends"))
|
||||
.collect();
|
||||
assert_eq!(depends, [("Depends", "c")]);
|
||||
assert_eq!(p.get("depends"), Some("c"));
|
||||
// ...kept at its original position, and a write round-trip no longer
|
||||
// leaks the stale duplicate.
|
||||
assert_eq!(write_paragraph(&p), "Package: hello\nDepends: c\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_field() {
|
||||
let mut p = Paragraph::new();
|
||||
p.set("A", "1");
|
||||
assert!(p.remove("a"));
|
||||
assert!(!p.remove("a"));
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `debian/control`: the source stanza plus all binary stanzas.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControlInfo {
|
||||
/// First paragraph (source package stanza).
|
||||
pub source: Paragraph,
|
||||
/// Remaining paragraphs (binary package stanzas).
|
||||
pub binaries: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl ControlInfo {
|
||||
/// Parse a `debian/control` file.
|
||||
pub fn parse(path: &Path) -> Result<ControlInfo, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read control file '{}': {}", path.display(), e))?;
|
||||
content
|
||||
.parse::<ControlInfo>()
|
||||
.map_err(|e| format!("invalid control file '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse control content from a string.
|
||||
///
|
||||
/// Prefer [`std::str::FromStr`] (`"...".parse::<ControlInfo>()`).
|
||||
pub fn parse_content(content: &str) -> Result<ControlInfo, String> {
|
||||
let paragraphs = parse_paragraphs(content);
|
||||
let mut iter = paragraphs.into_iter();
|
||||
let source = iter
|
||||
.next()
|
||||
.ok_or_else(|| "control file has no paragraphs".to_string())?;
|
||||
if source.get("Source").is_none() {
|
||||
return Err("first control paragraph has no 'Source' field".to_string());
|
||||
}
|
||||
let binaries: Vec<Paragraph> = iter.collect();
|
||||
for bin in &binaries {
|
||||
if bin.get("Package").is_none() {
|
||||
return Err("binary control paragraph has no 'Package' field".to_string());
|
||||
}
|
||||
}
|
||||
Ok(ControlInfo { source, binaries })
|
||||
}
|
||||
|
||||
/// The source package name.
|
||||
pub fn source_name(&self) -> &str {
|
||||
self.source.get("Source").expect("checked at parse")
|
||||
}
|
||||
|
||||
/// Section from the source stanza, or `'-'`.
|
||||
pub fn section(&self) -> &str {
|
||||
self.source.get("Section").unwrap_or("-")
|
||||
}
|
||||
|
||||
/// Priority from the source stanza, or `'-'`.
|
||||
pub fn priority(&self) -> &str {
|
||||
self.source.get("Priority").unwrap_or("-")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ControlInfo {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(content: &str) -> Result<Self, Self::Err> {
|
||||
ControlInfo::parse_content(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_info_tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn control_parsing() {
|
||||
let ci = ControlInfo::from_str(
|
||||
"Source: hello\nSection: utils\nPriority: optional\nMaintainer: A B <a@b.c>\nBuild-Depends: debhelper\n\nPackage: hello\nArchitecture: any\nDescription: test\n long\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ci.source_name(), "hello");
|
||||
assert_eq!(ci.section(), "utils");
|
||||
assert_eq!(ci.priority(), "optional");
|
||||
assert_eq!(ci.binaries.len(), 1);
|
||||
assert_eq!(ci.binaries[0].get("Package"), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_defaults() {
|
||||
let ci = ControlInfo::from_str("Source: x\n\nPackage: x\nDescription: d\n").unwrap();
|
||||
assert_eq!(ci.section(), "-");
|
||||
assert_eq!(ci.priority(), "-");
|
||||
}
|
||||
}
|
||||
+1793
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
//! `debian/files` registry: the contract between the build (`dh_builddeb`,
|
||||
//! `dpkg-gencontrol`, ...) and the artifact generators, mirroring
|
||||
//! `Dpkg::Dist::Files`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// One registered artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FilesEntry {
|
||||
/// File name (relative to the parent directory of the package tree).
|
||||
pub filename: String,
|
||||
/// Archive section (e.g. `utils`).
|
||||
pub section: String,
|
||||
/// Archive priority (e.g. `optional`).
|
||||
pub priority: String,
|
||||
/// Package name parsed from the file name pattern, if any.
|
||||
pub package: Option<String>,
|
||||
/// Version parsed from the file name pattern, if any.
|
||||
pub version: Option<String>,
|
||||
/// Architecture parsed from the file name pattern, if any.
|
||||
pub arch: Option<String>,
|
||||
/// Artifact type parsed from the file name extension
|
||||
/// (`deb`, `udeb`, `buildinfo`, `changes`, ...).
|
||||
pub package_type: Option<String>,
|
||||
/// Extra `key=value` attributes on the line (e.g. `automatic=yes`).
|
||||
pub attrs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FilesEntry {
|
||||
/// Create a minimal entry with only name/section/priority; the pattern
|
||||
/// fields are derived from the file name.
|
||||
pub fn new(filename: &str, section: &str, priority: &str) -> FilesEntry {
|
||||
let mut entry = parse_filename(filename).unwrap_or_else(|| FilesEntry {
|
||||
filename: filename.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
entry.section = section.to_string();
|
||||
entry.priority = priority.to_string();
|
||||
entry
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive metadata from an artifact file name following the common
|
||||
/// `<package>_<version>_<arch>.<type>` pattern, like
|
||||
/// `Dpkg::Dist::Files::parse_filename()`.
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
pub fn parse_filename(name: &str) -> Option<FilesEntry> {
|
||||
// Common pattern: name_version_arch.type where type is dot-separated
|
||||
// (e.g. tar.xz must not match here since it has no leading underscores).
|
||||
let parts: Vec<&str> = name.split('_').collect();
|
||||
if parts.len() == 3 {
|
||||
let (pkg, version, rest) = (parts[0], parts[1], parts[2]);
|
||||
if let Some(dot) = rest.rfind('.') {
|
||||
let arch = &rest[..dot];
|
||||
let ptype = &rest[dot + 1..];
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-+.:~".contains(c))
|
||||
};
|
||||
if valid(pkg) && valid(version) && valid(arch) && valid(ptype) {
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: Some(pkg.to_string()),
|
||||
version: Some(version.to_string()),
|
||||
arch: Some(arch.to_string()),
|
||||
package_type: Some(ptype.to_string()),
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: accept a conservative file-name character set.
|
||||
if !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-+.:,_~".contains(c))
|
||||
{
|
||||
return Some(FilesEntry {
|
||||
filename: name.to_string(),
|
||||
section: "-".to_string(),
|
||||
priority: "-".to_string(),
|
||||
package: None,
|
||||
version: None,
|
||||
arch: None,
|
||||
package_type: None,
|
||||
attrs: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The full `debian/files` registry, ordered by file name.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FilesList {
|
||||
files: BTreeMap<String, FilesEntry>,
|
||||
}
|
||||
|
||||
impl FilesList {
|
||||
/// An empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load `debian/files`. A missing file yields an empty registry.
|
||||
pub fn load(path: &Path) -> Result<FilesList, Box<dyn std::error::Error>> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FilesList::new()),
|
||||
Err(e) => {
|
||||
return Err(format!("cannot read '{}': {}", path.display(), e).into());
|
||||
}
|
||||
};
|
||||
FilesList::parse(&content).map_err(|e| format!("in '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// Parse a `debian/files` registry from its textual content
|
||||
/// (`filename section priority [key=value...]` lines).
|
||||
pub fn parse(content: &str) -> Result<FilesList, String> {
|
||||
let mut list = FilesList::new();
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() < 3 {
|
||||
return Err(format!("badly formed line: {line}"));
|
||||
}
|
||||
let mut entry = parse_filename(tokens[0])
|
||||
.ok_or_else(|| format!("badly formed file name: {}", tokens[0]))?;
|
||||
entry.section = tokens[1].to_string();
|
||||
entry.priority = tokens[2].to_string();
|
||||
for attr in &tokens[3..] {
|
||||
if let Some((k, v)) = attr.split_once('=') {
|
||||
entry.attrs.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
list.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Register (or replace) an entry.
|
||||
pub fn add(&mut self, entry: FilesEntry) {
|
||||
self.files.insert(entry.filename.clone(), entry);
|
||||
}
|
||||
|
||||
/// Remove entries matching a predicate. Returns how many were removed.
|
||||
pub fn retain<F: FnMut(&FilesEntry) -> bool>(&mut self, mut keep: F) -> usize {
|
||||
let before = self.files.len();
|
||||
self.files.retain(|_, e| keep(e));
|
||||
before - self.files.len()
|
||||
}
|
||||
|
||||
/// Iterate over entries sorted by file name.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &FilesEntry> {
|
||||
self.files.values()
|
||||
}
|
||||
|
||||
/// Look up an entry by file name.
|
||||
pub fn get(&self, filename: &str) -> Option<&FilesEntry> {
|
||||
self.files.get(filename)
|
||||
}
|
||||
|
||||
/// Number of registered files.
|
||||
pub fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// True if empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
/// Render the registry to its textual `debian/files` representation.
|
||||
pub fn render(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for entry in self.iter() {
|
||||
out.push_str(&entry.filename);
|
||||
out.push(' ');
|
||||
out.push_str(&entry.section);
|
||||
out.push(' ');
|
||||
out.push_str(&entry.priority);
|
||||
for (k, v) in &entry.attrs {
|
||||
out.push_str(&format!(" {k}={v}"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Save atomically: write `<path>.new` then rename over `path`, like
|
||||
/// dpkg does.
|
||||
pub fn save_atomic(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = path.with_extension("new");
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)
|
||||
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
|
||||
for entry in self.iter() {
|
||||
write!(f, "{} {} {}", entry.filename, entry.section, entry.priority)?;
|
||||
for (k, v) in &entry.attrs {
|
||||
write!(f, " {}={}", k, v)?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
}
|
||||
f.flush()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filename_pattern_parsing() {
|
||||
let e = parse_filename("hello_2.10-3_amd64.deb").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.version.as_deref(), Some("2.10-3"));
|
||||
assert_eq!(e.arch.as_deref(), Some("amd64"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("deb"));
|
||||
|
||||
let e = parse_filename("hello_0.1_source.buildinfo").unwrap();
|
||||
assert_eq!(e.package.as_deref(), Some("hello"));
|
||||
assert_eq!(e.arch.as_deref(), Some("source"));
|
||||
assert_eq!(e.package_type.as_deref(), Some("buildinfo"));
|
||||
|
||||
// Tarballs do not follow the 3-component pattern.
|
||||
let e = parse_filename("hello_0.1.tar.xz").unwrap();
|
||||
assert_eq!(e.package, None);
|
||||
assert_eq!(e.package_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_save_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("files");
|
||||
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("hello_1.0_amd64.deb", "devel", "optional"));
|
||||
list.add(FilesEntry::new("hello_1.0_source.buildinfo", "-", "-"));
|
||||
list.save_atomic(&path).unwrap();
|
||||
|
||||
let reloaded = FilesList::load(&path).unwrap();
|
||||
assert_eq!(reloaded.len(), 2);
|
||||
let deb = reloaded.get("hello_1.0_amd64.deb").unwrap();
|
||||
assert_eq!(deb.section, "devel");
|
||||
assert_eq!(deb.priority, "optional");
|
||||
|
||||
// Missing file loads as empty.
|
||||
let missing = FilesList::load(&dir.path().join("nonexistent")).unwrap();
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_removes_matching() {
|
||||
let mut list = FilesList::new();
|
||||
list.add(FilesEntry::new("x_1_source.buildinfo", "-", "-"));
|
||||
list.add(FilesEntry::new("x_1_amd64.deb", "-", "-"));
|
||||
let removed = list.retain(|e| e.package_type.as_deref() != Some("buildinfo"));
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Reusable Debian format primitives.
|
||||
//!
|
||||
//! 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/comparison
|
||||
//! - [`changelog`]: `debian/changelog` entry parsing
|
||||
|
||||
pub mod arch;
|
||||
pub mod changelog;
|
||||
pub mod checksums;
|
||||
pub mod control;
|
||||
pub mod deps;
|
||||
pub mod files;
|
||||
pub mod version;
|
||||
|
||||
pub use changelog::{
|
||||
ChangelogEntry, parse_changelog_entries, parse_changelog_entries_from_str,
|
||||
parse_changelog_entry, parse_changelog_entry_from_str,
|
||||
};
|
||||
pub use checksums::{ChecksumKind, Entry as ChecksumEntry, FileChecksums};
|
||||
pub use control::{
|
||||
ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph,
|
||||
};
|
||||
pub use files::{FilesEntry, FilesList};
|
||||
pub use version::DebianVersion;
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Debian version handling: splitting, validation and ordering of
|
||||
//! `[epoch:]upstream[-revision]` version strings.
|
||||
|
||||
/// A Debian version, split into its `[epoch:]upstream[-revision]` parts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DebianVersion {
|
||||
/// Optional numeric epoch (part before the first `:`).
|
||||
pub epoch: Option<u32>,
|
||||
/// Upstream version (may itself contain `-` when there is no revision).
|
||||
pub upstream: String,
|
||||
/// Optional Debian revision (part after the last `-`).
|
||||
pub debian_revision: Option<String>,
|
||||
}
|
||||
|
||||
impl DebianVersion {
|
||||
/// Parse and validate a Debian version string.
|
||||
pub fn parse(raw: &str) -> Result<DebianVersion, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty version string".to_string());
|
||||
}
|
||||
|
||||
let (epoch, rest) = match raw.split_once(':') {
|
||||
Some((e, r)) => {
|
||||
let epoch: u32 = e
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid epoch '{}' in version '{}'", e, raw))?;
|
||||
(Some(epoch), r)
|
||||
}
|
||||
None => (None, raw),
|
||||
};
|
||||
|
||||
// The revision is everything after the last hyphen.
|
||||
let (upstream, debian_revision) = match rest.rsplit_once('-') {
|
||||
Some((u, r)) => (u.to_string(), Some(r.to_string())),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
|
||||
if upstream.is_empty() {
|
||||
return Err(format!("missing upstream version in '{}'", raw));
|
||||
}
|
||||
for c in upstream.chars() {
|
||||
if !(c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '+' | '-' | '~' | ':')
|
||||
|| !c.is_ascii())
|
||||
{
|
||||
return Err(format!("invalid character '{}' in version '{}'", c, raw));
|
||||
}
|
||||
}
|
||||
if let Some(rev) = &debian_revision {
|
||||
if rev.is_empty() {
|
||||
// dpkg rejects a trailing hyphen: "bad syntax: revision
|
||||
// number is empty". Native versions (no `-` at all) are
|
||||
// handled above and stay valid.
|
||||
return Err(format!("empty debian revision in '{}'", raw));
|
||||
}
|
||||
for c in rev.chars() {
|
||||
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '~') || !c.is_ascii()) {
|
||||
return Err(format!(
|
||||
"invalid character '{}' in revision of version '{}'",
|
||||
c, raw
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DebianVersion {
|
||||
epoch,
|
||||
upstream,
|
||||
debian_revision,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full version string, including the epoch (`[epoch:]upstream[-rev]`).
|
||||
pub fn full(&self) -> String {
|
||||
match (&self.epoch, &self.debian_revision) {
|
||||
(Some(e), Some(r)) => format!("{}:{}-{}", e, self.upstream, r),
|
||||
(Some(e), None) => format!("{}:{}", e, self.upstream),
|
||||
(None, Some(r)) => format!("{}-{}", self.upstream, r),
|
||||
(None, None) => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Version string without the epoch (`upstream[-rev]`), used in artifact
|
||||
/// file names.
|
||||
pub fn no_epoch(&self) -> String {
|
||||
match &self.debian_revision {
|
||||
Some(r) => format!("{}-{}", self.upstream, r),
|
||||
None => self.upstream.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience predicate: whether this version orders strictly later
|
||||
/// than `other`.
|
||||
pub fn later_than(&self, other: &DebianVersion) -> bool {
|
||||
self > other
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two versions according to dpkg's ordering algorithm
|
||||
/// (Debian Policy §5.6.1 / `dpkg(1)`):
|
||||
///
|
||||
/// - the epoch compares numerically (a missing epoch counts as `0`),
|
||||
/// - then the upstream version and the Debian revision compare by
|
||||
/// alternating non-digit and digit chunks, from left to right,
|
||||
/// - in non-digit chunks letters sort earlier than non-letters, and `~`
|
||||
/// sorts before anything, including the end of the chunk,
|
||||
/// - digit chunks compare numerically (leading zeroes are irrelevant; an
|
||||
/// empty digit chunk counts as `0`, so a missing revision equals `0`).
|
||||
pub fn compare(a: &DebianVersion, b: &DebianVersion) -> std::cmp::Ordering {
|
||||
a.epoch
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.epoch.unwrap_or(0))
|
||||
.then_with(|| verrevcmp(a.upstream.as_bytes(), b.upstream.as_bytes()))
|
||||
.then_with(|| {
|
||||
verrevcmp(
|
||||
a.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||
b.debian_revision.as_deref().unwrap_or("").as_bytes(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sort weight of a character inside a non-digit chunk: `~` sorts before the
|
||||
/// end of the chunk, letters before non-letters, everything else by ASCII
|
||||
/// order.
|
||||
fn char_order(c: u8) -> i32 {
|
||||
if c == b'~' {
|
||||
-1
|
||||
} else if c.is_ascii_alphabetic() {
|
||||
i32::from(c)
|
||||
} else {
|
||||
i32::from(c) + 256
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare the upstream/revision part of two versions by alternating
|
||||
/// non-digit and digit chunks.
|
||||
fn verrevcmp(mut a: &[u8], mut b: &[u8]) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering;
|
||||
|
||||
while !a.is_empty() || !b.is_empty() {
|
||||
let mut first_diff: i32 = 0;
|
||||
|
||||
// Non-digit chunks: compare by character weight. A chunk boundary
|
||||
// (end of string or start of a digit run) weighs 0, which sorts
|
||||
// after `~` (-1) and before every real character.
|
||||
while (!a.is_empty() && !a[0].is_ascii_digit()) || (!b.is_empty() && !b[0].is_ascii_digit())
|
||||
{
|
||||
let ac = if !a.is_empty() && !a[0].is_ascii_digit() {
|
||||
char_order(a[0])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let bc = if !b.is_empty() && !b[0].is_ascii_digit() {
|
||||
char_order(b[0])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if ac != bc {
|
||||
return ac.cmp(&bc);
|
||||
}
|
||||
// Reaching here means both sides carried equal real characters.
|
||||
a = &a[1..];
|
||||
b = &b[1..];
|
||||
}
|
||||
|
||||
// Digit chunks: strip leading zeroes, then the number whose
|
||||
// remaining digit run is longer is larger; otherwise the first
|
||||
// differing digit decides.
|
||||
while !a.is_empty() && a[0] == b'0' {
|
||||
a = &a[1..];
|
||||
}
|
||||
while !b.is_empty() && b[0] == b'0' {
|
||||
b = &b[1..];
|
||||
}
|
||||
while !a.is_empty() && !b.is_empty() && a[0].is_ascii_digit() && b[0].is_ascii_digit() {
|
||||
if first_diff == 0 {
|
||||
first_diff = i32::from(a[0]) - i32::from(b[0]);
|
||||
}
|
||||
a = &a[1..];
|
||||
b = &b[1..];
|
||||
}
|
||||
if !a.is_empty() && a[0].is_ascii_digit() {
|
||||
return Ordering::Greater;
|
||||
}
|
||||
if !b.is_empty() && b[0].is_ascii_digit() {
|
||||
return Ordering::Less;
|
||||
}
|
||||
if first_diff != 0 {
|
||||
return first_diff.cmp(&0);
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
impl PartialOrd for DebianVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for DebianVersion {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
compare(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test vectors ported from dpkg's `scripts/t/Dpkg_Version.t` (`__DATA__`
|
||||
/// section): `(version_a, version_b, expected_cmp)` with `-1/0/1`. Shared
|
||||
/// with the differential tests against real `dpkg --compare-versions`.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_vectors {
|
||||
/// `(a, b, cmp)` triples.
|
||||
pub(crate) const COMPARE: &[(&str, &str, i32)] = &[
|
||||
("1.0-1", "2.0-2", -1),
|
||||
("2.2~rc-4", "2.2-1", -1),
|
||||
("2.2-1", "2.2~rc-4", 1),
|
||||
("1.0000-1", "1.0-1", 0),
|
||||
("1", "0:1", 0),
|
||||
("0", "0:0-0", 0),
|
||||
("2:2.5", "1:7.5", 1),
|
||||
("1:0foo", "0foo", 1),
|
||||
("0:0foo", "0foo", 0),
|
||||
("0foo", "0foo", 0),
|
||||
("0foo-0", "0foo", 0),
|
||||
("0foo", "0foo-0", 0),
|
||||
("0foo", "0fo", 1),
|
||||
("0foo-0", "0foo+", -1),
|
||||
("0foo~1", "0foo", -1),
|
||||
("0foo~foo+Bar", "0foo~foo+bar", -1),
|
||||
("0foo~~", "0foo~", -1),
|
||||
("1~", "1", -1),
|
||||
(
|
||||
"12345+that-really-is-some-ver-0",
|
||||
"12345+that-really-is-some-ver-10",
|
||||
-1,
|
||||
),
|
||||
("0foo-0", "0foo-01", -1),
|
||||
("0foo.bar", "0foobar", 1),
|
||||
("0foo.bar", "0foo1bar", 1),
|
||||
("0foo.bar", "0foo0bar", 1),
|
||||
("0foo1bar-1", "0foobar-1", -1),
|
||||
("0foo2.0", "0foo2", 1),
|
||||
("0foo2.0.0", "0foo2.10.0", -1),
|
||||
("0foo2.0", "0foo2.0.0", -1),
|
||||
("0foo2.0", "0foo2.10", -1),
|
||||
("0foo2.1", "0foo2.10", -1),
|
||||
("1.09", "1.9", 0),
|
||||
("1.0.8+nmu1", "1.0.8", 1),
|
||||
("3.11", "3.10+nmu1", 1),
|
||||
("0.9j-20080306-4", "0.9i-20070324-2", 1),
|
||||
("1.2.0~b7-1", "1.2.0~b6-1", 1),
|
||||
("1.011-1", "1.06-2", 1),
|
||||
("0.0.9+dfsg1-1", "0.0.8+dfsg1-3", 1),
|
||||
("4.6.99+svn6582-1", "4.6.99+svn6496-1", 1),
|
||||
("53", "52", 1),
|
||||
("0.9.9~pre122-1", "0.9.9~pre111-1", 1),
|
||||
("2:2.3.2-2+lenny2", "2:2.3.2-2", 1),
|
||||
("1:3.8.1-1", "3.8.GA-1", 1),
|
||||
("1.0.1+gpl-1", "1.0.1-2", 1),
|
||||
("1a", "1000a", -1),
|
||||
];
|
||||
|
||||
/// Unsorted lists with their expected order under dpkg comparison.
|
||||
pub(crate) const SORTED: &[(&[&str], &[&str])] = &[
|
||||
(
|
||||
&[
|
||||
"4:4-4",
|
||||
"5.0abc",
|
||||
"0.0-0.0alpha0",
|
||||
"10.100.1-1",
|
||||
"0~999.999zeta",
|
||||
"0:1.0-0",
|
||||
],
|
||||
&[
|
||||
"0~999.999zeta",
|
||||
"0.0-0.0alpha0",
|
||||
"0:1.0-0",
|
||||
"5.0abc",
|
||||
"10.100.1-1",
|
||||
"4:4-4",
|
||||
],
|
||||
),
|
||||
(
|
||||
&[
|
||||
"4",
|
||||
"5.0abc",
|
||||
"0.0alpha0",
|
||||
"10.100.1",
|
||||
"0~999.999zeta",
|
||||
"1.0",
|
||||
],
|
||||
&[
|
||||
"0~999.999zeta",
|
||||
"0.0alpha0",
|
||||
"1.0",
|
||||
"4",
|
||||
"5.0abc",
|
||||
"10.100.1",
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_splitting() {
|
||||
let v = DebianVersion::parse("1.2.3-4ubuntu5").unwrap();
|
||||
assert_eq!(v.epoch, None);
|
||||
assert_eq!(v.upstream, "1.2.3");
|
||||
assert_eq!(v.debian_revision.as_deref(), Some("4ubuntu5"));
|
||||
assert_eq!(v.full(), "1.2.3-4ubuntu5");
|
||||
assert_eq!(v.no_epoch(), "1.2.3-4ubuntu5");
|
||||
|
||||
let v = DebianVersion::parse("3:2.10-3").unwrap();
|
||||
assert_eq!(v.epoch, Some(3));
|
||||
assert_eq!(v.upstream, "2.10");
|
||||
assert_eq!(v.no_epoch(), "2.10-3");
|
||||
assert_eq!(v.full(), "3:2.10-3");
|
||||
|
||||
let v = DebianVersion::parse("1.0").unwrap();
|
||||
assert_eq!(v.debian_revision, None);
|
||||
assert_eq!(v.no_epoch(), "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_validation() {
|
||||
assert!(DebianVersion::parse("").is_err());
|
||||
assert!(DebianVersion::parse(":1.0").is_err());
|
||||
assert!(DebianVersion::parse("a:_b").is_err());
|
||||
assert!(DebianVersion::parse("1.0").is_ok());
|
||||
assert!(DebianVersion::parse("1.0~rc1-2").is_ok());
|
||||
}
|
||||
|
||||
/// dpkg rejects a trailing `-` ("bad syntax: revision number is
|
||||
/// empty") but accepts `1.0--1`, where the revision is the text after
|
||||
/// the *last* hyphen (upstream `1.0-` + revision `1`).
|
||||
#[test]
|
||||
fn version_empty_revision() {
|
||||
let err = DebianVersion::parse("1.0-").unwrap_err();
|
||||
assert!(err.contains("empty"), "unexpected message: {err}");
|
||||
|
||||
assert!(DebianVersion::parse("1.0-").is_err());
|
||||
// Epoch variants take the same path.
|
||||
assert!(DebianVersion::parse("3:1.0-").is_err());
|
||||
assert!(DebianVersion::parse("1.0-1").is_ok());
|
||||
// Native versions (no revision at all) are still fine.
|
||||
assert!(DebianVersion::parse("1.0").is_ok());
|
||||
assert!(DebianVersion::parse("3:1.0").is_ok());
|
||||
|
||||
let v = DebianVersion::parse("1.0--1").unwrap();
|
||||
assert_eq!(v.upstream, "1.0-");
|
||||
assert_eq!(v.debian_revision.as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
fn cmp_sign(a: &DebianVersion, b: &DebianVersion) -> i32 {
|
||||
match a.cmp(b) {
|
||||
std::cmp::Ordering::Less => -1,
|
||||
std::cmp::Ordering::Equal => 0,
|
||||
std::cmp::Ordering::Greater => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// All vectors from dpkg's own `Dpkg_Version.t` must pass.
|
||||
#[test]
|
||||
fn comparison_dpkg_vectors() {
|
||||
for (a, b, expected) in test_vectors::COMPARE {
|
||||
let va = DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}"));
|
||||
let vb = DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}"));
|
||||
assert_eq!(
|
||||
cmp_sign(&va, &vb),
|
||||
*expected,
|
||||
"{a} cmp {b} must be {expected}"
|
||||
);
|
||||
// Ordering is antisymmetric.
|
||||
assert_eq!(cmp_sign(&vb, &va), -*expected, "{b} cmp {a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_dpkg_vectors() {
|
||||
for (unsorted, expected) in test_vectors::SORTED {
|
||||
let mut versions: Vec<DebianVersion> = unsorted
|
||||
.iter()
|
||||
.map(|v| DebianVersion::parse(v).unwrap())
|
||||
.collect();
|
||||
versions.sort();
|
||||
let rendered: Vec<String> = versions.iter().map(DebianVersion::full).collect();
|
||||
let expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ubuntu-flavored cases: security updates, backports, PPA versions.
|
||||
#[test]
|
||||
fn comparison_ubuntu_flavored() {
|
||||
let cases: &[(&str, &str, i32)] = &[
|
||||
// Security update on top of a release upload.
|
||||
("1.0-0ubuntu1", "1.0-0ubuntu1.22.04.1", -1),
|
||||
// PPA/backports pre-releases sort before the real upload.
|
||||
("1.0-0ubuntu1~ppa1", "1.0-0ubuntu1", -1),
|
||||
("1.0~bpo22.04.1", "1.0", -1),
|
||||
// Series-specific uploads.
|
||||
("2.3-1ubuntu3.22.04.2", "2.3-1ubuntu3", 1),
|
||||
("1:2.0.4-0ubuntu1", "1:2.0.4-0ubuntu1.1", -1),
|
||||
];
|
||||
for (a, b, expected) in cases {
|
||||
let va = DebianVersion::parse(a).unwrap();
|
||||
let vb = DebianVersion::parse(b).unwrap();
|
||||
assert_eq!(cmp_sign(&va, &vb), *expected, "{a} cmp {b}");
|
||||
}
|
||||
|
||||
// later_than convenience.
|
||||
let old = DebianVersion::parse("1.0-0ubuntu1").unwrap();
|
||||
let new = DebianVersion::parse("1.0-0ubuntu1.22.04.1").unwrap();
|
||||
assert!(new.later_than(&old));
|
||||
assert!(!old.later_than(&old));
|
||||
}
|
||||
}
|
||||
+761
-48
@@ -1,8 +1,11 @@
|
||||
use crate::data::embed_data;
|
||||
use chrono::NaiveDate;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Information about a specific distribution series
|
||||
@@ -13,8 +16,8 @@ pub struct SeriesInformation {
|
||||
pub codename: String,
|
||||
/// Series version as numbers
|
||||
pub version: Option<String>,
|
||||
/// Series creation date
|
||||
pub created: NaiveDate,
|
||||
/// Series creation date (absent if missing or invalid in the CSV data)
|
||||
pub created: Option<NaiveDate>,
|
||||
/// Series release date
|
||||
pub release: Option<NaiveDate>,
|
||||
/// Series end-of-life date
|
||||
@@ -27,22 +30,162 @@ struct SeriesInfo {
|
||||
network: String,
|
||||
}
|
||||
|
||||
/// Architectures an archive mirror serves: an explicit list, or the `all`
|
||||
/// sentinel meaning one mirror serves every architecture (Debian's mirror
|
||||
/// setup — an exhaustive list would rot each time an arch is added)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum MirrorArchs {
|
||||
/// The `all` sentinel
|
||||
All(String),
|
||||
/// An explicit list of dpkg architecture names
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl MirrorArchs {
|
||||
/// Whether the mirror serves `arch`. A scalar other than the `all`
|
||||
/// sentinel serves nothing (a test locks that reading of the data).
|
||||
fn serves(&self, arch: &str) -> bool {
|
||||
match self {
|
||||
MirrorArchs::All(sentinel) => sentinel == "all",
|
||||
MirrorArchs::List(archs) => archs.iter().any(|a| a == arch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One archive mirror of a distribution: a URL serving a set of
|
||||
/// architectures, plus the sibling host serving its `-security` pocket
|
||||
/// for the same architectures (ports mirrors serve their own security)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Mirror {
|
||||
/// Base URL of the mirror (the primary mirror's URL doubles as the
|
||||
/// dist's base URL, see [`get_base_url`])
|
||||
pub url: String,
|
||||
/// Sibling host serving the `-security` pocket for these
|
||||
/// architectures; `None` when the mirror serves its own security
|
||||
#[serde(default)]
|
||||
security_url: Option<String>,
|
||||
/// Architectures the mirror serves (see [`MirrorArchs`])
|
||||
archs: MirrorArchs,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// Whether the mirror serves `arch`
|
||||
pub fn serves(&self, arch: &str) -> bool {
|
||||
self.archs.serves(arch)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DistData {
|
||||
base_url: String,
|
||||
mirrors: HashMap<String, Mirror>,
|
||||
archive_keyring: String,
|
||||
pockets: Vec<String>,
|
||||
#[serde(default)]
|
||||
sections: Vec<String>,
|
||||
components: Vec<String>,
|
||||
cross_pockets: Vec<String>,
|
||||
#[serde(default)]
|
||||
build_profiles: Vec<String>,
|
||||
/// Changelog suite names aliasing a distro-info series codename
|
||||
/// ('unstable' for Debian's 'sid'): the two names identify the same
|
||||
/// series ([`series_suite_alias`], [`resolve_suite_alias`])
|
||||
#[serde(default)]
|
||||
suite_aliases: HashMap<String, String>,
|
||||
series: SeriesInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Data {
|
||||
dist: std::collections::HashMap<String, DistData>,
|
||||
dist: HashMap<String, DistData>,
|
||||
}
|
||||
|
||||
embed_data! {
|
||||
static ref DATA: Data = "../data/distro_info.yml"
|
||||
}
|
||||
|
||||
const DATA_YAML: &str = include_str!("../distro_info.yml");
|
||||
lazy_static! {
|
||||
static ref DATA: Data = serde_yaml::from_str(DATA_YAML).unwrap();
|
||||
// Shared HTTP client used for all outgoing plain requests: timeouts keep
|
||||
// a hanging remote (connect or transfer) from stalling pkh indefinitely.
|
||||
// The short pool idle timeout and TCP keepalive avoid reusing keep-alive
|
||||
// connections that the remote closed in the meantime, which surfaces as
|
||||
// spurious 'error sending request' failures on busy mirrors/CDNs.
|
||||
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.pool_idle_timeout(Duration::from_secs(10))
|
||||
.tcp_keepalive(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("building the shared HTTP client with static options cannot fail");
|
||||
}
|
||||
|
||||
/// Shared HTTP client with a connect timeout (10s) and a total request
|
||||
/// timeout (30s), to be used for all outgoing plain HTTP(S) requests
|
||||
pub(crate) fn http_client() -> &'static reqwest::Client {
|
||||
&HTTP_CLIENT
|
||||
}
|
||||
|
||||
/// GET `url` with bounded retries on transient transport errors (a pooled
|
||||
/// keep-alive connection closed by the remote, a momentary network hiccup,
|
||||
/// ...): these always succeed again on a fresh connection, and mirrors are
|
||||
/// busy enough that unguarded single attempts make bulk operations flaky.
|
||||
///
|
||||
/// The response status is not inspected: 404s and the like are meaningful
|
||||
/// answers, not transport failures.
|
||||
pub(crate) async fn http_get_retried(url: &str) -> reqwest::Result<reqwest::Response> {
|
||||
http_get_retried_with_timeout(url, None).await
|
||||
}
|
||||
|
||||
/// [`http_get_retried`] with a per-request timeout override, for large
|
||||
/// streaming downloads that exceed the shared client's total timeout
|
||||
pub(crate) async fn http_get_retried_with_timeout(
|
||||
url: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> reqwest::Result<reqwest::Response> {
|
||||
const ATTEMPTS: u32 = 3;
|
||||
let mut last_error: Option<reqwest::Error> = None;
|
||||
for attempt in 0..ATTEMPTS {
|
||||
let mut request = http_client().get(url);
|
||||
if let Some(timeout) = timeout {
|
||||
request = request.timeout(timeout);
|
||||
}
|
||||
match request.send().await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
if attempt + 1 < ATTEMPTS {
|
||||
log::debug!(
|
||||
"GET '{url}' failed (attempt {}/{}, retrying): {}",
|
||||
attempt + 1,
|
||||
ATTEMPTS,
|
||||
e
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(300 * (u64::from(attempt) + 1))).await;
|
||||
}
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error.expect("at least one attempt was made"))
|
||||
}
|
||||
|
||||
/// Parse an optional '%Y-%m-%d' date from a CSV cell, warning instead of
|
||||
/// panicking on invalid remote data
|
||||
fn parse_optional_date(value: Option<&str>, series: &str, field: &str) -> Option<NaiveDate> {
|
||||
value.and_then(
|
||||
|date_str| match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
Ok(date) => Some(date),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Invalid '{}' date '{}' for series '{}': {}. Ignoring the date.",
|
||||
field,
|
||||
date_str,
|
||||
series,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
|
||||
@@ -79,24 +222,39 @@ fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Err
|
||||
let mut series_info_list = Vec::new();
|
||||
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
let series = record.get(series_idx).unwrap().to_string();
|
||||
let codename = record.get(codename_idx).unwrap().to_string();
|
||||
let record = match result {
|
||||
Ok(record) => record,
|
||||
Err(e) => {
|
||||
log::warn!("Skipping malformed series CSV row: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Rows missing essential identification fields are skipped: they
|
||||
// cannot be used nor reported meaningfully. Dates, on the other
|
||||
// hand, are all optional in the model, so a bad date keeps the row.
|
||||
let Some(series) = record.get(series_idx).filter(|s| !s.is_empty()) else {
|
||||
log::warn!(
|
||||
"Skipping series CSV row without a 'series' value: {:?}",
|
||||
record
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let Some(codename) = record.get(codename_idx).filter(|s| !s.is_empty()) else {
|
||||
log::warn!(
|
||||
"Skipping series CSV row for series '{}' without a 'codename' value",
|
||||
series
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let version = record.get(version_idx).map(|s| s.to_string());
|
||||
let created = record
|
||||
.get(created_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap())
|
||||
.unwrap();
|
||||
let release = record
|
||||
.get(release_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
|
||||
let eol = record
|
||||
.get(eol_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
|
||||
let created = parse_optional_date(record.get(created_idx), series, "created");
|
||||
let release = parse_optional_date(record.get(release_idx), series, "release");
|
||||
let eol = parse_optional_date(record.get(eol_idx), series, "eol");
|
||||
|
||||
series_info_list.push(SeriesInformation {
|
||||
series,
|
||||
codename,
|
||||
series: series.to_string(),
|
||||
codename: codename.to_string(),
|
||||
version,
|
||||
created,
|
||||
release,
|
||||
@@ -110,9 +268,40 @@ fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Err
|
||||
Ok(series_info_list)
|
||||
}
|
||||
|
||||
/// List the distributions known to pkh (e.g. "debian", "ubuntu")
|
||||
/// List the distributions known to pkh (e.g. "debian", "ubuntu"), sorted so
|
||||
/// that menus and error messages derived from it are deterministic
|
||||
pub fn supported_dists() -> Vec<String> {
|
||||
DATA.dist.keys().cloned().collect()
|
||||
let mut dists: Vec<String> = DATA.dist.keys().cloned().collect();
|
||||
dists.sort();
|
||||
dists
|
||||
}
|
||||
|
||||
/// Name of a dist's primary mirror entry: its URL doubles as the dist's
|
||||
/// base URL ([`get_base_url`]) and is the first candidate of
|
||||
/// [`mirror_for_arch`]
|
||||
const PRIMARY_MIRROR: &str = "primary";
|
||||
|
||||
/// The data of a known distribution: the shared "unknown distribution"
|
||||
/// error of the per-dist accessors
|
||||
fn dist_data(dist: &str) -> Result<&'static DistData, Box<dyn Error>> {
|
||||
DATA.dist.get(dist).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
||||
dist,
|
||||
supported_dists().join(", ")
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Special changelog distribution marking an entry that has not been
|
||||
/// released to any archive series yet
|
||||
pub const UNRELEASED: &str = "UNRELEASED";
|
||||
|
||||
/// Whether `series` is the special [`UNRELEASED`] distribution rather than
|
||||
/// a real archive series
|
||||
pub fn is_unreleased(series: &str) -> bool {
|
||||
series == UNRELEASED
|
||||
}
|
||||
|
||||
/// Get time-ordered list of series information for a distribution, development series first
|
||||
@@ -126,7 +315,7 @@ pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Bo
|
||||
})?;
|
||||
let series_info = &dist_data.series;
|
||||
let content = if Path::new(series_info.local.as_str()).exists() {
|
||||
std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv")).map_err(|e| {
|
||||
std::fs::read_to_string(series_info.local.as_str()).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read distribution series data for '{dist}' \
|
||||
from '{}': {}. The 'distro-info' package provides these CSV files.",
|
||||
@@ -134,7 +323,9 @@ pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Bo
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
reqwest::get(series_info.network.as_str())
|
||||
http_client()
|
||||
.get(series_info.network.as_str())
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
@@ -150,6 +341,30 @@ pub async fn get_ordered_series_name(dist: &str) -> Result<Vec<String>, Box<dyn
|
||||
Ok(series.iter().map(|info| info.series.clone()).collect())
|
||||
}
|
||||
|
||||
/// The series to actually target when the changelog says [`UNRELEASED`]:
|
||||
/// the development series of `dist`, i.e. the first entry of
|
||||
/// [`get_ordered_series_name`] (which is documented "development series
|
||||
/// first"). UNRELEASED work conventionally targets the next release, not
|
||||
/// the last stable one. `dist` is matched case-insensitively, so vendor
|
||||
/// names with original casing (dpkg's `Vendor:` field is e.g. "Ubuntu")
|
||||
/// are accepted as-is. Any other `series` is returned unchanged. Errors
|
||||
/// when `dist` is unknown or has no series list.
|
||||
pub async fn effective_series(series: &str, dist: &str) -> Result<String, Box<dyn Error>> {
|
||||
if !is_unreleased(series) {
|
||||
return Ok(series.to_string());
|
||||
}
|
||||
|
||||
// The series data keys are lowercase, unlike the vendor names that
|
||||
// callers typically resolve from dpkg
|
||||
let dist = dist.to_lowercase();
|
||||
|
||||
get_ordered_series_name(&dist)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("Distribution '{dist}' has no series to target").into())
|
||||
}
|
||||
|
||||
/// Get the latest released series for a dist (excluding future releases and special cases like sid)
|
||||
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
|
||||
let latest = get_n_latest_released_series(dist, 1).await?;
|
||||
@@ -203,25 +418,80 @@ pub async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>
|
||||
Err(format!("Unknown series: {}", series).into())
|
||||
}
|
||||
|
||||
/// Get the package pockets available for a given distribution
|
||||
///
|
||||
/// Example: get_dist_pockets(ubuntu) => ["proposed", "updates", ""]
|
||||
pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
||||
dist,
|
||||
supported_dists().join(", ")
|
||||
)
|
||||
})?;
|
||||
let mut pockets = dist_data.pockets.clone();
|
||||
/// The changelog suite name that aliases the series codename of `dist`
|
||||
/// (Debian's 'unstable' for 'sid'): the two names identify the same
|
||||
/// series. `None` when the series carries no suite alias.
|
||||
pub fn series_suite_alias(dist: &str, series: &str) -> Option<String> {
|
||||
dist_data(dist)
|
||||
.ok()?
|
||||
.suite_aliases
|
||||
.iter()
|
||||
.find(|(_suite, codename)| codename.as_str() == series)
|
||||
.map(|(suite, _)| suite.clone())
|
||||
}
|
||||
|
||||
// Explicitely add 'main' pocket, which is just the empty string
|
||||
pockets.push("".to_string());
|
||||
/// Identify a changelog suite name with the distro-info series codename
|
||||
/// it aliases (Debian's 'unstable' is 'sid'), and the dist that codename
|
||||
/// belongs to. `None` when `suite` is not a known alias of any dist.
|
||||
pub fn resolve_suite_alias(suite: &str) -> Option<(String, String)> {
|
||||
for (dist, data) in DATA.dist.iter() {
|
||||
if let Some(codename) = data.suite_aliases.get(suite) {
|
||||
return Some((dist.clone(), codename.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the package pockets available for a given distribution, in search order
|
||||
///
|
||||
/// The main archive ('') comes first so that a search without an explicit
|
||||
/// pocket prefers the released archive over its pockets; development pockets
|
||||
/// (e.g. '-proposed') come last.
|
||||
///
|
||||
/// Example: get_dist_pockets(ubuntu) => ["", "updates", "security", "proposed"]
|
||||
pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let mut pockets = dist_data(dist)?.pockets.clone();
|
||||
|
||||
// Explicitely add 'main' pocket, which is just the empty string, first
|
||||
pockets.insert(0, "".to_string());
|
||||
|
||||
Ok(pockets)
|
||||
}
|
||||
|
||||
/// Get the archive components of a distribution (ubuntu's main,
|
||||
/// restricted, universe, multiverse; Debian's main, contrib, non-free,
|
||||
/// non-free-firmware): the default set a build environment enables on its
|
||||
/// official sources. Live archive operations keep resolving components
|
||||
/// from Release files ([`get_components`]); this is the offline default.
|
||||
pub fn get_dist_components(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
Ok(dist_data(dist)?.components.clone())
|
||||
}
|
||||
|
||||
/// Get the pockets a cross-build environment enables for a series (the
|
||||
/// `<series>-<pocket>` suite list is built from these): updates,
|
||||
/// backports and security. Deliberately separate from
|
||||
/// [`get_dist_pockets`], which is the *search order* of pull — folding
|
||||
/// backports into it would change pull behavior.
|
||||
pub fn get_cross_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
Ok(dist_data(dist)?.cross_pockets.clone())
|
||||
}
|
||||
|
||||
/// Get the default build profiles of a distribution's vendor (Ubuntu
|
||||
/// activates `derivative.ubuntu noudeb`, Debian none), mirroring what
|
||||
/// `Dpkg::BuildProfiles` resolves when `DEB_BUILD_PROFILES` is unset.
|
||||
/// Vendors are matched case-insensitively by the caller (dpkg's `Vendor:`
|
||||
/// field keeps its original casing).
|
||||
pub fn get_build_profiles(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
Ok(dist_data(dist)?.build_profiles.clone())
|
||||
}
|
||||
|
||||
/// Get the valid `Section` values of a distribution's packages, as accepted
|
||||
/// by its archives (a `section/subsection` in debian/control validates on
|
||||
/// the part before the '/')
|
||||
pub fn get_sections(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
Ok(dist_data(dist)?.sections.clone())
|
||||
}
|
||||
|
||||
/// Get the sources URL for a distribution, series, pocket, and component
|
||||
pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
@@ -232,23 +502,105 @@ pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &s
|
||||
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
||||
}
|
||||
|
||||
/// Get the archive base URL for a distribution
|
||||
/// Get the archive base URL for a distribution: the URL of its primary
|
||||
/// mirror (the former `base_url` key folded into `mirrors.primary.url`
|
||||
/// when the mirrors were modeled — the signature is kept so the pull
|
||||
/// paths do not churn)
|
||||
///
|
||||
/// Example: ubuntu => http://archive.ubuntu.com/ubuntu
|
||||
/// Example: ubuntu => https://archive.ubuntu.com/ubuntu
|
||||
pub fn get_base_url(dist: &str) -> Result<String, Box<dyn Error>> {
|
||||
DATA.dist
|
||||
.get(dist)
|
||||
.map(|d| d.base_url.clone())
|
||||
let mirror = dist_data(dist)?
|
||||
.mirrors
|
||||
.get(PRIMARY_MIRROR)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
||||
dist,
|
||||
"Distribution '{dist}' has no '{PRIMARY_MIRROR}' mirror in the built-in \
|
||||
configuration. This is a bug; supported distributions are: {}.",
|
||||
supported_dists().join(", ")
|
||||
)
|
||||
})?;
|
||||
Ok(mirror.url.clone())
|
||||
}
|
||||
|
||||
/// The mirror of `dist` serving `arch`: the primary mirror first, then
|
||||
/// the other mirrors by name, so the answer is deterministic. Debian's
|
||||
/// `all` sentinel makes its primary mirror serve every architecture.
|
||||
/// Errors when the dist is unknown or no mirror serves the architecture
|
||||
/// (an architecture the built-in data does not know about).
|
||||
///
|
||||
/// Example: mirror_for_arch(ubuntu, riscv64) => the ports mirror
|
||||
pub fn mirror_for_arch(dist: &str, arch: &str) -> Result<&'static Mirror, Box<dyn Error>> {
|
||||
let data = dist_data(dist)?;
|
||||
if let Some(primary) = data.mirrors.get(PRIMARY_MIRROR)
|
||||
&& primary.serves(arch)
|
||||
{
|
||||
return Ok(primary);
|
||||
}
|
||||
let mut others: Vec<&String> = data
|
||||
.mirrors
|
||||
.keys()
|
||||
.filter(|name| name.as_str() != PRIMARY_MIRROR)
|
||||
.collect();
|
||||
others.sort();
|
||||
others
|
||||
.into_iter()
|
||||
.filter_map(|name| data.mirrors.get(name))
|
||||
.find(|mirror| mirror.serves(arch))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No mirror of '{dist}' serves the '{arch}' architecture. Supported \
|
||||
distributions are: {}.",
|
||||
supported_dists().join(", ")
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Host part of an apt-source URL: everything after the `://` scheme up
|
||||
/// to the first `/` (a `:port` suffix stripped). URLs without a scheme
|
||||
/// yield their leading segment.
|
||||
fn url_host(url: &str) -> &str {
|
||||
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
authority
|
||||
.split_once(':')
|
||||
.map_or(authority, |(host, _)| host)
|
||||
}
|
||||
|
||||
/// Whether the host of `uri` is the host of `mirror_url` or a subdomain
|
||||
/// of it: the old substring checks (`uri.contains("archive.ubuntu.com")`)
|
||||
/// intentionally matched the country mirrors fronting each archive host
|
||||
/// (`fr.archive.ubuntu.com`), and an exact host comparison would have
|
||||
/// dropped them. The leading dot of the suffix keeps look-alike hosts
|
||||
/// (`notarchive.ubuntu.com`) out.
|
||||
fn uri_matches_url_host(uri: &str, mirror_url: &str) -> bool {
|
||||
let host = url_host(mirror_url);
|
||||
let uri_host = url_host(uri);
|
||||
uri_host == host || uri_host.ends_with(&format!(".{host}"))
|
||||
}
|
||||
|
||||
/// Whether `uri` points at `mirror` or its security sibling: the URI's
|
||||
/// host is the mirror's (or the sibling's) host or a subdomain of it (see
|
||||
/// [`uri_matches_url_host`])
|
||||
pub fn is_mirror_source(mirror: &Mirror, uri: &str) -> bool {
|
||||
uri_matches_url_host(uri, &mirror.url)
|
||||
|| mirror
|
||||
.security_url
|
||||
.as_deref()
|
||||
.is_some_and(|security| uri_matches_url_host(uri, security))
|
||||
}
|
||||
|
||||
/// Whether `uri` points at an official source of `dist` — one of its
|
||||
/// archive mirrors or their security siblings — as opposed to a PPA or
|
||||
/// another third-party repository. Unknown distributions match nothing.
|
||||
pub fn is_official_source(dist: &str, uri: &str) -> bool {
|
||||
DATA.dist.get(dist).is_some_and(|data| {
|
||||
data.mirrors
|
||||
.values()
|
||||
.any(|mirror| is_mirror_source(mirror, uri))
|
||||
})
|
||||
}
|
||||
|
||||
/// Obtain the URLs for the archive keyrings of a distribution series
|
||||
///
|
||||
/// For 'sid' and 'experimental', returns keyrings from the 3 latest releases
|
||||
@@ -320,7 +672,7 @@ pub async fn get_components(
|
||||
let url = get_release_url(base_url, series, pocket);
|
||||
log::debug!("Fetching Release file from: {}", url);
|
||||
|
||||
let content = reqwest::get(&url).await?.text().await?;
|
||||
let content = http_client().get(&url).send().await?.text().await?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Components:")
|
||||
@@ -355,7 +707,9 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
reqwest::get(series_info.network.as_str())
|
||||
http_client()
|
||||
.get(series_info.network.as_str())
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
@@ -387,10 +741,315 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The release number of a distribution series, paired with the dist it
|
||||
/// belongs to: the version column of the series data, stripped to its
|
||||
/// leading token ("12" for Debian bookworm, "26.04" out of Ubuntu
|
||||
/// resolute's "26.04 LTS"). `None` when the series carries no version at
|
||||
/// all (Debian's rolling sid/experimental have an empty column;
|
||||
/// pseudo-versions like "unstable" pass through, callers validate per
|
||||
/// vendor). Errors when no known distribution carries the series.
|
||||
pub async fn get_series_release_number(
|
||||
series: &str,
|
||||
) -> Result<Option<(String, String)>, Box<dyn Error>> {
|
||||
let dist = get_dist_from_series(series).await?;
|
||||
for info in get_ordered_series(&dist).await? {
|
||||
if info.series == series {
|
||||
let number = info
|
||||
.version
|
||||
.as_deref()
|
||||
.and_then(|version| version.split_whitespace().next())
|
||||
.map(str::to_string);
|
||||
return Ok(number.map(|number| (dist, number)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_sections() {
|
||||
// Both distributions bundle the policy section list
|
||||
for dist in ["debian", "ubuntu"] {
|
||||
let sections = get_sections(dist).unwrap();
|
||||
assert!(sections.contains(&"utils".to_string()));
|
||||
assert!(sections.contains(&"devel".to_string()));
|
||||
// 'unknown' is exactly what archives reject
|
||||
assert!(!sections.contains(&"unknown".to_string()));
|
||||
}
|
||||
assert!(get_sections("not-a-distro").is_err());
|
||||
}
|
||||
|
||||
/// The primary mirror's URL is the former `base_url`, byte for byte:
|
||||
/// the pull paths build their archive URLs from it.
|
||||
#[test]
|
||||
fn test_primary_mirror_url_is_the_base_url() {
|
||||
assert_eq!(
|
||||
get_base_url("ubuntu").unwrap(),
|
||||
"https://archive.ubuntu.com/ubuntu"
|
||||
);
|
||||
assert_eq!(
|
||||
get_base_url("debian").unwrap(),
|
||||
"https://deb.debian.org/debian"
|
||||
);
|
||||
assert!(get_base_url("not-a-distro").is_err());
|
||||
}
|
||||
|
||||
/// Mirror-per-architecture resolution: the local architectures come
|
||||
/// from Ubuntu's primary mirror, the others from ports; Debian's `all`
|
||||
/// sentinel makes its one mirror serve everything, including
|
||||
/// architectures the data never lists.
|
||||
#[test]
|
||||
fn test_mirror_for_arch() {
|
||||
assert_eq!(
|
||||
mirror_for_arch("ubuntu", "amd64").unwrap().url,
|
||||
"https://archive.ubuntu.com/ubuntu"
|
||||
);
|
||||
assert_eq!(
|
||||
mirror_for_arch("ubuntu", "riscv64").unwrap().url,
|
||||
"http://ports.ubuntu.com/ubuntu-ports"
|
||||
);
|
||||
for arch in ["amd64", "riscv64", "brand-new"] {
|
||||
assert_eq!(
|
||||
mirror_for_arch("debian", arch).unwrap().url,
|
||||
"https://deb.debian.org/debian",
|
||||
"the `all` sentinel serves every architecture, including {arch}"
|
||||
);
|
||||
}
|
||||
// An architecture no Ubuntu mirror serves, and an unknown dist.
|
||||
assert!(mirror_for_arch("ubuntu", "mips64el").is_err());
|
||||
assert!(mirror_for_arch("not-a-distro", "amd64").is_err());
|
||||
}
|
||||
|
||||
/// The `archs` forms and their reading: the `all` sentinel serves
|
||||
/// everything, an explicit list serves exactly its members, and any
|
||||
/// other scalar serves nothing (a data bug a validation would have to
|
||||
/// catch, hence the documented reading).
|
||||
#[test]
|
||||
fn test_mirror_archs_forms() {
|
||||
let all: MirrorArchs = serde_yaml::from_str("all").unwrap();
|
||||
assert!(all.serves("anything"));
|
||||
let list: MirrorArchs = serde_yaml::from_str("[amd64, i386]").unwrap();
|
||||
assert!(list.serves("amd64"));
|
||||
assert!(!list.serves("arm64"));
|
||||
let typo: MirrorArchs = serde_yaml::from_str("every").unwrap();
|
||||
assert!(!typo.serves("amd64"));
|
||||
}
|
||||
|
||||
/// Official-source matching is host-based but keeps matching the
|
||||
/// country mirrors the old substring checks matched (`fr.archive.
|
||||
/// ubuntu.com`): equality or a `.{host}` suffix, never a bare
|
||||
/// substring — `notarchive.ubuntu.com` must not match.
|
||||
#[test]
|
||||
fn test_is_official_source_matches_country_mirrors_only() {
|
||||
for uri in [
|
||||
"https://archive.ubuntu.com/ubuntu",
|
||||
"http://security.ubuntu.com/ubuntu",
|
||||
"http://ports.ubuntu.com/ubuntu-ports",
|
||||
// Country mirrors front the same archives.
|
||||
"http://fr.archive.ubuntu.com/ubuntu",
|
||||
"https://de.security.ubuntu.com/ubuntu",
|
||||
] {
|
||||
assert!(is_official_source("ubuntu", uri), "{uri}");
|
||||
}
|
||||
for uri in [
|
||||
"https://deb.debian.org/debian",
|
||||
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
|
||||
"http://notarchive.ubuntu.com/ubuntu",
|
||||
"http://archive.ubuntu.com.evil.example/ubuntu",
|
||||
] {
|
||||
assert!(!is_official_source("ubuntu", uri), "{uri}");
|
||||
}
|
||||
// Debian: its own mirror matches, Ubuntu's mirrors do not, and an
|
||||
// unknown dist matches nothing.
|
||||
assert!(is_official_source(
|
||||
"debian",
|
||||
"https://deb.debian.org/debian"
|
||||
));
|
||||
assert!(!is_official_source(
|
||||
"debian",
|
||||
"http://security.ubuntu.com/ubuntu"
|
||||
));
|
||||
assert!(!is_official_source(
|
||||
"not-a-distro",
|
||||
"https://deb.debian.org/debian"
|
||||
));
|
||||
}
|
||||
|
||||
/// The dist-level defaults the build paths read: components,
|
||||
/// cross-build pockets (deliberately not the pull search order) and
|
||||
/// vendor build profiles.
|
||||
#[test]
|
||||
fn test_dist_components_cross_pockets_and_build_profiles() {
|
||||
assert_eq!(
|
||||
get_dist_components("ubuntu").unwrap(),
|
||||
vec!["main", "restricted", "universe", "multiverse"]
|
||||
);
|
||||
assert!(
|
||||
get_dist_components("debian")
|
||||
.unwrap()
|
||||
.contains(&"non-free-firmware".to_string())
|
||||
);
|
||||
|
||||
for dist in ["debian", "ubuntu"] {
|
||||
assert_eq!(
|
||||
get_cross_pockets(dist).unwrap(),
|
||||
vec!["updates", "backports", "security"]
|
||||
);
|
||||
}
|
||||
// Not the pull search order: no 'proposed', no empty main pocket.
|
||||
let cross = get_cross_pockets("ubuntu").unwrap();
|
||||
assert!(!cross.contains(&"proposed".to_string()));
|
||||
assert!(!cross.contains(&"".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
get_build_profiles("ubuntu").unwrap(),
|
||||
vec!["derivative.ubuntu", "noudeb"]
|
||||
);
|
||||
assert!(get_build_profiles("debian").unwrap().is_empty());
|
||||
|
||||
for getter in [
|
||||
get_dist_components as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||
get_cross_pockets as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||
get_build_profiles as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||
] {
|
||||
assert!(getter("not-a-distro").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_series_csv_malformed_rows() {
|
||||
// A short row (missing 'codename') is skipped, a row with an invalid
|
||||
// 'created' date is kept without a date, and invalid 'release'/'eol'
|
||||
// dates become None: none of this may panic on remote data
|
||||
let csv_data = "series,codename,version,created,release,eol\n\
|
||||
noble,Noble N,24.04,2023-10-26,2024-04-25,2029-04-25\n\
|
||||
lonely\n\
|
||||
badbad,Bad B,1.0,not-a-date,2020-01-01,also-bad\n\
|
||||
sid,sid,unstable,1999-01-01,,\n";
|
||||
|
||||
let series = parse_series_csv(csv_data).unwrap();
|
||||
|
||||
// Rows are returned most recent first (the parser reverses the list),
|
||||
// with the malformed 'lonely' row skipped entirely
|
||||
let names: Vec<&str> = series.iter().map(|s| s.series.as_str()).collect();
|
||||
assert_eq!(names, vec!["sid", "badbad", "noble"]);
|
||||
|
||||
let noble = &series[2];
|
||||
assert_eq!(noble.codename, "Noble N");
|
||||
assert_eq!(noble.version.as_deref(), Some("24.04"));
|
||||
assert_eq!(
|
||||
noble.created,
|
||||
Some(NaiveDate::from_ymd_opt(2023, 10, 26).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
noble.release,
|
||||
Some(NaiveDate::from_ymd_opt(2024, 4, 25).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
noble.eol,
|
||||
Some(NaiveDate::from_ymd_opt(2029, 4, 25).unwrap())
|
||||
);
|
||||
|
||||
let badbad = &series[1];
|
||||
assert_eq!(badbad.created, None);
|
||||
assert_eq!(
|
||||
badbad.release,
|
||||
Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
|
||||
);
|
||||
assert_eq!(badbad.eol, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_dist_pockets_order() {
|
||||
// Without an explicit pocket, packages are searched in this order:
|
||||
// main archive first, then updates, security, and proposed last
|
||||
let pockets = get_dist_pockets("ubuntu").unwrap();
|
||||
assert_eq!(
|
||||
pockets,
|
||||
vec![
|
||||
"".to_string(),
|
||||
"updates".to_string(),
|
||||
"security".to_string(),
|
||||
"proposed".to_string()
|
||||
]
|
||||
);
|
||||
|
||||
let pockets = get_dist_pockets("debian").unwrap();
|
||||
assert_eq!(
|
||||
pockets,
|
||||
vec![
|
||||
"".to_string(),
|
||||
"updates".to_string(),
|
||||
"security".to_string(),
|
||||
"proposed-updates".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_unreleased() {
|
||||
// Matching is exact: UNRELEASED is uppercase by Debian convention
|
||||
assert!(is_unreleased("UNRELEASED"));
|
||||
assert!(!is_unreleased("unreleased"));
|
||||
assert!(!is_unreleased("noble"));
|
||||
assert!(!is_unreleased(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_effective_series_passthrough() {
|
||||
// A real series is returned unchanged, and the dist is not even
|
||||
// looked up (an unknown dist only matters for UNRELEASED)
|
||||
assert_eq!(effective_series("noble", "ubuntu").await.unwrap(), "noble");
|
||||
assert_eq!(effective_series("sid", "debian").await.unwrap(), "sid");
|
||||
assert_eq!(
|
||||
effective_series("noble", "unknown-distro").await.unwrap(),
|
||||
"noble"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_effective_series_unreleased() {
|
||||
// UNRELEASED resolves to the development series of the dist, i.e.
|
||||
// the first entry of the time-ordered list. On current distro-info
|
||||
// data this is the next Ubuntu release, while Debian's list starts
|
||||
// with 'experimental' (sid comes second), so assert against the
|
||||
// data itself rather than a hardcoded name.
|
||||
for dist in ["ubuntu", "debian"] {
|
||||
let ordered = get_ordered_series_name(dist).await.unwrap();
|
||||
let resolved = effective_series(UNRELEASED, dist).await.unwrap();
|
||||
assert_eq!(resolved, ordered[0]);
|
||||
assert_ne!(resolved, UNRELEASED);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_effective_series_unreleased_dist_case_insensitive() {
|
||||
// Distro data keys are lowercase but dpkg vendors keep original
|
||||
// casing ("Ubuntu"): the UNRELEASED lookup must resolve both
|
||||
let expected = effective_series(UNRELEASED, "ubuntu").await.unwrap();
|
||||
assert_eq!(
|
||||
effective_series(UNRELEASED, "Ubuntu").await.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
effective_series(UNRELEASED, "UBUNTU").await.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_effective_series_unknown_dist() {
|
||||
// UNRELEASED on an unknown distribution cannot be resolved
|
||||
assert!(
|
||||
effective_series(UNRELEASED, "unknown-distro")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_debian_series() {
|
||||
let series = get_ordered_series_name("debian").await.unwrap();
|
||||
@@ -405,6 +1064,41 @@ mod tests {
|
||||
assert!(series.contains(&"jammy".to_string()));
|
||||
}
|
||||
|
||||
/// Suite aliases identify a changelog suite name with the series
|
||||
/// codename of the same series: Debian's 'unstable' is 'sid'
|
||||
#[test]
|
||||
fn test_suite_aliases() {
|
||||
assert_eq!(
|
||||
resolve_suite_alias("unstable"),
|
||||
Some(("debian".to_string(), "sid".to_string()))
|
||||
);
|
||||
// A series codename or unknown suite is not an alias
|
||||
assert_eq!(resolve_suite_alias("sid"), None);
|
||||
assert_eq!(resolve_suite_alias("noble"), None);
|
||||
assert_eq!(
|
||||
series_suite_alias("debian", "sid"),
|
||||
Some("unstable".to_string())
|
||||
);
|
||||
assert_eq!(series_suite_alias("debian", "trixie"), None);
|
||||
assert_eq!(series_suite_alias("ubuntu", "noble"), None);
|
||||
}
|
||||
|
||||
/// Every suite alias must map to a real series of its dist, or the
|
||||
/// selector would offer a phantom entry
|
||||
#[tokio::test]
|
||||
async fn test_suite_aliases_target_real_series() {
|
||||
for (dist, data) in DATA.dist.iter() {
|
||||
for (suite, codename) in &data.suite_aliases {
|
||||
let series = get_ordered_series_name(dist).await.unwrap_or_default();
|
||||
assert!(
|
||||
series.contains(codename),
|
||||
"suite alias '{suite}' of {dist} maps to '{codename}', \
|
||||
which is not a known series"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dist_from_series() {
|
||||
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
||||
@@ -427,6 +1121,25 @@ mod tests {
|
||||
assert!(unknown_number.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_series_release_number() {
|
||||
let (dist, bookworm) = get_series_release_number("bookworm")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(dist, "debian");
|
||||
assert_eq!(bookworm, "12");
|
||||
|
||||
// Ubuntu LTS rows carry a " LTS" decoration: only the leading
|
||||
// YY.MM token is the release number
|
||||
let (dist, noble) = get_series_release_number("noble").await.unwrap().unwrap();
|
||||
assert_eq!(dist, "ubuntu");
|
||||
assert_eq!(noble, "24.04");
|
||||
|
||||
// No known dist carries the series
|
||||
assert!(get_series_release_number("not-a-series").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_keyring_urls_sid() {
|
||||
// Test that 'sid' returns keyrings from the 3 latest released versions
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Passive interrupt state shared between the CLI and the library.
|
||||
//!
|
||||
//! Everything active about Ctrl+C lives in the CLI (`main.rs`): it installs
|
||||
//! the SIGINT handler, wakes a watchdog thread, prints the interrupt notice
|
||||
//! and exits with the conventional status 130. This module only holds the
|
||||
//! state the library's own types need:
|
||||
//!
|
||||
//! - the interrupted flag ([`mark_interrupted`] / [`interrupted`]), read by
|
||||
//! flows so they stand down while the watchdog tears everything down;
|
||||
//! - the cleanup hook registry ([`register_cleanup_hook`]) for resources
|
||||
//! that must not outlive the process (e.g. the ephemeral build chroot,
|
||||
//! see [`crate::deb::ephemeral`]), drained and run by the CLI watchdog
|
||||
//! right before exiting ([`run_cleanup_hooks`]);
|
||||
//! - the reporter slot ([`set_reporter`]): the live build view registers
|
||||
//! how to clear the terminal (and where the full log lives); the CLI
|
||||
//! runs it as the first step of the shutdown.
|
||||
//!
|
||||
//! Nothing here installs signal handlers, prints or exits: a library
|
||||
//! consumer embedding these types keeps its own signal disposition.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
/// How the live view reports an interrupt: it clears the terminal and
|
||||
/// returns the log-file hint to print below the notice, if any
|
||||
pub type Reporter = Box<dyn FnOnce() -> Option<String> + Send>;
|
||||
|
||||
/// A boxed, send-safe cleanup hook body
|
||||
type CleanupFn = Box<dyn Fn() + Send>;
|
||||
|
||||
/// The reporter run before the cleanup hooks; taken out when it runs
|
||||
static REPORTER: Mutex<Option<Reporter>> = Mutex::new(None);
|
||||
|
||||
/// Whether a Ctrl+C has been intercepted since the CLI installed the
|
||||
/// handler
|
||||
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Registry of cleanup hooks waiting to run at interrupt time
|
||||
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Source of the registry ids used to deregister a specific hook
|
||||
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A pending cleanup hook together with its registry id
|
||||
struct CleanupHook {
|
||||
id: u64,
|
||||
f: CleanupFn,
|
||||
}
|
||||
|
||||
/// Record that a Ctrl+C has been intercepted; called by the CLI signal
|
||||
/// handler
|
||||
pub fn mark_interrupted() {
|
||||
INTERRUPTED.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether a Ctrl+C has been intercepted; flows use this to stay quiet and
|
||||
/// to leave the cleanup to the CLI watchdog
|
||||
pub fn interrupted() -> bool {
|
||||
INTERRUPTED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Register how the live view reports an interrupt: the CLI watchdog runs
|
||||
/// it as the first step of the shutdown, before the cleanup hooks. At most
|
||||
/// one reporter runs per process: a later call replaces the one set before.
|
||||
/// Without any reporter the watchdog only prints the plain notice.
|
||||
pub fn set_reporter(report: Reporter) {
|
||||
*REPORTER.lock().unwrap_or_else(PoisonError::into_inner) = Some(report);
|
||||
}
|
||||
|
||||
/// Take the registered reporter out of the slot; `None` when no live view
|
||||
/// registered one (`--verbose`, piped output)
|
||||
pub fn take_reporter() -> Option<Reporter> {
|
||||
REPORTER
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
|
||||
/// Register a hook to be run when the process is interrupted (after the
|
||||
/// reporter), returning a guard whose drop deregisters the hook again.
|
||||
///
|
||||
/// Hooks must be self-contained — stored paths plus direct subprocesses —
|
||||
/// and must never block indefinitely: they run in the watchdog while the
|
||||
/// interrupted flow is still unwinding, and a second Ctrl+C during cleanup
|
||||
/// is a no-op.
|
||||
pub fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
|
||||
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
|
||||
CLEANUP_HOOKS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.push(CleanupHook { id, f });
|
||||
CleanupHookGuard(id)
|
||||
}
|
||||
|
||||
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
|
||||
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
|
||||
/// the interrupt path can no longer run it
|
||||
pub struct CleanupHookGuard(u64);
|
||||
|
||||
impl CleanupHookGuard {
|
||||
/// Registry id of the hook (used to filter the registry in tests)
|
||||
#[cfg(test)]
|
||||
fn id(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Remove the hook from the registry; returns whether it was still
|
||||
/// pending
|
||||
pub fn deregister(&mut self) -> bool {
|
||||
deregister_cleanup_hook(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CleanupHookGuard {
|
||||
fn drop(&mut self) {
|
||||
deregister_cleanup_hook(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a hook from the registry; returns whether it was still pending
|
||||
fn deregister_cleanup_hook(id: u64) -> bool {
|
||||
let mut hooks = CLEANUP_HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let len_before = hooks.len();
|
||||
hooks.retain(|hook| hook.id != id);
|
||||
hooks.len() != len_before
|
||||
}
|
||||
|
||||
/// Drain and run every registered cleanup hook exactly once.
|
||||
///
|
||||
/// Called by the CLI watchdog right before the process exits. Draining uses
|
||||
/// `try_lock` with a bounded retry instead of a blocking lock as a hard
|
||||
/// upper bound on interrupt latency: the sequence must never hang waiting
|
||||
/// for a lock, however unlikely a stalled holder is. Timing out therefore
|
||||
/// skips cleanup (leaking) rather than hanging.
|
||||
pub fn run_cleanup_hooks() {
|
||||
run_drained_hooks(drain_cleanup_hooks());
|
||||
}
|
||||
|
||||
/// Take every pending hook out of the registry, waiting at most ~1s for the
|
||||
/// registry lock (see [`run_cleanup_hooks`] for why this must not block
|
||||
/// forever)
|
||||
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
|
||||
const RETRIES: usize = 200;
|
||||
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
|
||||
|
||||
for _ in 0..RETRIES {
|
||||
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
|
||||
return std::mem::take(&mut *hooks);
|
||||
}
|
||||
std::thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Run drained hooks one by one, isolating panics so that one failing hook
|
||||
/// cannot skip the remaining ones
|
||||
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
|
||||
for CleanupHook { id, f } in hooks {
|
||||
// Hooks are arbitrary user code; assert unwind safety so they can be
|
||||
// run inside a catching context
|
||||
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort message extraction from a panic payload
|
||||
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// Serializes these tests: they drain the process-global registry, and
|
||||
/// unrelated tests may hold registrations concurrently that must be
|
||||
/// neither run nor lost. Poison-proof: a test failing while holding the
|
||||
/// lock must not cascade into the others.
|
||||
static TEST_LOCK: StdMutex<()> = StdMutex::new(());
|
||||
|
||||
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
TEST_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Drain the registry and take out only the hooks with the given ids,
|
||||
/// putting everything else back so unrelated registrations stay pending
|
||||
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
|
||||
let drained = drain_cleanup_hooks();
|
||||
let mut mine = Vec::new();
|
||||
let mut others = Vec::new();
|
||||
for hook in drained {
|
||||
if ids.contains(&hook.id) {
|
||||
mine.push(hook);
|
||||
} else {
|
||||
others.push(hook);
|
||||
}
|
||||
}
|
||||
CLEANUP_HOOKS
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.extend(others);
|
||||
mine
|
||||
}
|
||||
|
||||
/// Register a hook that counts its invocations
|
||||
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let seen = counter.clone();
|
||||
let guard = register_cleanup_hook(Box::new(move || {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
(guard, counter)
|
||||
}
|
||||
|
||||
/// Hooks run in registration order, and draining means each hook runs
|
||||
/// exactly once even across repeated cleanup passes.
|
||||
#[test]
|
||||
fn hooks_run_once_in_registration_order() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let log = Arc::new(StdMutex::new(Vec::new()));
|
||||
let mut guards = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for name in ["hook-a", "hook-b", "hook-c"] {
|
||||
let log = log.clone();
|
||||
// The returned guard must stay alive: dropping it deregisters
|
||||
let guard = register_cleanup_hook(Box::new(move || {
|
||||
log.lock().unwrap().push(name);
|
||||
}));
|
||||
ids.push(guard.id());
|
||||
guards.push(guard);
|
||||
}
|
||||
|
||||
// Only our own hooks are extracted; they run in registration order
|
||||
let mine = take_hooks(&ids);
|
||||
assert_eq!(mine.len(), ids.len());
|
||||
run_drained_hooks(mine);
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
// Draining removed them: a second pass runs nothing again
|
||||
assert!(take_hooks(&ids).is_empty());
|
||||
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
/// A panicking hook is contained by the runner: it neither aborts the
|
||||
/// process nor skips the hooks registered around it.
|
||||
#[test]
|
||||
fn panicking_hook_does_not_skip_the_others() {
|
||||
let _serial = test_lock();
|
||||
// The hook below panics on purpose: do not record it as a test
|
||||
// failure in the end-of-run matrix
|
||||
let _quiet = crate::test_support::suppress_failure_recording();
|
||||
|
||||
let (before, ran_before) = counting_hook();
|
||||
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
|
||||
let (after, ran_after) = counting_hook();
|
||||
|
||||
let ids = [before.id(), boom.id(), after.id()];
|
||||
run_drained_hooks(take_hooks(&ids));
|
||||
|
||||
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// Explicit deregistration removes the hook: it is no longer drained and
|
||||
/// never runs; a second deregistration reports it as already gone.
|
||||
#[test]
|
||||
fn deregistered_hook_never_runs() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let (mut guard, ran) = counting_hook();
|
||||
|
||||
assert!(guard.deregister());
|
||||
assert!(!guard.deregister());
|
||||
|
||||
assert!(take_hooks(&[guard.id()]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Dropping the registration guard deregisters the hook implicitly.
|
||||
#[test]
|
||||
fn dropping_the_guard_deregisters_the_hook() {
|
||||
let _serial = test_lock();
|
||||
|
||||
let id;
|
||||
let ran;
|
||||
{
|
||||
let (guard, counter) = counting_hook();
|
||||
id = guard.id();
|
||||
ran = counter;
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
assert!(take_hooks(&[id]).is_empty());
|
||||
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
//! Launchpad integration for `pkh put`: PPA upload targets, Launchpad
|
||||
//! account (username) discovery and pre-upload checks against the Launchpad
|
||||
//! API.
|
||||
//!
|
||||
//! Launchpad's SFTP upload server requires the SSH username to be a real
|
||||
//! Launchpad account name — anonymous logins are rejected ("Launchpad user
|
||||
//! 'anonymous' doesn't have a registered SSH key") — and authenticates it
|
||||
//! with the SSH keys registered on that account
|
||||
//! (<https://launchpad.net/~/+editsshkeys>). The username therefore has to
|
||||
//! be discovered on the machine rather than hardcoded: first from the git
|
||||
//! configuration ([`username`], the `lp.user` key), then through the generic
|
||||
//! fallbacks (SSH configuration `User`, local user name — see
|
||||
//! [`crate::put::ssh`]).
|
||||
//!
|
||||
//! The upload queue itself is a blind write: the SFTP server accepts any
|
||||
//! file an authenticated user puts into their incoming area, and invalid
|
||||
//! targets are only rejected later, during queue processing. The
|
||||
//! [`ppa_info`] check makes sure the target actually exists before anything
|
||||
//! is uploaded.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::data::embed_data;
|
||||
use crate::put::target::UploadTarget;
|
||||
|
||||
/// Git configuration key holding the Launchpad account name
|
||||
const LP_USER_KEY: &str = "lp.user";
|
||||
|
||||
/// Launchpad service endpoints, loaded from the bundled `launchpad.yml`
|
||||
/// data file (same pattern as `distro_info.yml`): static endpoints that
|
||||
/// change with Launchpad, not with the code, are data — several of them
|
||||
/// were previously duplicated across three modules.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LaunchpadData {
|
||||
/// Base URL of the Launchpad REST API
|
||||
api_base: String,
|
||||
/// Host of the PPA SFTP upload server
|
||||
ssh_host: String,
|
||||
/// Port of the PPA SFTP upload server
|
||||
ssh_port: u16,
|
||||
/// Host of the PPA upload queue over anonymous FTP (the transport
|
||||
/// `pkh put` degrades to when the SSH connection never comes up)
|
||||
ftp_host: String,
|
||||
/// Port of the anonymous FTP upload queue
|
||||
ftp_port: u16,
|
||||
/// Upload queue incoming directory template (`{owner}`/`{ppa}`)
|
||||
incoming_template: String,
|
||||
/// PPA package-content (apt repository) URL template
|
||||
content_host_template: String,
|
||||
/// Ubuntu source-package git web URL template (`{package}`)
|
||||
git_web_template: String,
|
||||
}
|
||||
|
||||
embed_data! {
|
||||
static ref LAUNCHPAD_DATA: LaunchpadData = "../data/launchpad.yml"
|
||||
}
|
||||
|
||||
/// The PPA upload queue over anonymous FTP (host, port): the transport
|
||||
/// dput-ng's plain `ppa:` profile pushes over, and the one `pkh put`
|
||||
/// degrades to when the SSH connection itself never comes up.
|
||||
pub(crate) fn ppa_ftp_queue() -> (String, u16) {
|
||||
(LAUNCHPAD_DATA.ftp_host.clone(), LAUNCHPAD_DATA.ftp_port)
|
||||
}
|
||||
|
||||
/// Base URL of the Launchpad REST API
|
||||
fn api_base() -> &'static str {
|
||||
&LAUNCHPAD_DATA.api_base
|
||||
}
|
||||
|
||||
/// Host serving PPA package content, derived from the content-host
|
||||
/// template so the URL builders and the URL parsers of PPA addresses
|
||||
/// cannot drift apart
|
||||
pub(crate) fn ppa_content_host() -> &'static str {
|
||||
let template = LAUNCHPAD_DATA.content_host_template.as_str();
|
||||
let after_scheme = template
|
||||
.split_once("://")
|
||||
.map_or(template, |(_, rest)| rest);
|
||||
after_scheme.split('/').next().unwrap_or(after_scheme)
|
||||
}
|
||||
|
||||
/// Base URL of the apt repository serving a PPA's packages
|
||||
/// (e.g. `https://ppa.launchpadcontent.net/user/ppa/ubuntu`)
|
||||
pub(crate) fn ppa_content_url(owner: &str, ppa: &str) -> String {
|
||||
LAUNCHPAD_DATA
|
||||
.content_host_template
|
||||
.replace("{owner}", owner)
|
||||
.replace("{ppa}", ppa)
|
||||
}
|
||||
|
||||
/// URL of the Launchpad git repository of an Ubuntu source package
|
||||
/// (`git.launchpad.net/ubuntu/+source/<package>`), the preferred VCS of
|
||||
/// Ubuntu packages
|
||||
pub(crate) fn ubuntu_source_git_url(package: &str) -> String {
|
||||
LAUNCHPAD_DATA
|
||||
.git_web_template
|
||||
.replace("{package}", package)
|
||||
}
|
||||
|
||||
/// Page size (`ws.size`) asked from Launchpad collections. Launchpad
|
||||
/// truncates collection answers at 75 entries by default and rejects
|
||||
/// `ws.size` above 300 (both verified against the live API); 100 sits
|
||||
/// comfortably under the cap while keeping multi-page walks rare.
|
||||
const WS_PAGE_SIZE: u32 = 100;
|
||||
|
||||
/// Hard cap on the pages followed while walking a `getPublishedSources`
|
||||
/// collection: 20 pages x 100 entries = 2000 currently published entries
|
||||
/// for one source name. The query only counts `Published` entries of live
|
||||
/// series/pockets, so real histories are a handful of entries; a walk
|
||||
/// reaching the cap means the API is misbehaving (an endless next-link
|
||||
/// chain), not that the history is genuinely huge.
|
||||
const MAX_COLLECTION_PAGES: u32 = 20;
|
||||
|
||||
/// The Launchpad username configured in git: the repository-local
|
||||
/// configuration wins over the global one, like git's own precedence.
|
||||
/// `None` when no git repository is found or the key is unset.
|
||||
pub fn username(cwd: &Path) -> Option<String> {
|
||||
// A repository's config covers the local file; the global/system levels
|
||||
// are consulted separately so the key is found in both setups
|
||||
if let Ok(repo) = git2::Repository::discover(cwd)
|
||||
&& let Ok(config) = repo.config()
|
||||
&& let Some(value) = config_value(&config)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
if let Ok(config) = git2::Config::open_default() {
|
||||
return config_value(&config);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Trimmed, non-empty `lp.user` value of a configuration, `None` when unset
|
||||
fn config_value(config: &git2::Config) -> Option<String> {
|
||||
config
|
||||
.get_string(LP_USER_KEY)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Split a `user/ppa_name` PPA argument, rejecting malformed ones
|
||||
fn split_ppa(ppa: &str) -> Result<(String, String), String> {
|
||||
let parts: Vec<&str> = ppa.split('/').collect();
|
||||
if parts.len() != 2 || parts.iter().any(|p| p.is_empty()) {
|
||||
return Err(format!(
|
||||
"Invalid PPA format: '{ppa}'. Expected: user/ppa_name"
|
||||
));
|
||||
}
|
||||
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
}
|
||||
|
||||
/// URL of the Launchpad API resource of a Launchpad account
|
||||
fn person_url(user: &str) -> String {
|
||||
format!("{}/~{user}", api_base())
|
||||
}
|
||||
|
||||
/// URL of the Launchpad API resource of a PPA (`~user/+archive/ubuntu/name`
|
||||
/// covers the default `ppa` archive and named archives alike); shared by the
|
||||
/// put-side pre-flight checks and the apt keyring's fingerprint lookup
|
||||
pub(crate) fn archive_url(user: &str, ppa: &str) -> String {
|
||||
format!("{}/~{user}/+archive/ubuntu/{ppa}", api_base())
|
||||
}
|
||||
|
||||
/// Resolve a `user/ppa_name` PPA argument into its upload target (the
|
||||
/// SFTP host and incoming template of `launchpad.yml`), like dput-ng's
|
||||
/// `ppa:user/ppa` profile expansion.
|
||||
pub fn ppa_target(ppa: &str) -> Result<UploadTarget, String> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
|
||||
Ok(UploadTarget {
|
||||
fqdn: LAUNCHPAD_DATA.ssh_host.clone(),
|
||||
port: LAUNCHPAD_DATA.ssh_port,
|
||||
login: None,
|
||||
incoming: LAUNCHPAD_DATA
|
||||
.incoming_template
|
||||
.replace("{owner}", &user)
|
||||
.replace("{ppa}", &name),
|
||||
label: format!("ppa:{ppa}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The subset of the Launchpad Archive API resource relevant for uploads
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PpaInfo {
|
||||
/// Display name of the archive (e.g. "Noctalia")
|
||||
pub displayname: String,
|
||||
/// The archive's self-description
|
||||
pub description: Option<String>,
|
||||
/// Disabled archives accept no uploads; absent/null on many archives
|
||||
/// (treated as enabled)
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Look up the PPA `user/name` (same format as `pkh put --ppa`) in the
|
||||
/// Launchpad API, failing with a precise message when the account or the
|
||||
/// archive does not exist, or the archive is disabled. This is the
|
||||
/// pre-flight check the SFTP queue itself never does.
|
||||
pub async fn ppa_info(ppa: &str) -> Result<PpaInfo, Box<dyn Error>> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
let client = crate::distro_info::http_client();
|
||||
|
||||
let response = client
|
||||
.get(person_url(&user))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(format!(
|
||||
"Launchpad user '~{user}' does not exist: check the PPA argument '{ppa}'"
|
||||
)
|
||||
.into());
|
||||
} else if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Launchpad API returned {} for user '~{user}'",
|
||||
response.status()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get(archive_url(&user, &name))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::OK => {
|
||||
let info: PpaInfo = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
|
||||
if info.enabled == Some(false) {
|
||||
return Err(
|
||||
format!("PPA '{ppa}' is disabled: it exists but accepts no uploads").into(),
|
||||
);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
||||
}
|
||||
status => Err(format!("Launchpad API returned {status} for PPA '{ppa}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode a query-string value (RFC 3986): unreserved characters
|
||||
/// pass through, everything else becomes `%XX`. Debian source package names
|
||||
/// may contain `+` (`g++`), which must not reach the API unencoded — query
|
||||
/// values follow form-urlencoded rules, where a literal `+` decodes to a
|
||||
/// space.
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// URL of the first page of the `getPublishedSources` API call listing the
|
||||
/// currently `Published` source packages named `source_name` in the PPA
|
||||
/// `user/name`: `exact_match` avoids Launchpad's default case-insensitive
|
||||
/// substring matching, which would return unrelated sources (`data` matching
|
||||
/// `datatables`). Further pages are reached through the answer's
|
||||
/// `next_collection_link`, not by hand-building URLs.
|
||||
fn published_sources_url(user: &str, ppa: &str, source_name: &str) -> String {
|
||||
format!(
|
||||
"{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published&ws.size={WS_PAGE_SIZE}",
|
||||
archive_url(user, ppa),
|
||||
percent_encode(source_name)
|
||||
)
|
||||
}
|
||||
|
||||
/// One page of a `getPublishedSources` answer: the subset of the source
|
||||
/// package publishing history the superseded-upload check needs (the live
|
||||
/// answer carries many more fields, ignored by serde)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PublishedSource {
|
||||
/// Version of the published source package
|
||||
source_package_version: String,
|
||||
}
|
||||
|
||||
/// One page of the `getPublishedSources` collection answer
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PublishedSources {
|
||||
/// The currently published source packages matching the query, on this
|
||||
/// page only
|
||||
#[serde(default)]
|
||||
entries: Vec<PublishedSource>,
|
||||
/// URL of the next page, present only when the collection was
|
||||
/// truncated (Launchpad answers carry it as a plain JSON string)
|
||||
next_collection_link: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse one page of a `getPublishedSources` collection into the versions
|
||||
/// it carries plus the link to the next page (`None` on the last one): the
|
||||
/// pagination decision, factored out of the HTTP walk so it can be tested
|
||||
/// without a server.
|
||||
fn parse_collection_page(body: &str) -> Result<(Vec<String>, Option<String>), serde_json::Error> {
|
||||
let sources: PublishedSources = serde_json::from_str(body)?;
|
||||
Ok((
|
||||
sources
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| entry.source_package_version)
|
||||
.collect(),
|
||||
sources.next_collection_link,
|
||||
))
|
||||
}
|
||||
|
||||
/// GET one page of a collection, mapping the API statuses to the same
|
||||
/// errors as the other Launchpad calls (404 means the PPA does not exist).
|
||||
/// Returns the response body for [`parse_collection_page`].
|
||||
async fn fetch_collection_page(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
ppa: &str,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::OK => response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("cannot read the Launchpad API response for '{ppa}': {e}").into()),
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
||||
}
|
||||
status => Err(format!("Launchpad API returned {status} for PPA '{ppa}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a `getPublishedSources` collection page by page: fetch the first
|
||||
/// page, then follow `next_collection_link` (the canonical Launchpad
|
||||
/// pagination) until a page comes without one, accumulating the versions of
|
||||
/// every page in order.
|
||||
///
|
||||
/// Exceeding [`MAX_COLLECTION_PAGES`] errors rather than returning the
|
||||
/// partial list: the result feeds `put`'s superseded-upload check, where a
|
||||
/// silently truncated list is exactly the bug pagination fixes — a
|
||||
/// superseded upload wrongly allowed through, to be rejected (or to
|
||||
/// silently supersede) in Launchpad's queue hours later. Every other
|
||||
/// failure mode of this check (network, HTTP status, parsing) aborts the
|
||||
/// upload too, and `put` fails before anything is written, so erring costs
|
||||
/// only a clear message.
|
||||
async fn walk_collection(
|
||||
client: &reqwest::Client,
|
||||
first_url: &str,
|
||||
ppa: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let mut versions = Vec::new();
|
||||
let mut url = first_url.to_string();
|
||||
for _page in 1..=MAX_COLLECTION_PAGES {
|
||||
let body = fetch_collection_page(client, &url, ppa).await?;
|
||||
let (mut page_versions, next) = parse_collection_page(&body)
|
||||
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
|
||||
versions.append(&mut page_versions);
|
||||
match next {
|
||||
Some(next) => url = next,
|
||||
None => return Ok(versions),
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"the Launchpad API keeps paginating the published sources of '{ppa}' \
|
||||
after {MAX_COLLECTION_PAGES} pages: cannot run the superseded check \
|
||||
on a partial list"
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
/// Every version of `source_name` currently `Published` in the PPA
|
||||
/// `user/name` (same `user/ppa_name` format as `pkh put --ppa`), in API
|
||||
/// order. Empty when the source was never published there — a 200 answer
|
||||
/// with zero entries, the normal first-upload case. Launchpad truncates
|
||||
/// collections per page, so the walk follows the API's `next_collection_link`
|
||||
/// until the collection is exhausted: a single page would miss the highest
|
||||
/// version of a source published in many series/pockets over time, and the
|
||||
/// superseded check would wrongly pass.
|
||||
pub async fn published_versions(
|
||||
ppa: &str,
|
||||
source_name: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
walk_collection(
|
||||
crate::distro_info::http_client(),
|
||||
&published_sources_url(&user, &name, source_name),
|
||||
ppa,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// PPA uploads only target Ubuntu series: fail before uploading when the
|
||||
/// changes' distribution is not a known series (typo) or a non-Ubuntu one —
|
||||
/// both are only rejected during queue processing otherwise
|
||||
pub async fn check_ppa_series(distribution: &str) -> Result<(), Box<dyn Error>> {
|
||||
match crate::distro_info::get_dist_from_series(distribution).await {
|
||||
Ok(dist) if dist == "ubuntu" => Ok(()),
|
||||
Ok(dist) => Err(format!(
|
||||
"series '{distribution}' belongs to {dist}: PPA uploads target \
|
||||
Ubuntu series only"
|
||||
)
|
||||
.into()),
|
||||
Err(_) => Err(format!(
|
||||
"'{distribution}' is not a known distribution series: check the \
|
||||
debian/changelog entry, the upload would be rejected"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ppa_target_expands_user_and_name() {
|
||||
let target = ppa_target("paultag/fluxbox").unwrap();
|
||||
assert_eq!(target.fqdn, "ppa.launchpad.net");
|
||||
assert_eq!(target.port, 22);
|
||||
assert_eq!(target.incoming, "~paultag/fluxbox");
|
||||
assert_eq!(target.label, "ppa:paultag/fluxbox");
|
||||
// No static login: the username is discovered per machine
|
||||
assert_eq!(target.login, None);
|
||||
}
|
||||
|
||||
/// The anonymous FTP fallback queue resolves from the same data the
|
||||
/// dput-ng `ppa:` profile uses.
|
||||
#[test]
|
||||
fn ppa_ftp_queue_resolves() {
|
||||
assert_eq!(ppa_ftp_queue(), ("ppa.launchpad.net".to_string(), 21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_missing_separator() {
|
||||
assert!(ppa_target("just-a-name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_extra_components() {
|
||||
assert!(ppa_target("user/ppa/extra").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_empty_components() {
|
||||
assert!(ppa_target("user/").is_err());
|
||||
assert!(ppa_target("/ppa").is_err());
|
||||
assert!(ppa_target("/").is_err());
|
||||
}
|
||||
|
||||
/// The `lp.user` key is read from the git configuration of the
|
||||
/// repository containing the working directory
|
||||
#[test]
|
||||
fn username_comes_from_repo_git_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
repo.config()
|
||||
.unwrap()
|
||||
.set_str(LP_USER_KEY, "vhaudiquet")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(username(dir.path()).as_deref(), Some("vhaudiquet"));
|
||||
}
|
||||
|
||||
/// Values are trimmed, and an empty value counts as unset (it must not
|
||||
/// shadow a real lookup failure with a useless username)
|
||||
#[test]
|
||||
fn username_ignores_blank_values() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
repo.config().unwrap().set_str(LP_USER_KEY, " ").unwrap();
|
||||
|
||||
// Blank local value: the resolution keeps looking (and finds
|
||||
// nothing here unless a global lp.user exists — the assertion
|
||||
// accepts either "no value" or a real global value, never the
|
||||
// blank one)
|
||||
let found = username(dir.path());
|
||||
assert_ne!(found.as_deref(), Some(" "));
|
||||
let _ = found;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_urls_match_launchpad_resources() {
|
||||
// Both URL shapes verified against the live API: 200 for an
|
||||
// existing account/archive, 404 for a missing one
|
||||
assert_eq!(
|
||||
person_url("vhaudiquet"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_url("vhaudiquet", "noctalia"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia"
|
||||
);
|
||||
}
|
||||
|
||||
/// The data-driven endpoint accessors build the same addresses the
|
||||
/// former hardcoded constants carried (each verified against the live
|
||||
/// service), and the content host is derived from the same template
|
||||
/// the content URLs are built from
|
||||
#[test]
|
||||
fn data_driven_endpoints_match_the_service() {
|
||||
assert_eq!(
|
||||
ppa_content_url("vhaudiquet", "noctalia"),
|
||||
"https://ppa.launchpadcontent.net/vhaudiquet/noctalia/ubuntu"
|
||||
);
|
||||
assert_eq!(ppa_content_host(), "ppa.launchpadcontent.net");
|
||||
assert_eq!(
|
||||
ubuntu_source_git_url("hello"),
|
||||
"https://git.launchpad.net/ubuntu/+source/hello"
|
||||
);
|
||||
}
|
||||
|
||||
/// The API answer carries many unrelated fields; deserialization must
|
||||
/// pick the relevant ones and tolerate a null `enabled`
|
||||
#[test]
|
||||
fn ppa_info_parses_api_response() {
|
||||
let json = r#"{
|
||||
"self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia",
|
||||
"web_link": "https://launchpad.net/~vhaudiquet/+archive/ubuntu/noctalia",
|
||||
"displayname": "Noctalia",
|
||||
"description": "Noctalia PPA with experimental builds",
|
||||
"enabled": null,
|
||||
"official_bug_tags": ["a11y", "appstream"]
|
||||
}"#;
|
||||
|
||||
let info: PpaInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.displayname, "Noctalia");
|
||||
assert_eq!(
|
||||
info.description.as_deref(),
|
||||
Some("Noctalia PPA with experimental builds")
|
||||
);
|
||||
assert_eq!(info.enabled, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encode_keeps_unreserved_and_escapes_the_rest() {
|
||||
// The characters of Debian source package names pass through
|
||||
assert_eq!(percent_encode("noctalia"), "noctalia");
|
||||
assert_eq!(percent_encode("libfoo-1.0"), "libfoo-1.0");
|
||||
// `+` must be escaped: in query values it would decode to a space
|
||||
assert_eq!(percent_encode("g++"), "g%2B%2B");
|
||||
assert_eq!(percent_encode("a b/c?d&e"), "a%20b%2Fc%3Fd%26e");
|
||||
}
|
||||
|
||||
/// The query matches the verified live `getPublishedSources` call, with
|
||||
/// the source name percent-encoded and an explicit page size (the API
|
||||
/// default of 75 entries would hide part of long publishing histories)
|
||||
#[test]
|
||||
fn published_sources_url_matches_launchpad_call() {
|
||||
assert_eq!(
|
||||
published_sources_url("vhaudiquet", "noctalia", "noctalia"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=noctalia&exact_match=true&status=Published&ws.size=100"
|
||||
);
|
||||
assert_eq!(
|
||||
published_sources_url("vhaudiquet", "noctalia", "g++"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=g%2B%2B&exact_match=true&status=Published&ws.size=100"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live answer carries many unrelated fields per entry; only
|
||||
/// `source_package_version` is needed (shape verified against the API)
|
||||
#[test]
|
||||
fn published_sources_parses_api_response() {
|
||||
let json = r#"{
|
||||
"start": 0,
|
||||
"total_size": 2,
|
||||
"entries": [
|
||||
{
|
||||
"self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia/+sourcepub/18737497",
|
||||
"resource_type_link": "https://api.launchpad.net/1.0/#source_package_publishing_history",
|
||||
"display_name": "noctalia 5.1.0-1ubuntu2 in stonking",
|
||||
"component_name": "main",
|
||||
"section_name": "x11",
|
||||
"status": "Published",
|
||||
"pocket": "Release",
|
||||
"date_published": "2026-09-16T19:36:46.116930+00:00",
|
||||
"scheduled_deletion_date": null,
|
||||
"source_package_name": "noctalia",
|
||||
"source_package_version": "5.1.0-1ubuntu2",
|
||||
"http_etag": "\"98f12b47\""
|
||||
},
|
||||
{"unknown_extra": {"nested": [1, 2]}, "source_package_version": "2:1.0-1"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let (versions, next) = parse_collection_page(json).unwrap();
|
||||
assert_eq!(versions, vec!["5.1.0-1ubuntu2", "2:1.0-1"]);
|
||||
// A page without a next link is the end of the collection
|
||||
assert_eq!(next, None);
|
||||
}
|
||||
|
||||
/// A 200 answer with zero entries is the normal "nothing published
|
||||
/// there" case, and must deserialize to an empty list
|
||||
#[test]
|
||||
fn published_sources_parses_empty_collection() {
|
||||
let (versions, next) =
|
||||
parse_collection_page(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap();
|
||||
assert!(versions.is_empty());
|
||||
assert_eq!(next, None);
|
||||
}
|
||||
|
||||
/// A truncated page announces the next one through
|
||||
/// `next_collection_link`, carried as a plain JSON string (shape
|
||||
/// verified against the live API)
|
||||
#[test]
|
||||
fn parse_collection_page_reads_next_link() {
|
||||
let json = r#"{
|
||||
"start": 0,
|
||||
"total_size": 150,
|
||||
"entries": [{"source_package_version": "1.0-1"}],
|
||||
"next_collection_link": "https://api.launchpad.net/1.0/~u/+archive/ubuntu/p?ws.op=getPublishedSources&ws.size=100&memo=100&ws.start=100"
|
||||
}"#;
|
||||
|
||||
let (versions, next) = parse_collection_page(json).unwrap();
|
||||
assert_eq!(versions, vec!["1.0-1"]);
|
||||
assert_eq!(
|
||||
next.as_deref(),
|
||||
Some(
|
||||
"https://api.launchpad.net/1.0/~u/+archive/ubuntu/p?ws.op=getPublishedSources&ws.size=100&memo=100&ws.start=100"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// Serve canned byte responses on a local port, one per connection (the
|
||||
/// last response repeats), and return the listener for URL building
|
||||
///
|
||||
/// The canned responses must use 'Connection: close' so the client opens
|
||||
/// a fresh connection (and receives a fresh response) per request.
|
||||
fn serve_responses(listener: std::net::TcpListener, responses: Vec<String>) {
|
||||
use std::io::{Read, Write};
|
||||
std::thread::spawn(move || {
|
||||
for (served, mut stream) in listener.incoming().flatten().enumerate() {
|
||||
let index = served.min(responses.len() - 1);
|
||||
// Drain the request first: closing with unread inbound data
|
||||
// would send a TCP RST and destroy the response in flight
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") => break,
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let body = &responses[index];
|
||||
let _ = stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One `getPublishedSources` page carrying `versions`, with the
|
||||
/// `next_collection_link` of a truncated page when `next` is given
|
||||
fn collection_body(versions: &[&str], next: Option<&str>) -> String {
|
||||
let entries: Vec<String> = versions
|
||||
.iter()
|
||||
.map(|v| format!(r#"{{"source_package_version": "{v}"}}"#))
|
||||
.collect();
|
||||
let next_field = next
|
||||
.map(|link| format!(r#", "next_collection_link": "{link}""#))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"{{"start": 0, "total_size": {}, "entries": [{}]{next_field}}}"#,
|
||||
versions.len(),
|
||||
entries.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Bind a fresh mock server ready to serve `responses` (the caller
|
||||
/// needs the address to build self-referential `next_collection_link`s
|
||||
/// before serving starts)
|
||||
fn bound_collection_server() -> (std::net::TcpListener, String) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
(listener, base)
|
||||
}
|
||||
|
||||
/// The collection walk follows `next_collection_link`: the versions of
|
||||
/// every page are collected in order, and the walk stops on the page
|
||||
/// without a next link (the mock repeats its last response forever, so
|
||||
/// an extra fetch would still pass — but a missing next-link handling
|
||||
/// would drop page two's versions from the result)
|
||||
#[tokio::test]
|
||||
async fn walk_collection_collects_every_page() {
|
||||
let (listener, base) = bound_collection_server();
|
||||
serve_responses(
|
||||
listener,
|
||||
vec![
|
||||
collection_body(&["1.0-1", "1.6-1"], Some(&format!("{base}/next"))),
|
||||
collection_body(&["0.9-1"], None),
|
||||
],
|
||||
);
|
||||
|
||||
let versions = walk_collection(
|
||||
crate::distro_info::http_client(),
|
||||
&format!("{base}/~u/+archive/ubuntu/p?ws.op=getPublishedSources"),
|
||||
"u/p",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(versions, vec!["1.0-1", "1.6-1", "0.9-1"]);
|
||||
}
|
||||
|
||||
/// A next-link chain that never ends must error, not loop forever: the
|
||||
/// partial list would feed the superseded check a false "not superseded"
|
||||
#[tokio::test]
|
||||
async fn walk_collection_errors_when_pagination_never_ends() {
|
||||
// The last (only) response repeats forever, each page linking back
|
||||
// to the server: the walk must stop at the page cap by itself
|
||||
let (listener, base) = bound_collection_server();
|
||||
serve_responses(
|
||||
listener,
|
||||
vec![collection_body(&["1.0-1"], Some(&format!("{base}/loop")))],
|
||||
);
|
||||
|
||||
let err = walk_collection(
|
||||
crate::distro_info::http_client(),
|
||||
&format!("{base}/~u/+archive/ubuntu/p?ws.op=getPublishedSources"),
|
||||
"u/p",
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("keeps paginating the published sources of 'u/p'"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -9,22 +9,55 @@ pub mod apt;
|
||||
pub mod build;
|
||||
/// Parse or edit a Debian changelog of a source package
|
||||
pub mod changelog;
|
||||
/// Embedding convention for static reference data (`data/*.yml`), applied
|
||||
/// by each owning module via the `embed_data!` macro
|
||||
pub(crate) mod data;
|
||||
/// Build a Debian package into a binary (.deb)
|
||||
pub mod deb;
|
||||
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
||||
/// changelog entries, artifact registries)
|
||||
pub mod debian;
|
||||
/// Obtain general information about distribution, series, etc
|
||||
pub mod distro_info;
|
||||
/// Passive interrupt state: the interrupted flag, the cleanup hook registry
|
||||
/// and the live view's reporter slot (the CLI owns the signal handling)
|
||||
pub mod interrupt;
|
||||
/// 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
|
||||
pub mod package_info;
|
||||
/// Prune residual pkh build artifacts and caches
|
||||
pub mod prune;
|
||||
/// Download a source package locally
|
||||
pub mod pull;
|
||||
/// Upload a built source package to a PPA (or archive)
|
||||
pub mod put;
|
||||
/// Handle package-specific quirks and workarounds
|
||||
pub mod quirks;
|
||||
|
||||
/// Line classifiers rewriting raw subprocess output into display actions
|
||||
/// and countable progress (pure logic, shared by build views)
|
||||
pub mod logfmt;
|
||||
|
||||
/// Reporting ports: environment-agnostic build observation ([`BuildView`])
|
||||
/// and question answering ([`Prompter`]), implemented by terminal views,
|
||||
/// server bridges or the inert [`Quiet`]
|
||||
pub mod report;
|
||||
|
||||
/// Terminal UI helpers (progress bars, live build views, prompts)
|
||||
pub mod ui;
|
||||
|
||||
/// Handle context for .deb building: locally, over ssh, in a chroot...
|
||||
pub mod context;
|
||||
|
||||
/// Quiet test runs: per-test log files, subprocess capture and failure
|
||||
/// matrix (inert passthrough outside test binaries)
|
||||
pub(crate) mod test_support;
|
||||
|
||||
/// Utility functions
|
||||
pub(crate) mod utils;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Target series (`--series`); unresolved when None.
|
||||
pub series: Option<String>,
|
||||
/// Git worktree state; None when the tree is not inside a git repository.
|
||||
pub git: Option<GitStatus>,
|
||||
}
|
||||
|
||||
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<GitStatus> {
|
||||
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 })
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<Finding>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Finding>,
|
||||
}
|
||||
|
||||
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<Finding>) -> 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<String>) {
|
||||
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<String> {
|
||||
let mut lines = vec![tag.description.to_string()];
|
||||
for reference in tag.references {
|
||||
lines.push(format!("Please refer to {}", reference));
|
||||
}
|
||||
lines
|
||||
}
|
||||
+186
@@ -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<output::Level>,
|
||||
/// 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<String>,
|
||||
/// Run only these native checks (`--check`, repeatable).
|
||||
pub only_checks: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// Target series, when known.
|
||||
pub series: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<LintReport, String> {
|
||||
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::<Result<_, _>>()?
|
||||
};
|
||||
|
||||
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<Finding>) -> Vec<Finding> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Rendering of a lint report (text and JSON) and the exit-code decision.
|
||||
//!
|
||||
//! Text output keeps lintian's line shape (`<L>: <pkg> <type>: <tag>
|
||||
//! <details>`) 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<ColorMode> {
|
||||
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<Level> {
|
||||
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<Vec<Level>, String> {
|
||||
let levels: Vec<Level> = spec
|
||||
.split(',')
|
||||
.filter(|word| !word.trim().is_empty())
|
||||
.map(Level::parse)
|
||||
.collect::<Option<_>>()
|
||||
.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<Level> {
|
||||
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 `<L>: <pkg> <type>: <tag>
|
||||
/// <details>` 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<serde_json::Value> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Finding>,
|
||||
/// Human-visible notes (artifact reuse, fresh packing) rendered as `N:`.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Option<WrapperOutcome>, 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<PathBuf, String> {
|
||||
// 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<PathBuf> {
|
||||
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
|
||||
/// (`../<source>_<version>.dsc`, pkh build's own naming), if it exists.
|
||||
fn expected_build_output(root: &Path) -> Option<PathBuf> {
|
||||
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<Finding> {
|
||||
let tag_line = Regex::new(r"^(?P<letter>[EWIPXOC]): (?P<rest>.*)$").expect("static regex");
|
||||
let subject = Regex::new(
|
||||
r"^(?P<pkg>\S+?)(?: (?P<ptype>source|binary|udeb|changes|buildinfo))?: (?P<tag>\S+)(?: (?P<details>.*))?$",
|
||||
)
|
||||
.expect("static regex");
|
||||
let note_line = Regex::new(r"^N:(?: (?P<text>.*))?$").expect("static regex");
|
||||
|
||||
let mut findings: Vec<Finding> = 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<PathBuf, String> {
|
||||
let mut entries: Vec<PathBuf> = 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<TempDir, String> {
|
||||
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 `<outer>/<name>` 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 <j@e.org> 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());
|
||||
}
|
||||
}
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
//! Line classifiers rewriting raw subprocess output for the live UI
|
||||
//!
|
||||
//! Each classifier is a small stateful machine fed every captured line of a
|
||||
//! build phase; it decides what to display (rewritten lines, warnings,
|
||||
//! errors) and whether the line carries countable progress. Classifiers are
|
||||
//! pure with respect to the UI: they only return [`Action`]s.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::context::Stream;
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum length of a rewritten line displayed in the rolling pane
|
||||
pub(crate) const MAX_LINE_WIDTH: usize = 120;
|
||||
|
||||
/// What a classifier decided to do with a captured line
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
/// Drop the line (noise)
|
||||
Hidden,
|
||||
/// Display a rewritten line in the rolling pane
|
||||
Shown(String),
|
||||
/// Display a warning line (yellow)
|
||||
Warning(String),
|
||||
/// Display an error line (red, sticky)
|
||||
Error(String),
|
||||
/// Update the determinate progress bar
|
||||
Progress {
|
||||
/// Current position
|
||||
pos: u64,
|
||||
/// Total number of items (0 = unknown)
|
||||
total: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stateful classifier turning raw subprocess lines into UI actions
|
||||
pub trait Classifier: Send {
|
||||
/// Feed one captured line, returning the actions it produces
|
||||
fn feed(&mut self, stream: Stream, line: &str) -> Vec<Action>;
|
||||
}
|
||||
|
||||
/// Truncate a line to [`MAX_LINE_WIDTH`], appending an ellipsis if cut
|
||||
pub(crate) fn truncate(line: &str) -> String {
|
||||
if line.chars().count() <= MAX_LINE_WIDTH {
|
||||
line.to_string()
|
||||
} else {
|
||||
let cut: String = line.chars().take(MAX_LINE_WIDTH - 1).collect();
|
||||
format!("{}…", cut.trim_end())
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify apt-style severity prefixes (`E:` / `W:`)
|
||||
fn apt_severity(line: &str) -> Option<Action> {
|
||||
if line.starts_with("E:") {
|
||||
Some(Action::Error(truncate(line)))
|
||||
} else if line.starts_with("W:") {
|
||||
Some(Action::Warning(truncate(line)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `apt-get update` output
|
||||
///
|
||||
/// Collapses `Get:/Hit:/Ign:` lines into a running source counter and always
|
||||
/// surfaces errors and warnings.
|
||||
#[derive(Default)]
|
||||
pub struct AptUpdateClassifier {
|
||||
sources: u64,
|
||||
}
|
||||
|
||||
impl AptUpdateClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for AptUpdateClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
if line.starts_with("Get:") || line.starts_with("Hit:") || line.starts_with("Ign:") {
|
||||
self.sources += 1;
|
||||
vec![Action::Shown(format!(
|
||||
"Updating package lists… ({} sources)",
|
||||
self.sources
|
||||
))]
|
||||
} else if let Some(severity) = apt_severity(line) {
|
||||
vec![severity]
|
||||
} else {
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `apt-get install` / `apt-get build-dep` output
|
||||
///
|
||||
/// Parses the upfront summary ("N upgraded, M newly installed, …") to derive
|
||||
/// a total, then counts `Unpacking`/`Setting up` lines to drive a determinate
|
||||
/// progress bar.
|
||||
#[derive(Default)]
|
||||
pub struct AptInstallClassifier {
|
||||
label: String,
|
||||
total: u64,
|
||||
done: u64,
|
||||
}
|
||||
|
||||
impl AptInstallClassifier {
|
||||
/// Create a classifier for an install phase labeled `label`
|
||||
pub fn new(label: &str) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(&self) -> Action {
|
||||
if self.total > 0 {
|
||||
Action::Progress {
|
||||
pos: self.done.min(self.total),
|
||||
total: self.total,
|
||||
}
|
||||
} else {
|
||||
Action::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for AptInstallClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
static SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
|
||||
let summary_re = SUMMARY_RE.get_or_init(|| {
|
||||
Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap()
|
||||
});
|
||||
static UNPACK_RE: OnceLock<Regex> = OnceLock::new();
|
||||
let unpack_re =
|
||||
UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap());
|
||||
static SETUP_RE: OnceLock<Regex> = OnceLock::new();
|
||||
let setup_re =
|
||||
SETUP_RE.get_or_init(|| Regex::new(r"^Setting up ([^ ]+) \(([^)]+)\)").unwrap());
|
||||
|
||||
if summary_re.is_match(line) && !self.label.is_empty() {
|
||||
// Only accept the summary once: later lines may repeat counts
|
||||
if self.total == 0 {
|
||||
let total: u64 = summary_re
|
||||
.captures_iter(line)
|
||||
.filter_map(|c| c[1].parse::<u64>().ok())
|
||||
.sum();
|
||||
self.total = total;
|
||||
vec![Action::Shown(format!("{}: {} packages", self.label, total))]
|
||||
} else {
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
} else if let Some(caps) = unpack_re.captures(line) {
|
||||
self.done += 1;
|
||||
vec![
|
||||
Action::Shown(format!(
|
||||
"{}: unpacking {} ({})",
|
||||
self.label, &caps[1], &caps[2]
|
||||
)),
|
||||
self.progress(),
|
||||
]
|
||||
} else if let Some(caps) = setup_re.captures(line) {
|
||||
self.done += 1;
|
||||
vec![
|
||||
Action::Shown(format!(
|
||||
"{}: setting up {} ({})",
|
||||
self.label, &caps[1], &caps[2]
|
||||
)),
|
||||
self.progress(),
|
||||
]
|
||||
} else if let Some(severity) = apt_severity(line) {
|
||||
vec![severity]
|
||||
} else {
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `quilt push -a` output
|
||||
///
|
||||
/// Driven by the number of patches listed in `debian/patches/series`, known
|
||||
/// before the command runs.
|
||||
pub struct QuiltClassifier {
|
||||
total: u64,
|
||||
applied: u64,
|
||||
}
|
||||
|
||||
impl QuiltClassifier {
|
||||
/// Create a classifier expecting `total` patches
|
||||
pub fn new(total: usize) -> Self {
|
||||
Self {
|
||||
total: total as u64,
|
||||
applied: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for QuiltClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
static APPLYING_RE: OnceLock<Regex> = OnceLock::new();
|
||||
let applying_re =
|
||||
APPLYING_RE.get_or_init(|| Regex::new(r"^Applying patch ([^ ]+)").unwrap());
|
||||
|
||||
if line.contains("failed") || line.contains("Failed") {
|
||||
// Check failures first: "Applying patch x failed" must not be
|
||||
// counted as a successful application
|
||||
vec![Action::Error(truncate(line))]
|
||||
} else if let Some(caps) = applying_re.captures(line) {
|
||||
self.applied += 1;
|
||||
let mut actions = vec![Action::Shown(format!("Applying patch {}", &caps[1]))];
|
||||
if self.total > 0 {
|
||||
actions.push(Action::Progress {
|
||||
pos: self.applied.min(self.total),
|
||||
total: self.total,
|
||||
});
|
||||
}
|
||||
actions
|
||||
} else if line.starts_with("Now at patch") {
|
||||
vec![Action::Shown(truncate(line))]
|
||||
} else {
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for make/cmake-based builds (`debian/rules build`, dh helpers)
|
||||
///
|
||||
/// Detects `[ 42%]`-style progress markers, hides directory enter/leave
|
||||
/// noise, and shows compile/link/dh lines.
|
||||
#[derive(Default)]
|
||||
pub struct MakeClassifier {}
|
||||
|
||||
impl MakeClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for MakeClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
static PERCENT_RE: OnceLock<Regex> = OnceLock::new();
|
||||
let percent_re = PERCENT_RE.get_or_init(|| Regex::new(r"\[\s*(\d+)%\]").unwrap());
|
||||
|
||||
if let Some(caps) = percent_re.captures(line) {
|
||||
let pct: u64 = caps[1].parse().unwrap_or(0);
|
||||
return vec![
|
||||
Action::Shown(truncate(line)),
|
||||
Action::Progress {
|
||||
pos: pct,
|
||||
total: 100,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if line.contains("make[")
|
||||
&& (line.contains("Entering directory") || line.contains("Leaving directory"))
|
||||
{
|
||||
return vec![Action::Hidden];
|
||||
}
|
||||
|
||||
if line.contains("error:")
|
||||
|| line.contains("Error ")
|
||||
|| line.contains("*** [")
|
||||
|| line.contains("failed")
|
||||
{
|
||||
return vec![Action::Error(truncate(line))];
|
||||
}
|
||||
|
||||
if line.starts_with("dh_")
|
||||
|| line.contains("gcc ")
|
||||
|| line.contains("g++ ")
|
||||
|| line.contains("cc ")
|
||||
|| line.contains("clang")
|
||||
|| line.contains("ld ")
|
||||
|| line.contains("ar ")
|
||||
{
|
||||
return vec![Action::Shown(truncate(line))];
|
||||
}
|
||||
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `dpkg-source` output (source-build phases)
|
||||
///
|
||||
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
|
||||
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
|
||||
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
|
||||
/// emitted while repacking tarballs are surfaced too.
|
||||
#[derive(Default)]
|
||||
pub struct DpkgSourceClassifier {}
|
||||
|
||||
impl DpkgSourceClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for DpkgSourceClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
const PREFIX: &str = "dpkg-source: ";
|
||||
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
|
||||
|
||||
if let Some(rest) = rest.strip_prefix("info: ") {
|
||||
vec![Action::Shown(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("warning: ") {
|
||||
vec![Action::Warning(truncate(rest))]
|
||||
} else if let Some(rest) = rest.strip_prefix("error: ") {
|
||||
vec![Action::Error(truncate(rest))]
|
||||
} else if let Some(tar) = rest.strip_prefix("tar: ") {
|
||||
// Diagnostics from the tarball repacking subprocess; warnings
|
||||
// about unknown header keywords are benign, real failures are not.
|
||||
let lower = tar.to_lowercase();
|
||||
if ["error", "cannot", "failed", "exited"]
|
||||
.iter()
|
||||
.any(|m| lower.contains(m))
|
||||
{
|
||||
vec![Action::Error(truncate(tar))]
|
||||
} else {
|
||||
vec![Action::Warning(truncate(tar))]
|
||||
}
|
||||
} else if line == PREFIX.trim_end() || rest.is_empty() {
|
||||
vec![Action::Hidden]
|
||||
} else {
|
||||
// Unprefixed output from a foreign subprocess: keep it visible
|
||||
vec![Action::Shown(truncate(line))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifier for `mmdebstrap` output (chroot tarball creation)
|
||||
///
|
||||
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
|
||||
/// else is chroot-internal apt/dpkg noise.
|
||||
#[derive(Default)]
|
||||
pub struct MmdebstrapClassifier {}
|
||||
|
||||
impl MmdebstrapClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for MmdebstrapClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
if let Some(rest) = line.strip_prefix("I: ") {
|
||||
vec![Action::Shown(truncate(rest))]
|
||||
} else if let Some(rest) = line.strip_prefix("W: ") {
|
||||
vec![Action::Warning(truncate(rest))]
|
||||
} else if let Some(rest) = line.strip_prefix("E: ") {
|
||||
vec![Action::Error(truncate(rest))]
|
||||
} else if line.starts_with("Setting up ") {
|
||||
vec![Action::Shown(truncate(line))]
|
||||
} else {
|
||||
vec![Action::Hidden]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic fallback classifier: shows the last meaningful line and surfaces
|
||||
/// obvious error/warning patterns.
|
||||
#[derive(Default)]
|
||||
pub struct GenericClassifier {}
|
||||
|
||||
impl GenericClassifier {
|
||||
/// Create a new classifier
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Classifier for GenericClassifier {
|
||||
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
|
||||
if line.starts_with("E:")
|
||||
|| line.contains("error:")
|
||||
|| line.contains("Error ")
|
||||
|| line.contains("failed")
|
||||
{
|
||||
vec![Action::Error(truncate(line))]
|
||||
} else if line.starts_with("W:") {
|
||||
vec![Action::Warning(truncate(line))]
|
||||
} else {
|
||||
vec![Action::Shown(truncate(line))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn feed_one(c: &mut dyn Classifier, line: &str) -> Vec<Action> {
|
||||
c.feed(Stream::Stdout, line)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apt_update_collapses_sources_and_surfaces_errors() {
|
||||
let mut c = AptUpdateClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Hit:1 http://archive.ubuntu.com noble InRelease"),
|
||||
vec![Action::Shown(
|
||||
"Updating package lists… (1 sources)".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"Get:2 http://security.ubuntu.com noble-security InRelease"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"Updating package lists… (2 sources)".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "E: Repository 'x' changed its 'suite' value"),
|
||||
vec![Action::Error(
|
||||
"E: Repository 'x' changed its 'suite' value".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Reading package lists..."),
|
||||
vec![Action::Hidden]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apt_install_counts_packages() {
|
||||
let mut c = AptInstallClassifier::new("Installing build dependencies");
|
||||
// Summary line sets the total
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"2 upgraded, 3 newly installed, 0 to remove and 0 not upgraded."
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"Installing build dependencies: 5 packages".to_string()
|
||||
)]
|
||||
);
|
||||
// Unpacking and setting up drive progress
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Unpacking libfoo (1.2-3)"),
|
||||
vec![
|
||||
Action::Shown(
|
||||
"Installing build dependencies: unpacking libfoo (1.2-3)".to_string()
|
||||
),
|
||||
Action::Progress { pos: 1, total: 5 }
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Setting up libfoo (1.2-3)"),
|
||||
vec![
|
||||
Action::Shown(
|
||||
"Installing build dependencies: setting up libfoo (1.2-3)".to_string()
|
||||
),
|
||||
Action::Progress { pos: 2, total: 5 }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quilt_counts_patches() {
|
||||
let mut c = QuiltClassifier::new(2);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Applying patch debian/patches/foo.patch"),
|
||||
vec![
|
||||
Action::Shown("Applying patch debian/patches/foo.patch".to_string()),
|
||||
Action::Progress { pos: 1, total: 2 }
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Applying patch debian/patches/bar.patch"),
|
||||
vec![
|
||||
Action::Shown("Applying patch debian/patches/bar.patch".to_string()),
|
||||
Action::Progress { pos: 2, total: 2 }
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Applying patch x failed"),
|
||||
vec![Action::Error("Applying patch x failed".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_detects_percent_and_hides_noise() {
|
||||
let mut c = MakeClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o"
|
||||
),
|
||||
vec![
|
||||
Action::Shown(
|
||||
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o".to_string()
|
||||
),
|
||||
Action::Progress {
|
||||
pos: 42,
|
||||
total: 100
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "make[2]: Entering directory '/tmp/build'"),
|
||||
vec![Action::Hidden]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "make[1]: *** [Makefile:531: hello.o] Error 1"),
|
||||
vec![Action::Error(
|
||||
"make[1]: *** [Makefile:531: hello.o] Error 1".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "dh_auto_build"),
|
||||
vec![Action::Shown("dh_auto_build".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mmdebstrap_prefixes() {
|
||||
let mut c = MmdebstrapClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "I: chroot architecture is amd64"),
|
||||
vec![Action::Shown("chroot architecture is amd64".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "W: some warning"),
|
||||
vec![Action::Warning("some warning".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "Get:1 http://x InRelease"),
|
||||
vec![Action::Hidden]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_shows_lines_and_errors() {
|
||||
let mut c = GenericClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "some random output"),
|
||||
vec![Action::Shown("some random output".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "something failed badly"),
|
||||
vec![Action::Error("something failed badly".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_severity_prefixes() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: using patch list from debian/patches/series"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"using patch list from debian/patches/series".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"applying patch debian/patches/reproducible.patch".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
|
||||
),
|
||||
vec![Action::Shown(
|
||||
"building hello in ../hello_2.10-5.dsc".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: warning: upstream signing key but no upstream signature"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"upstream signing key but no upstream signature".to_string()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"dpkg-source: error: unrepresentable changes to source"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"unrepresentable changes to source".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dpkg_source_tar_and_unknown_lines() {
|
||||
let mut c = DpkgSourceClassifier::new();
|
||||
// Benign tar header-keyword warnings stay yellow
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
|
||||
),
|
||||
vec![Action::Warning(
|
||||
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
|
||||
)]
|
||||
);
|
||||
// Real tar failures are errors
|
||||
assert_eq!(
|
||||
feed_one(
|
||||
&mut c,
|
||||
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
|
||||
),
|
||||
vec![Action::Error(
|
||||
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
|
||||
)]
|
||||
);
|
||||
// Unprefixed foreign output stays visible
|
||||
assert_eq!(
|
||||
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
|
||||
vec![Action::Shown(
|
||||
"gpgv: Signature made Tue 01 Jan 2026".to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_long_lines() {
|
||||
let long = "x".repeat(300);
|
||||
let truncated = truncate(&long);
|
||||
assert_eq!(truncated.chars().count(), MAX_LINE_WIDTH);
|
||||
assert!(truncated.ends_with('…'));
|
||||
assert_eq!(truncate("short"), "short");
|
||||
}
|
||||
}
|
||||
+784
-185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
//! Generators for the common `debian/` files of a scaffolded package, plus
|
||||
//! the orig tarball creation.
|
||||
//!
|
||||
//! Everything here renders in memory as [`OutputFile`]s; the caller writes
|
||||
//! them all-or-nothing after checking for collisions (see
|
||||
//! [`super::scaffold`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Datelike;
|
||||
use tar::Builder;
|
||||
use xz2::write::XzEncoder;
|
||||
|
||||
use super::options::{NewOptions, SourceFormat};
|
||||
use super::templates::{OutputFile, Template};
|
||||
|
||||
/// `3.0 (quilt)` source format, the default when packaging an existing
|
||||
/// project.
|
||||
pub const SOURCE_FORMAT_QUILT: &str = "3.0 (quilt)";
|
||||
/// `3.0 (native)` source format, the default for a fresh skeleton.
|
||||
pub const SOURCE_FORMAT_NATIVE: &str = "3.0 (native)";
|
||||
|
||||
/// The three source formats pkh knows how to build.
|
||||
pub const KNOWN_SOURCE_FORMATS: [&str; 3] = [SOURCE_FORMAT_QUILT, SOURCE_FORMAT_NATIVE, "1.0"];
|
||||
|
||||
/// Current Debian Policy version, written as the `Standards-Version` of the
|
||||
/// generated `debian/control` (mandatory in the source stanza per policy).
|
||||
/// Bump as policy evolves.
|
||||
pub const DEBIAN_POLICY_VERSION: &str = "4.7.4";
|
||||
|
||||
/// Directory and file names excluded from the orig tarball, at any depth of
|
||||
/// the tree.
|
||||
const ORIG_EXCLUDE: &[&str] = &[
|
||||
".git",
|
||||
"debian",
|
||||
"target",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
];
|
||||
|
||||
/// Path of the orig tarball for `name`/`upstream_version` next to `tree`.
|
||||
pub fn orig_tarball_path(
|
||||
tree: &Path,
|
||||
name: &str,
|
||||
upstream_version: &str,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
tree.parent()
|
||||
.map(|parent| parent.join(format!("{name}_{upstream_version}.orig.tar.xz")))
|
||||
}
|
||||
|
||||
/// Render every common `debian/` file of the package.
|
||||
pub fn files(opts: &NewOptions, template: &Template) -> Vec<OutputFile> {
|
||||
let mut files = vec![
|
||||
source_format(opts),
|
||||
changelog(opts),
|
||||
control(opts, template),
|
||||
rules(opts, template),
|
||||
copyright(opts),
|
||||
debian_gitignore(opts),
|
||||
];
|
||||
if opts.source_format == SourceFormat::Quilt {
|
||||
files.push(local_options());
|
||||
}
|
||||
if opts.autopkgtest {
|
||||
files.push(autopkgtest_control());
|
||||
files.push(autopkgtest_smoke(opts));
|
||||
}
|
||||
if let Some(watch) = &opts.watch {
|
||||
files.push(OutputFile::new("debian/watch", watch.clone()));
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// `debian/tests/control`: the autopkgtest smoke test definition.
|
||||
fn autopkgtest_control() -> OutputFile {
|
||||
OutputFile::new(
|
||||
"debian/tests/control",
|
||||
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n",
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/tests/smoke`: run the installed command once; `--help` first,
|
||||
/// `--version` as the fallback (some tools only answer one of them).
|
||||
fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
|
||||
OutputFile::executable(
|
||||
"debian/tests/smoke",
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
set -e\n\
|
||||
{command} --help >/dev/null 2>&1 || {command} --version\n",
|
||||
command = opts.command,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/source/format`: `3.0 (native)` for a skeleton by default,
|
||||
/// `3.0 (quilt)` for an existing project; either can be forced with
|
||||
/// `--native` / `--quilt`.
|
||||
fn source_format(opts: &NewOptions) -> OutputFile {
|
||||
OutputFile::new(
|
||||
"debian/source/format",
|
||||
format!("{}\n", opts.source_format.deb_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/source/local-options` with `single-debian-patch`, so later
|
||||
/// upstream-tree edits stay representable as one `debian/patches/debian-changes-*`
|
||||
/// patch instead of failing the build (quilt only). Common build-output
|
||||
/// directories are excluded from the delta as well: compiling locally before
|
||||
/// a source build must not turn `target/`, `node_modules/` or `.venv/`
|
||||
/// binaries into unrepresentable changes (dpkg ignores `__pycache__` and
|
||||
/// friends by default, but not those).
|
||||
fn local_options() -> OutputFile {
|
||||
OutputFile::new(
|
||||
"debian/source/local-options",
|
||||
"single-debian-patch\n\
|
||||
extend-diff-ignore = ^target/\n\
|
||||
extend-diff-ignore = ^node_modules/\n\
|
||||
extend-diff-ignore = ^\\.venv/\n",
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/changelog`: the single initial entry, distribution UNRELEASED by
|
||||
/// default (the dh_make convention: a fresh package is by definition not
|
||||
/// ready for upload, and pkh skips signing for UNRELEASED), or the target
|
||||
/// series with `--release`.
|
||||
fn changelog(opts: &NewOptions) -> OutputFile {
|
||||
let distribution = if opts.release {
|
||||
opts.series.as_str()
|
||||
} else {
|
||||
crate::distro_info::UNRELEASED
|
||||
};
|
||||
let date = chrono::Local::now().format("%a, %d %b %Y %H:%M:%S %z");
|
||||
OutputFile::new(
|
||||
"debian/changelog",
|
||||
format!(
|
||||
"{name} ({version}) {distribution}; urgency=medium\n\
|
||||
\n\
|
||||
\x20 * Initial release.\n\
|
||||
\n\
|
||||
\x20-- {maintainer_name} <{maintainer_email}> {date}\n",
|
||||
name = opts.name,
|
||||
version = opts.full_version(),
|
||||
distribution = distribution,
|
||||
maintainer_name = opts.maintainer.0,
|
||||
maintainer_email = opts.maintainer.1,
|
||||
date = date,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a field whose values continue one per line (RFC822 continuation,
|
||||
/// one leading space, commas between values, first value on the field line):
|
||||
///
|
||||
/// ```text
|
||||
/// Build-Depends: debhelper-compat (= 13),
|
||||
/// python3-all
|
||||
/// ```
|
||||
fn render_field(name: &str, values: &[String]) -> String {
|
||||
let last = values.len() - 1;
|
||||
let mut out = format!("{}: {}", name, values[0]);
|
||||
if last > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('\n');
|
||||
for (i, value) in values.iter().enumerate().skip(1) {
|
||||
out.push_str(&format!(" {value}"));
|
||||
if i != last {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a free-text field body (long description, license paragraphs):
|
||||
/// every line as a continuation, blank lines as ` .` (the deb822 encoding).
|
||||
fn render_continuation_text(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for line in text.lines() {
|
||||
if line.trim().is_empty() {
|
||||
out.push_str(" .\n");
|
||||
} else {
|
||||
out.push_str(&format!(" {line}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `debian/control`: one source stanza plus one binary stanza.
|
||||
///
|
||||
/// The binary package name is the source package name, the architecture
|
||||
/// comes from the template (`all` for shell/empty), and a non-empty
|
||||
/// `opts.depends` (the empty/metapackage flavor) lands in the binary
|
||||
/// stanza's `Depends` field.
|
||||
fn control(opts: &NewOptions, template: &Template) -> OutputFile {
|
||||
let mut control = String::new();
|
||||
|
||||
// Source stanza.
|
||||
control.push_str(&format!("Source: {}\n", opts.name));
|
||||
control.push_str("Section: utils\n");
|
||||
control.push_str("Priority: optional\n");
|
||||
control.push_str(&format!(
|
||||
"Maintainer: {} <{}>\n",
|
||||
opts.maintainer.0, opts.maintainer.1
|
||||
));
|
||||
control.push_str("Rules-Requires-Root: no\n");
|
||||
control.push_str(&format!("Standards-Version: {DEBIAN_POLICY_VERSION}\n"));
|
||||
|
||||
let mut build_depends = vec!["debhelper-compat (= 13)".to_string()];
|
||||
build_depends.extend(template.build_depends(opts));
|
||||
control.push_str(&render_field("Build-Depends", &build_depends));
|
||||
|
||||
for (key, value) in template.source_fields(opts) {
|
||||
control.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
|
||||
if let Some(homepage) = &opts.homepage {
|
||||
control.push_str(&format!("Homepage: {homepage}\n"));
|
||||
}
|
||||
|
||||
control.push('\n');
|
||||
|
||||
// Binary stanza.
|
||||
control.push_str(&format!("Package: {}\n", opts.name));
|
||||
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
|
||||
if !opts.depends.is_empty() {
|
||||
control.push_str(&render_field("Depends", &opts.depends));
|
||||
}
|
||||
control.push_str(&format!("Description: {}\n", opts.summary));
|
||||
control.push_str(&render_continuation_text(&opts.long_description));
|
||||
|
||||
OutputFile::new("debian/control", control)
|
||||
}
|
||||
|
||||
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
||||
/// template's dh line (plus the template's extra overrides, when any),
|
||||
/// written with the executable bit.
|
||||
fn rules(opts: &NewOptions, template: &Template) -> OutputFile {
|
||||
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
||||
let extra = template.rules_extra(opts);
|
||||
if !extra.is_empty() {
|
||||
contents.push('\n');
|
||||
contents.push_str(&extra);
|
||||
if !contents.ends_with('\n') {
|
||||
contents.push('\n');
|
||||
}
|
||||
}
|
||||
OutputFile::executable("debian/rules", contents)
|
||||
}
|
||||
|
||||
/// Short license-reference paragraph embedded in `debian/copyright`.
|
||||
fn license_reference_paragraph(license: &super::options::License) -> String {
|
||||
use super::options::License;
|
||||
match license {
|
||||
License::Custom(s) if s.eq_ignore_ascii_case("unknown") => {
|
||||
"The licensing terms of this package are not known yet. \
|
||||
Replace this paragraph with a proper license reference."
|
||||
.to_string()
|
||||
}
|
||||
License::Custom(s) => format!(
|
||||
"The package is distributed under the terms of the '{s}' license. \
|
||||
Replace this paragraph with the full license reference."
|
||||
),
|
||||
known => format!(
|
||||
"The package is distributed under the terms of the {} license. \
|
||||
The full license text is available at <{}>.",
|
||||
known.spdx(),
|
||||
known.spdx_url()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `debian/copyright` in the DEP-5 machine-readable format: header, the
|
||||
/// `Files: *` stanza covering the current year, and a standalone license
|
||||
/// stanza with a short reference paragraph.
|
||||
fn copyright(opts: &NewOptions) -> OutputFile {
|
||||
let year = chrono::Local::now().year();
|
||||
let mut out = String::new();
|
||||
|
||||
out.push_str("Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n");
|
||||
out.push_str(&format!("Upstream-Name: {}\n", opts.name));
|
||||
if let Some(homepage) = &opts.homepage {
|
||||
out.push_str(&format!("Source: {homepage}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
out.push_str("Files: *\n");
|
||||
out.push_str(&format!(
|
||||
"Copyright: {} {} <{}>\n",
|
||||
year, opts.maintainer.0, opts.maintainer.1
|
||||
));
|
||||
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
||||
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
||||
&opts.license,
|
||||
)));
|
||||
out.push('\n');
|
||||
|
||||
out.push_str(&format!("License: {}\n", opts.license.spdx()));
|
||||
out.push_str(&render_continuation_text(&license_reference_paragraph(
|
||||
&opts.license,
|
||||
)));
|
||||
|
||||
OutputFile::new("debian/copyright", out)
|
||||
}
|
||||
|
||||
/// `debian/.gitignore`: the debhelper build artifacts. The patterns are
|
||||
/// relative to `debian/` itself (a `debian/`-prefixed pattern would be
|
||||
/// anchored to `debian/debian/` inside this file, per gitignore(5)).
|
||||
fn debian_gitignore(opts: &NewOptions) -> OutputFile {
|
||||
OutputFile::new(
|
||||
"debian/.gitignore",
|
||||
format!(
|
||||
"files\n\
|
||||
.debhelper/\n\
|
||||
*.log\n\
|
||||
{}/\n\
|
||||
debhelper-build-stamp\n\
|
||||
*.substvars\n",
|
||||
opts.name
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Entries of the root `.gitignore` written in skeleton mode (build
|
||||
/// artifacts, next to the tree).
|
||||
pub const ROOT_GITIGNORE_ENTRIES: [&str; 6] = [
|
||||
"*.deb",
|
||||
"*.dsc",
|
||||
"*.changes",
|
||||
"*.buildinfo",
|
||||
"*.tar.xz",
|
||||
"target/",
|
||||
];
|
||||
|
||||
/// Comment heading a root `.gitignore` freshly created by pkh (the skeleton
|
||||
/// build-artifact section).
|
||||
pub const ROOT_GITIGNORE_HEADER: &str = "# pkh build artifacts";
|
||||
|
||||
/// Merge `entries` into the root `.gitignore` contents `existing` (the
|
||||
/// current file contents, when there is one): missing entries are appended,
|
||||
/// an existing file is never overwritten just to duplicate entries. A fresh
|
||||
/// file is headed by the `header` comment when one is given; appending to a
|
||||
/// user file adds bare entries. Returns the new contents, or `None` when
|
||||
/// nothing has to be written.
|
||||
pub fn merge_gitignore_entries(
|
||||
existing: Option<&str>,
|
||||
entries: &[&str],
|
||||
header: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let have: HashSet<&str> = existing
|
||||
.map(|content| content.lines().map(str::trim).collect())
|
||||
.unwrap_or_default();
|
||||
let missing: Vec<&str> = entries
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entry| !have.contains(entry))
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = existing.unwrap_or("").to_string();
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
// Section comment only for a fresh file; appending to a user file adds
|
||||
// bare entries.
|
||||
if existing.is_none()
|
||||
&& let Some(header) = header
|
||||
{
|
||||
out.push_str(header);
|
||||
out.push('\n');
|
||||
}
|
||||
for entry in missing {
|
||||
out.push_str(entry);
|
||||
out.push('\n');
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Create `../<name>_<upstream_version>.orig.tar.xz` containing the tree,
|
||||
/// excluding `debian/` and VCS/build directories, so the first
|
||||
/// `dpkg-source -b` (quilt) succeeds immediately. Refuses to overwrite an
|
||||
/// existing tarball.
|
||||
pub fn create_orig_tarball(
|
||||
tree: &Path,
|
||||
name: &str,
|
||||
upstream_version: &str,
|
||||
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
||||
create_orig_tarball_excluding(tree, name, upstream_version, false)
|
||||
}
|
||||
|
||||
/// [`create_orig_tarball`] with the generated `vendor/` directory of a
|
||||
/// vendored rust package excluded from the snapshot: its contents travel in
|
||||
/// the separate `<name>_<uver>.orig-vendor.tar.xz` component instead (see
|
||||
/// [`super::orig`]), so they can be regenerated independently of the
|
||||
/// upstream sources.
|
||||
pub fn create_orig_tarball_excluding(
|
||||
tree: &Path,
|
||||
name: &str,
|
||||
upstream_version: &str,
|
||||
exclude_vendor: bool,
|
||||
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
|
||||
let tarball_path = orig_tarball_path(tree, name, upstream_version).ok_or_else(|| {
|
||||
format!(
|
||||
"cannot determine the parent directory of '{}'",
|
||||
tree.display()
|
||||
)
|
||||
})?;
|
||||
if tarball_path.exists() {
|
||||
return Err(format!(
|
||||
"'{}' already exists: pkh new refuses to overwrite it. \
|
||||
Remove it first, or pass --native to skip the orig tarball.",
|
||||
tarball_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let file = std::fs::File::create(&tarball_path)?;
|
||||
let encoder = XzEncoder::new(file, 6);
|
||||
let mut builder = Builder::new(encoder);
|
||||
|
||||
// Deterministic-ish ordering: sort entries by name at every level.
|
||||
let prefix = format!("{name}-{upstream_version}");
|
||||
// The single top-level directory dpkg-source expects.
|
||||
builder.append_dir(&prefix, tree)?;
|
||||
let top_excludes: &[&str] = if exclude_vendor { &["vendor"] } else { &[] };
|
||||
append_tree(&mut builder, tree, &prefix, 0, ORIG_EXCLUDE, top_excludes)?;
|
||||
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("failed to write '{}': {}", tarball_path.display(), e))?;
|
||||
|
||||
log::info!(
|
||||
"Created orig tarball {}",
|
||||
crate::report::display_path(&tarball_path)
|
||||
);
|
||||
Ok(tarball_path)
|
||||
}
|
||||
|
||||
/// Recursively append `dir` to the archive under `archive_path`, skipping
|
||||
/// non-regular files, the names of `excludes` at any depth, the `debian/`
|
||||
/// directory and the names of `top_excludes` at the top level (depth 0).
|
||||
pub(crate) fn append_tree(
|
||||
builder: &mut Builder<XzEncoder<std::fs::File>>,
|
||||
dir: &Path,
|
||||
archive_path: &str,
|
||||
depth: usize,
|
||||
excludes: &[&str],
|
||||
top_excludes: &[&str],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)?.collect::<Result<_, _>>()?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy().into_owned();
|
||||
|
||||
if depth == 0 && (name == "debian" || top_excludes.contains(&name.as_str())) {
|
||||
continue;
|
||||
}
|
||||
if excludes.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_archive_path = format!("{archive_path}/{name}");
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?;
|
||||
if metadata.is_dir() {
|
||||
builder.append_dir(&entry_archive_path, &path)?;
|
||||
append_tree(
|
||||
builder,
|
||||
&path,
|
||||
&entry_archive_path,
|
||||
depth + 1,
|
||||
excludes,
|
||||
top_excludes,
|
||||
)?;
|
||||
} else if metadata.is_file() {
|
||||
// The mode (including the exec bit) travels through the header.
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_metadata(&metadata);
|
||||
header.set_size(metadata.len());
|
||||
let file = std::fs::File::open(&path)
|
||||
.map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
|
||||
builder
|
||||
.append_data(&mut header, &entry_archive_path, file)
|
||||
.map_err(|e| format!("cannot add '{}' to the tarball: {}", path.display(), e))?;
|
||||
} else {
|
||||
// Sockets, fifos, devices have no business in an orig tarball.
|
||||
log::warn!(
|
||||
"Skipping non-regular file '{}' while creating the orig tarball",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an in-memory file list to `tree`, creating parent directories and
|
||||
/// applying the executable bit. Callers must have checked collisions first.
|
||||
pub(crate) fn write_files(
|
||||
tree: &Path,
|
||||
files: &[OutputFile],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for file in files {
|
||||
let path = tree.join(&file.path);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, &file.contents)?;
|
||||
if file.executable {
|
||||
let mut permissions = std::fs::metadata(&path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions)?;
|
||||
}
|
||||
log::debug!("Wrote {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir, TemplateId};
|
||||
|
||||
fn opts() -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::SHELL,
|
||||
source_dir: SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool that does one thing well".into(),
|
||||
long_description: "A tool that does one thing well".into(),
|
||||
homepage: Some("https://example.com/mytool".into()),
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane Doe".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
source_format: SourceFormat::Quilt,
|
||||
orig: None,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_format_and_local_options() {
|
||||
let o = opts();
|
||||
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||
let find = |path: &str| {
|
||||
files
|
||||
.iter()
|
||||
.find(|f| f.path == path)
|
||||
.unwrap_or_else(|| panic!("{path} missing"))
|
||||
};
|
||||
|
||||
assert_eq!(find("debian/source/format").contents, "3.0 (quilt)\n");
|
||||
assert_eq!(
|
||||
find("debian/source/local-options").contents,
|
||||
"single-debian-patch\n\
|
||||
extend-diff-ignore = ^target/\n\
|
||||
extend-diff-ignore = ^node_modules/\n\
|
||||
extend-diff-ignore = ^\\.venv/\n"
|
||||
);
|
||||
|
||||
let native = NewOptions {
|
||||
source_format: SourceFormat::Native,
|
||||
..opts()
|
||||
};
|
||||
let files = super::files(
|
||||
&native,
|
||||
crate::new::templates::get(TemplateId::SHELL).unwrap(),
|
||||
);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|f| f.path != "debian/source/local-options")
|
||||
);
|
||||
assert_eq!(
|
||||
files
|
||||
.iter()
|
||||
.find(|f| f.path == "debian/source/format")
|
||||
.unwrap()
|
||||
.contents,
|
||||
"3.0 (native)\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_rendering_and_parse() {
|
||||
let o = opts();
|
||||
let changelog = super::changelog(&o);
|
||||
assert_eq!(changelog.path, "debian/changelog");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("changelog");
|
||||
std::fs::write(&path, &changelog.contents).unwrap();
|
||||
let (source, version, distribution) =
|
||||
crate::changelog::parse_changelog_header(&path).unwrap();
|
||||
assert_eq!(source, "mytool");
|
||||
assert_eq!(version, "0.1.0-1");
|
||||
assert_eq!(distribution, "UNRELEASED");
|
||||
// dpkg-style zero-padded RFC2822 date in the trailer.
|
||||
assert!(
|
||||
changelog
|
||||
.contents
|
||||
.contains(" -- Jane Doe <jane@example.com> ")
|
||||
);
|
||||
let date_line = changelog
|
||||
.contents
|
||||
.lines()
|
||||
.find(|l| l.starts_with(" -- "))
|
||||
.unwrap();
|
||||
let date = date_line.rsplit_once(" ").unwrap().1;
|
||||
// `%d` is zero-padded: positions 5-6 must be the two-digit day
|
||||
// (e.g. "Tue, 05 Sep 2026 ...").
|
||||
assert!(date[5..7].bytes().all(|b| b.is_ascii_digit()));
|
||||
|
||||
// --release writes the target series.
|
||||
let released = NewOptions {
|
||||
release: true,
|
||||
..opts()
|
||||
};
|
||||
std::fs::write(&path, super::changelog(&released).contents).unwrap();
|
||||
let (_, _, distribution) = crate::changelog::parse_changelog_header(&path).unwrap();
|
||||
assert_eq!(distribution, "resolute");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_rendering_and_parse() {
|
||||
let o = opts();
|
||||
let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||
|
||||
// RFC822 continuation: first dep on the field line, the rest indented.
|
||||
assert!(
|
||||
control
|
||||
.contents
|
||||
.contains("Build-Depends: debhelper-compat (= 13)\n")
|
||||
);
|
||||
|
||||
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
||||
assert_eq!(parsed.source_name(), "mytool");
|
||||
assert_eq!(parsed.source.get("Section"), Some("utils"));
|
||||
assert_eq!(parsed.source.get("Priority"), Some("optional"));
|
||||
assert_eq!(parsed.source.get("Rules-Requires-Root"), Some("no"));
|
||||
assert_eq!(
|
||||
parsed.source.get("Standards-Version"),
|
||||
Some(DEBIAN_POLICY_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.source.get("Homepage"),
|
||||
Some("https://example.com/mytool")
|
||||
);
|
||||
assert_eq!(parsed.binaries.len(), 1);
|
||||
assert_eq!(parsed.binaries[0].get("Package"), Some("mytool"));
|
||||
assert_eq!(parsed.binaries[0].get("Architecture"), Some("all"));
|
||||
assert_eq!(
|
||||
parsed.binaries[0].get("Description"),
|
||||
Some("A tool that does one thing well\nA tool that does one thing well")
|
||||
);
|
||||
|
||||
// Without homepage both the control Homepage field and the DEP-5
|
||||
// Source field are absent (the stanza's leading `Source:` line is
|
||||
// still there of course).
|
||||
let o = NewOptions {
|
||||
homepage: None,
|
||||
..opts()
|
||||
};
|
||||
let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||
assert!(!control.contents.contains("Homepage:"));
|
||||
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
||||
assert!(parsed.source.get("Homepage").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_is_executable_minimal_makefile() {
|
||||
let o = opts();
|
||||
let rules = super::rules(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||
assert!(rules.executable);
|
||||
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_files_autopkgtest_and_watch() {
|
||||
let mut o = opts();
|
||||
o.autopkgtest = true;
|
||||
o.watch = Some(
|
||||
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
.to_string(),
|
||||
);
|
||||
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||
let find = |path: &str| {
|
||||
files
|
||||
.iter()
|
||||
.find(|f| f.path == path)
|
||||
.unwrap_or_else(|| panic!("{path} missing"))
|
||||
};
|
||||
|
||||
let control = find("debian/tests/control");
|
||||
assert_eq!(
|
||||
control.contents,
|
||||
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n"
|
||||
);
|
||||
let smoke = find("debian/tests/smoke");
|
||||
assert!(smoke.executable);
|
||||
assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n"));
|
||||
assert!(
|
||||
smoke
|
||||
.contents
|
||||
.contains("mytool --help >/dev/null 2>&1 || mytool --version")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find("debian/watch").contents,
|
||||
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
);
|
||||
|
||||
// Without the extras none of the files are rendered.
|
||||
let plain = super::files(
|
||||
&opts(),
|
||||
crate::new::templates::get(TemplateId::SHELL).unwrap(),
|
||||
);
|
||||
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
||||
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copyright_is_dep5() {
|
||||
let c = super::copyright(&opts());
|
||||
assert!(c.contents.starts_with(
|
||||
"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"
|
||||
));
|
||||
assert!(c.contents.contains("Upstream-Name: mytool\n"));
|
||||
assert!(c.contents.contains("Source: https://example.com/mytool\n"));
|
||||
assert!(c.contents.contains("Files: *\n"));
|
||||
assert!(c.contents.contains("License: MIT\n"));
|
||||
assert!(c.contents.contains(&format!(
|
||||
"Copyright: {} Jane Doe <jane@example.com>\n",
|
||||
chrono::Local::now().year()
|
||||
)));
|
||||
assert!(c.contents.contains("https://spdx.org/licenses/MIT.html"));
|
||||
|
||||
// Unknown license: honest reference paragraph, still valid deb822.
|
||||
let o = NewOptions {
|
||||
license: License::Custom("unknown".into()),
|
||||
..opts()
|
||||
};
|
||||
let c = super::copyright(&o);
|
||||
assert!(c.contents.contains("not known yet"));
|
||||
assert!(crate::debian::parse_paragraphs(&c.contents).len() >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_gitignore_contents() {
|
||||
let g = super::debian_gitignore(&opts());
|
||||
assert_eq!(
|
||||
g.contents,
|
||||
"files\n.debhelper/\n*.log\nmytool/\n\
|
||||
debhelper-build-stamp\n*.substvars\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitignore_merge() {
|
||||
// Fresh file: header + all entries.
|
||||
let fresh =
|
||||
merge_gitignore_entries(None, &ROOT_GITIGNORE_ENTRIES, Some(ROOT_GITIGNORE_HEADER))
|
||||
.unwrap();
|
||||
assert!(fresh.starts_with("# pkh build artifacts\n"));
|
||||
for entry in ROOT_GITIGNORE_ENTRIES {
|
||||
assert!(fresh.contains(entry), "{entry} missing");
|
||||
}
|
||||
|
||||
// A fresh file without a header carries the bare entries.
|
||||
assert_eq!(
|
||||
merge_gitignore_entries(None, &["a/", "b"], None).unwrap(),
|
||||
"a/\nb\n"
|
||||
);
|
||||
|
||||
// Existing file: only the missing entries are appended, nothing lost.
|
||||
let existing = "*.deb\nnode_modules/\n";
|
||||
let merged = merge_gitignore_entries(
|
||||
Some(existing),
|
||||
&ROOT_GITIGNORE_ENTRIES,
|
||||
Some(ROOT_GITIGNORE_HEADER),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(merged.starts_with(existing));
|
||||
assert!(merged.contains("*.dsc\n"));
|
||||
assert!(!merged.contains("*.deb\n*.deb"));
|
||||
|
||||
// Everything already there: nothing to write.
|
||||
let full: String = ROOT_GITIGNORE_ENTRIES
|
||||
.iter()
|
||||
.map(|e| format!("{e}\n"))
|
||||
.collect();
|
||||
assert!(
|
||||
merge_gitignore_entries(
|
||||
Some(&full),
|
||||
&ROOT_GITIGNORE_ENTRIES,
|
||||
Some(ROOT_GITIGNORE_HEADER),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The vendoring entries of the rust template merge into an existing
|
||||
/// user `.gitignore` like any other entry set: appended after the
|
||||
/// user's lines, no header comment, idempotent.
|
||||
#[test]
|
||||
fn gitignore_merge_appends_template_entries() {
|
||||
let entries = ["vendor/", ".cargo/config.toml"];
|
||||
let merged =
|
||||
merge_gitignore_entries(Some("# my project\n*.log\n"), &entries, None).unwrap();
|
||||
assert_eq!(merged, "# my project\n*.log\nvendor/\n.cargo/config.toml\n");
|
||||
|
||||
// Already ignored: nothing to write.
|
||||
assert!(
|
||||
merge_gitignore_entries(Some("vendor/\n.cargo/config.toml\n"), &entries, None)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orig_tarball_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tree = dir.path().join("mytool");
|
||||
std::fs::create_dir_all(tree.join("debian")).unwrap();
|
||||
std::fs::create_dir_all(tree.join("target")).unwrap();
|
||||
std::fs::create_dir_all(tree.join("src/nested")).unwrap();
|
||||
std::fs::write(tree.join("debian/control"), "control").unwrap();
|
||||
std::fs::write(tree.join("target/artifact"), "junk").unwrap();
|
||||
std::fs::write(tree.join("src/nested/code.txt"), "code").unwrap();
|
||||
|
||||
let tarball = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap();
|
||||
assert_eq!(tarball, dir.path().join("mytool_0.1.0.orig.tar.xz"));
|
||||
assert!(tarball.exists());
|
||||
|
||||
let file = std::fs::File::open(&tarball).unwrap();
|
||||
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(file));
|
||||
let mut names: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
// The tree prefix and the nested file are there...
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.any(|n| n.trim_end_matches('/') == "mytool-0.1.0")
|
||||
);
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.any(|n| n == "mytool-0.1.0/src/nested/code.txt")
|
||||
);
|
||||
// ...but debian/, target/ and other excluded names are not.
|
||||
assert!(!names.iter().any(|n| n.contains("debian")));
|
||||
assert!(!names.iter().any(|n| n.contains("target")));
|
||||
names.sort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orig_tarball_refuses_overwrite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tree = dir.path().join("mytool");
|
||||
std::fs::create_dir_all(&tree).unwrap();
|
||||
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"existing").unwrap();
|
||||
|
||||
let err = create_orig_tarball(&tree, "mytool", "0.1.0").unwrap_err();
|
||||
assert!(err.to_string().contains("already exists"));
|
||||
}
|
||||
|
||||
/// The vendored-rust variant excludes the top-level `vendor/` (it
|
||||
/// travels in the orig-vendor component) but keeps unrelated trees.
|
||||
#[test]
|
||||
fn orig_tarball_vendor_exclusion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tree = dir.path().join("mytool");
|
||||
std::fs::create_dir_all(tree.join("vendor/serde/src")).unwrap();
|
||||
std::fs::create_dir_all(tree.join("src/vendor")).unwrap();
|
||||
std::fs::write(tree.join("vendor/serde/src/lib.rs"), "code").unwrap();
|
||||
std::fs::write(tree.join("src/vendor/mod.rs"), "code").unwrap();
|
||||
std::fs::write(tree.join("Cargo.toml"), "[package]").unwrap();
|
||||
|
||||
let tarball = create_orig_tarball_excluding(&tree, "mytool", "0.1.0", true).unwrap();
|
||||
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
||||
std::fs::File::open(&tarball).unwrap(),
|
||||
));
|
||||
let names: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
// The generated vendored tree is out...
|
||||
assert!(
|
||||
!names.iter().any(|n| n.starts_with("mytool-0.1.0/vendor")),
|
||||
"{names:?}"
|
||||
);
|
||||
// ...an unrelated nested vendor/ stays in...
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/src/vendor/mod.rs"),
|
||||
"{names:?}"
|
||||
);
|
||||
// ...and normal files are unaffected.
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
|
||||
"{names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_files_sets_exec_bit_and_parents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let files = vec![
|
||||
OutputFile::new("a/b/c.txt", "deep"),
|
||||
OutputFile::executable("debian/rules", "#!/usr/bin/make -f\n"),
|
||||
];
|
||||
write_files(dir.path(), &files).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("a/b/c.txt")).unwrap(),
|
||||
"deep"
|
||||
);
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join("debian/rules"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o755);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Project detection for `pkh new`: which template matches an existing
|
||||
//! source directory.
|
||||
//!
|
||||
//! The rule set is deliberately simple and table-driven (highest precedence
|
||||
//! first):
|
||||
//!
|
||||
//! 1. the `detect.files` marker files declared by the template manifests
|
||||
//! (`data/templates/<id>/manifest.yml`, in registry order: `Cargo.toml`,
|
||||
//! `pyproject.toml`/`setup.py`/`setup.cfg`, `meson.build`,
|
||||
//! `CMakeLists.txt`, `configure.ac`, `go.mod`, `Makefile`) looked for at
|
||||
//! the top level of the directory — more than one distinct template
|
||||
//! matching is [`Detection::Ambiguous`]; templates without markers
|
||||
//! (shell: the single-script heuristic below; empty: never detected)
|
||||
//! declare none,
|
||||
//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file
|
||||
//! whose first line is a `#!` shebang) → [`TemplateId::SHELL`],
|
||||
//! several scripts or none → nothing,
|
||||
//! 3. otherwise [`Detection::Empty`].
|
||||
//!
|
||||
//! Detection only looks at the top level on purpose: source files below
|
||||
//! `src/` etc. carry no extra signal (a `src/main.rs` without `Cargo.toml`
|
||||
//! is not a Rust project pkh can package), and recursion would turn stray
|
||||
//! vendored files into false matches.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::licenses;
|
||||
use super::options::TemplateId;
|
||||
use super::templates;
|
||||
|
||||
/// Outcome of the detection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Detection {
|
||||
/// Exactly one template matches.
|
||||
Single(TemplateId),
|
||||
/// Several templates match; the caller must ask (wizard) or demand an
|
||||
/// explicit `--lang`.
|
||||
Ambiguous(Vec<TemplateId>),
|
||||
/// Nothing recognized.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Detect the template matching the project in `dir`: the manifests'
|
||||
/// marker files in registry order (the detection priority), then the
|
||||
/// shell single-script heuristic.
|
||||
pub fn detect(dir: &Path) -> Detection {
|
||||
let mut hits: Vec<TemplateId> = Vec::new();
|
||||
for template in templates::all() {
|
||||
let markers = template.detect_files();
|
||||
if !markers.is_empty()
|
||||
&& markers.iter().any(|marker| dir.join(marker).exists())
|
||||
&& !hits.contains(&template.id())
|
||||
{
|
||||
hits.push(template.id());
|
||||
}
|
||||
}
|
||||
|
||||
match hits.as_slice() {
|
||||
[] => {}
|
||||
[only] => return Detection::Single(*only),
|
||||
_ => return Detection::Ambiguous(hits),
|
||||
}
|
||||
|
||||
if single_script(dir).is_some() {
|
||||
Detection::Single(TemplateId::SHELL)
|
||||
} else {
|
||||
Detection::Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// The single top-level script of `dir`, if there is exactly one: a file
|
||||
/// with the `.sh` extension, or whose first line starts with `#!`. Returns
|
||||
/// `None` when there are zero or several candidates.
|
||||
pub fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
|
||||
let mut found: Option<std::path::PathBuf> = None;
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// Hidden files and packaging leftovers carry no signal.
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let is_script = name.ends_with(".sh") || has_shebang(&path);
|
||||
if is_script {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(path);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Whether the first line of the file starts with `#!`.
|
||||
fn has_shebang(path: &Path) -> bool {
|
||||
let Ok(content) = std::fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
content.starts_with(b"#!")
|
||||
}
|
||||
|
||||
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
|
||||
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
|
||||
/// matched against the marker sets of the bundled license table
|
||||
/// (`data/licenses.yml`: MIT, BSD-2/3, Apache-2.0, GPL-2/3, LGPL-2.1/3,
|
||||
/// ISC). `None` when no license file exists or nothing recognizable is
|
||||
/// found.
|
||||
pub fn sniff_license(dir: &Path) -> Option<String> {
|
||||
let content = licenses::detect_files()
|
||||
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
|
||||
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
|
||||
// first top-level file whose name looks like a license notice.
|
||||
.or_else(|| {
|
||||
let mut candidates: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
let name = name.to_ascii_uppercase();
|
||||
// American and British spellings both count.
|
||||
name.starts_with("LICENSE")
|
||||
|| name.starts_with("LICENCE")
|
||||
|| name.starts_with("COPYING")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
candidates.sort();
|
||||
std::fs::read_to_string(candidates.into_iter().next()?).ok()
|
||||
})?;
|
||||
|
||||
// An explicit SPDX identifier is the most reliable signal.
|
||||
static SPDX_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let spdx = SPDX_REGEX.get_or_init(|| {
|
||||
Regex::new(r"(?i)SPDX-License-Identifier\s*:\s*([A-Za-z0-9+.\- ]+)").unwrap()
|
||||
});
|
||||
if let Some(id) = spdx
|
||||
.captures(&content)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|id| id.as_str().trim_end().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
// The recognizable-license markers live in the bundled table; the
|
||||
// LICENSE_TEXTS test below is their behavioral lock.
|
||||
let text = content.to_ascii_lowercase();
|
||||
licenses::detect_from_text(&text).map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn touch(dir: &Path, name: &str) {
|
||||
std::fs::write(dir.join(name), "x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_files_map_to_templates() {
|
||||
let cases = [
|
||||
("Cargo.toml", TemplateId::RUST),
|
||||
("pyproject.toml", TemplateId::PYTHON),
|
||||
("setup.py", TemplateId::PYTHON),
|
||||
("setup.cfg", TemplateId::PYTHON),
|
||||
("meson.build", TemplateId::MESON),
|
||||
("CMakeLists.txt", TemplateId::CMAKE),
|
||||
("configure.ac", TemplateId::AUTOTOOLS),
|
||||
("go.mod", TemplateId::GO),
|
||||
("Makefile", TemplateId::MAKEFILE),
|
||||
];
|
||||
for (marker, expected) in cases {
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), marker);
|
||||
assert_eq!(detect(dir.path()), Detection::Single(expected), "{marker}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_markers_are_ambiguous() {
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "Cargo.toml");
|
||||
touch(dir.path(), "Makefile");
|
||||
assert_eq!(
|
||||
detect(dir.path()),
|
||||
Detection::Ambiguous(vec![TemplateId::RUST, TemplateId::MAKEFILE])
|
||||
);
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "pyproject.toml");
|
||||
touch(dir.path(), "setup.py");
|
||||
// Both markers map to the same template: one hit, not ambiguous.
|
||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::PYTHON));
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "meson.build");
|
||||
touch(dir.path(), "CMakeLists.txt");
|
||||
assert_eq!(
|
||||
detect(dir.path()),
|
||||
Detection::Ambiguous(vec![TemplateId::MESON, TemplateId::CMAKE])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_script_is_shell() {
|
||||
// .sh extension.
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "run.sh");
|
||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
|
||||
|
||||
// Shebang without extension.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap();
|
||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
|
||||
|
||||
// Two scripts: not exactly one, nothing recognized.
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "a.sh");
|
||||
touch(dir.path(), "b.sh");
|
||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||
|
||||
// Plain files without shebang are not scripts.
|
||||
let dir = tempdir().unwrap();
|
||||
touch(dir.path(), "README");
|
||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_matches_is_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||
|
||||
// Nonexistent directory: empty, not a panic.
|
||||
let dir = tempdir().unwrap();
|
||||
assert_eq!(detect(&dir.path().join("missing")), Detection::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_files_and_subdirs_are_ignored() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("subdir.sh")).unwrap();
|
||||
std::fs::write(dir.path().join(".hidden.sh"), "#!/bin/sh\n").unwrap();
|
||||
// The only "real" script candidate is in a subdir or hidden: no hit.
|
||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||
}
|
||||
|
||||
/// Distinctive (shortened) excerpts of the recognizable license texts:
|
||||
/// the behavioral lock of the marker sets in `data/licenses.yml` — a
|
||||
/// bad marker edit fails here, not on real packages.
|
||||
const LICENSE_TEXTS: [(&str, &str); 11] = [
|
||||
(
|
||||
"MIT",
|
||||
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
|
||||
),
|
||||
("Apache-2.0", "Apache License\nVersion 2.0, January 2004"),
|
||||
(
|
||||
"GPL-2.0+",
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991\neither version 2 of the License",
|
||||
),
|
||||
(
|
||||
"GPL-3.0+",
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
),
|
||||
(
|
||||
"LGPL-2.1+",
|
||||
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999",
|
||||
),
|
||||
(
|
||||
"LGPL-3.0+",
|
||||
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
),
|
||||
(
|
||||
"BSD-2-Clause",
|
||||
"Redistribution and use in source and binary forms, with or without\nmodification, are permitted",
|
||||
),
|
||||
(
|
||||
"BSD-3-Clause",
|
||||
"Redistribution and use in source and binary forms, with or without\nmay be used to endorse or promote products",
|
||||
),
|
||||
(
|
||||
"ISC",
|
||||
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
|
||||
),
|
||||
// Dual-licensed preamble: the GPL reference outranks the MIT
|
||||
// boilerplate, like the old hardcoded cascade decided.
|
||||
(
|
||||
"GPL-2.0+",
|
||||
"MIT License\n\nAlternatively, under the terms of the GNU General Public License,\
|
||||
\nversion 2 of the License.",
|
||||
),
|
||||
// LGPL text naming both versions: the 2.1 wording wins.
|
||||
(
|
||||
"LGPL-2.1+",
|
||||
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999\n\
|
||||
This is version 2.1; version 3 is available separately.",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn sniff_license_recognizes_license_files() {
|
||||
for (expected, text) in LICENSE_TEXTS {
|
||||
// Every candidate file name is looked at.
|
||||
for name in ["LICENSE", "COPYING", "LICENSE.md", "COPYING.txt"] {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(name), text).unwrap();
|
||||
assert_eq!(
|
||||
sniff_license(dir.path()).as_deref(),
|
||||
Some(expected),
|
||||
"{name}: {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_license_prefers_spdx_identifier() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("LICENSE"),
|
||||
"Custom terms here\nSPDX-License-Identifier: Zlib\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("Zlib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_license_handles_case_variants_and_missing_files() {
|
||||
// Unusual spelling found through the directory scan.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Licence.TXT"),
|
||||
"Permission is hereby granted, free of charge",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||
|
||||
// Exact candidates win over the directory scan (LICENSE before
|
||||
// LICENSE.blurb).
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("LICENSE.blurb"),
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("LICENSE"), "MIT License").unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||
|
||||
// Unrecognizable or missing text: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("LICENSE"), "do whatever you want\n").unwrap();
|
||||
assert_eq!(sniff_license(dir.path()), None);
|
||||
assert_eq!(sniff_license(&dir.path().join("missing")), None);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user