Compare commits

..
9 Commits
Author SHA1 Message Date
vhaudiquet 5015ff7278 test: fix pull archive fallback test
CI / build (pull_request) Failing after 30s
2025-12-24 12:30:36 +01:00
vhaudiquet e872f6b992 test: fix context ensure_available test 2025-12-24 12:15:55 +01:00
vhaudiquet 5b1bcdb453 exp: cross #7
CI / build (pull_request) Failing after 2m16s
2025-12-23 17:19:41 +01:00
vhaudiquet 3ecfe6dda2 exp: cross #6 2025-12-22 23:08:44 +01:00
vhaudiquet 63389f0bad exp: cross #5 2025-12-22 00:13:37 +01:00
vhaudiquet 75751ad301 exp: cross #4 2025-12-21 22:07:34 +01:00
vhaudiquet 0d4ae565dd exp: cross #3 2025-12-21 21:37:56 +01:00
vhaudiquet 31bcd28c72 exp: cross #2 2025-12-20 00:06:07 +01:00
vhaudiquet 8e9e19a6ca exp: cross 2025-12-17 17:27:27 +01:00
143 changed files with 1453 additions and 56610 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

+6 -134
View File
@@ -2,8 +2,7 @@ name: CI
on:
push:
branches: [ "main", "ci-test" ]
tags: [ "v*" ]
branches: [ "main" ]
pull_request:
branches: [ "main" ]
@@ -13,150 +12,23 @@ env:
jobs:
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
with:
components: rustfmt, clippy
components: rustfmt
- name: Check format
run: cargo fmt --check
- name: Install build dependencies
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-
sudo apt-get install -y pkg-config libssl-dev
- name: Build
run: cargo build
env:
RUSTFLAGS: -Dwarnings
- name: Lint
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
sudo apt-get install -y git pristine-tar mmdebstrap util-linux dpkg-dev
- name: Setup subuid/subgid
run: |
usermod --add-subuids 100000-200000 --add-subgids 100000-200000 ${USER:-root}
- name: Run tests with verbose logging (timeout 30min)
env:
RUST_LOG: debug
run: timeout 30m cargo test -- --nocapture
sudo apt-get install -y pristine-tar sbuild mmdebstrap dpkg-dev
- name: Run tests
run: cargo test
snap:
needs: build
runs-on: ubuntu-latest
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:
- 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: ./*.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 }}
+1 -7
View File
@@ -1,8 +1,2 @@
*.lock
target
# Local snapcraft builds
.craft
parts
prim
stage
*.snap
-108
View File
@@ -1,108 +0,0 @@
# 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
-3054
View File
File diff suppressed because it is too large Load Diff
+1 -19
View File
@@ -1,20 +1,14 @@
[package]
name = "pkh"
version = "0.2.0"
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"] }
cmd_lib = "2.0.0"
crossterm = "0.28"
flate2 = "1.1.5"
serde = { version = "1.0.228", features = ["derive"] }
libc = "0.2"
csv = "1.3.0"
reqwest = { version = "0.12.9", features = ["blocking", "json", "stream"] }
git2 = "0.20.2"
@@ -22,8 +16,6 @@ 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"
indicatif = "0.18.3"
@@ -35,14 +27,4 @@ xz2 = "0.1"
serde_json = "1.0.145"
directories = "6.0.0"
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"
serial_test = "3.3.1"
tempfile = "3.10.1"
-338
View File
@@ -1,338 +0,0 @@
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
View File
@@ -1,21 +0,0 @@
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.
+53 -103
View File
@@ -2,30 +2,6 @@
`pkh` is a packaging helper for Debian/Ubuntu packages.
![](.github/pkh.gif)
## 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, autopkgtest, ...):
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
@@ -42,27 +18,15 @@ Options:
-d, --dist <dist> Target package distribution (debian, ubuntu)
-v, --version <version> Target package version
-a, --arch <arch> Target architecture (amd64, arm64, riscv64, ...)
-p, --pocket <pocket> Target distribution pocket (updates, security, proposed, ...)
--ppa <ppa> Do the action in/for a specific PPA
```
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
pull Get 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)
test Run the package's DEP-8 as-installed tests (autopkgtests)
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
@@ -98,82 +62,68 @@ 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
...
git add debian/patches/xxx.patch
git commit -m "Applied patch xxx"
pkh 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 the commits to your fork
# Push previously commited changes
git push xxx user-fork
```
### Incremental builds (build sessions)
## Roadmap: features needed for 1.0
Every `pkh deb` build records a **session** under `/var/tmp/pkh/sessions`:
the bootstrapped chroot, the installed build dependencies and the build
artifacts of the staged tree. When a build fails (or is interrupted), the
session is kept and can be resumed:
Basically, wrapping the basic debian workflows.
Missing features:
- [ ] `pkh pull`
- [x] Obtain package sources from git
- [x] Obtain package sources from the archive (fallback)
- [ ] 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 sbuild+unshare, with binary emulation (default)
- [ ] 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), sbuild/VM?, ppa
- [ ] Async test
```
pkh deb # fails after 25 minutes
pkh deb --resume # reuses the chroot, build deps and objects;
# only what changed is recompiled
pkh deb list # the sessions of this tree, with their ids
pkh deb --resume <id> # resume a specific session
pkh deb --keep # keep the session even after a successful build
# (iterate: edit, `pkh deb --resume --keep`, ...)
pkh prune # garbage-collect old sessions (7-day retention)
```
## Nice-to-have features
A plain `pkh deb` never reuses a session — everything is rechecked from
scratch — and it replaces the session of its target. `pkh deb --resume`
refuses to adopt a session built for a different series/architecture.
- [ ] '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
Packages whose `debian/rules` keeps an input-untracked stamp cache (the
kernels' `debian/stamps`) declare it in the quirks data (`resume_clear`):
resumed builds drop the cache so the wrapper steps — including the
kernel's flavour config export — re-run over the incremental inner
builds, and a config edit is never silently ignored. If a build died
hard (OOM kill), the next build unmounts and clears the leftover session
state first (escalating through sudo when needed).
### Running the DEP-8 tests
`pkh test` runs the package's as-installed tests (`debian/tests/control`,
declared by `Testsuite: autopkgtest` in `debian/control`) through a
native runner on a pkh testbed, and reports one verdict per test:
```
pkh test # test this tree: builds it first if needed
pkh test --mode local # on the current context, no isolation
pkh test --mode vm --image img.qcow2 # in a VM, via autopkgtest
pkh test --json --fail-on skip # CI-shaped
pkh test --list-tests # what does this package test?
```
By default the tests run in an ephemeral unshare chroot bootstrapped
from the same cached tarballs as `pkh deb`, against the binaries pkh deb
just built next to the tree (stale or missing output is rebuilt first;
`--no-build` refuses, `--debs` overrides). Pockets and PPAs resolve test
dependencies like in `pkh deb` (`-p`, `--ppa`), and `--test-name`,
`--skip-test`, `--shell-fail` and `--setup-commands` cover the day-to-day
debugging loop. Tests needing a real machine (`isolation-machine`,
`needs-reboot`) run only in `--mode vm`, which execs the installed
`autopkgtest` with its qemu runner.
Exit codes: 0 all tests passed (skips and flaky allowed), 1 at least one
test failed (or a `--fail-on` trigger), 2 runtime errors (no declared
tests, no usable testbed, ...).
## 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
- pull: cache Sources.gz files to improve speed
- pull: 'pkh pull' in a package tree should git pull and re-fetch orig tgz
-210
View File
@@ -1,210 +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 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
-31
View File
@@ -1,31 +0,0 @@
## 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
-19
View File
@@ -1,19 +0,0 @@
## 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
-17
View File
@@ -1,17 +0,0 @@
## 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}"
-33
View File
@@ -1,33 +0,0 @@
## 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}
-139
View File
@@ -1,139 +0,0 @@
## 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
-82
View File
@@ -1,82 +0,0 @@
# 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.
# The kernel's debian/rules keeps its wrapper-step stamps in
# debian/stamps, and the flavour config rule there exports .config from
# the annotations with no config prerequisite — a resumed build would
# keep the previous attempt's configuration and silently ignore config
# edits. List the stamp cache under `resume_clear` so resumed builds
# drop it and re-run the wrapper steps; the inner kbuild keeps its own
# incremental state, so only the cheap wrapper passes re-run. This is
# the packaging's long-standing shape rather than a fixable regression,
# so the entry applies to every series.
linux:
deb:
- series: []
resume_clear:
- debian/stamps
- 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: []
resume_clear:
- debian/stamps
- 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
-130
View File
@@ -1,130 +0,0 @@
---
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.
-2
View File
@@ -1,2 +0,0 @@
bin_PROGRAMS = {command}
{command}_SOURCES = hello.c
@@ -1,5 +0,0 @@
AC_INIT([{name}], [{upstream_version}])
AM_INIT_AUTOMAKE([foreign])
AC_PROG_CC
AC_CONFIG_FILES([Makefile])
AC_OUTPUT
-8
View File
@@ -1,8 +0,0 @@
#include <stdio.h>
/* Placeholder for {name}, generated by `pkh new`. */
int main(void)
{
printf("Hello from {command}!\n");
return 0;
}
-30
View File
@@ -1,30 +0,0 @@
## 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
-5
View File
@@ -1,5 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project({name} VERSION {upstream_version})
add_executable({command} hello.c)
install(TARGETS {command} RUNTIME DESTINATION bin)
-8
View File
@@ -1,8 +0,0 @@
#include <stdio.h>
/* Placeholder for {name}, generated by `pkh new`. */
int main(void)
{
printf("Hello from {command}!\n");
return 0;
}
-22
View File
@@ -1,22 +0,0 @@
## 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
-1
View File
@@ -1 +0,0 @@
{name} - empty base tree scaffolded by `pkh new`; there is intentionally no upstream build system here.
-19
View File
@@ -1,19 +0,0 @@
## 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
-3
View File
@@ -1,3 +0,0 @@
module {name}
go 1.21
-8
View File
@@ -1,8 +0,0 @@
// Placeholder for {name}, generated by `pkh new`.
package main
import "fmt"
func main() {
fmt.Println("Hello from {command}!")
}
-24
View File
@@ -1,24 +0,0 @@
## 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
-16
View File
@@ -1,16 +0,0 @@
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
-8
View File
@@ -1,8 +0,0 @@
#include <stdio.h>
/* Placeholder for {name}, generated by `pkh new`. */
int main(void)
{
printf("Hello from {command}!\n");
return 0;
}
-1
View File
@@ -1 +0,0 @@
{command} usr/bin/{command}
-28
View File
@@ -1,28 +0,0 @@
## 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
-8
View File
@@ -1,8 +0,0 @@
#include <stdio.h>
/* Placeholder for {name}, generated by `pkh new`. */
int main(void)
{
printf("Hello from {command}!\n");
return 0;
}
-28
View File
@@ -1,28 +0,0 @@
## 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
-3
View File
@@ -1,3 +0,0 @@
project('{name}', version: '{upstream_version}', license: '{license}', default_options: ['c_std=c11'])
executable('{command}', 'hello.c', install: true)
-5
View File
@@ -1,5 +0,0 @@
"""Placeholder for {name}, generated by `pkh new`."""
def main() -> None:
print("Hello from {command}!")
-29
View File
@@ -1,29 +0,0 @@
## 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
-12
View File
@@ -1,12 +0,0 @@
[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"
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "{crate_name}"
version = "{upstream_version}"
edition = "2021"
[dependencies]
-4
View File
@@ -1,4 +0,0 @@
// Placeholder for {name}, generated by `pkh new`.
fn main() {
println!("Hello from {command}!");
}
-33
View File
@@ -1,33 +0,0 @@
## 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
-19
View File
@@ -1,19 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
{command}.sh usr/bin/{command}
-25
View File
@@ -1,25 +0,0 @@
## 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
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
# Placeholder for {name}, generated by `pkh new`.
echo "Hello from {command}!"
-455
View File
@@ -1,455 +0,0 @@
# `pkh deb` Incremental Builds — Spec
## Problem
`pkh deb` has no incremental story. Every invocation — including a rerun
seconds after a failed build — pays the full pipeline again:
1. mmdebstrap tarball **extraction** into a fresh `/tmp/pkh-<ts>` chroot
(the tarball itself is cached, but extraction of a kernel-sized chroot
is minutes),
2. device nodes + `/proc` bind mount,
3. `apt-get update`,
4. essentials install,
5. `quilt push -a`,
6. native build-dependency resolution + install,
7. `debian/rules build` **from scratch** in a freshly staged copy of the
tree,
8. `fakeroot debian/rules binary`.
The failures this hurts most are the late ones. A kernel package that
dies in `debian/rules build` after 25 minutes is restarted from zero;
fixing one compile error and rerunning recompiles everything, and a
cycle of "tweak, rebuild, fail again" costs a full pipeline each time.
The raw material for resumption already exists but is unreachable:
- **Failed chroots are kept.** `EphemeralContextGuard::drop`
(`src/deb/ephemeral.rs`) deliberately keeps the chroot when the build
did not succeed — but the next run creates a new `pkh-<ts>` directory
and cannot find or reuse the old one. The kept chroot is a leak until
`pkh prune`, not a cache.
- **The staged tree is an overlayfs mount** (`src/context/unshare.rs`):
host tree = lowerdir (read live), build writes = upperdir inside the
chroot. If the upperdir survives between runs, object files survive
too, and host-side edits propagate automatically — exactly the
semantics incremental builds need.
- **Ctrl+C deletes everything** (`sigint_cleanup_chroot`), which is the
worst behavior for a long build interrupted on purpose.
## Goal
`pkh deb --resume` after a failed build resumes from the point of
failure (plain `pkh deb` always rebuilds from scratch — see §5):
- same environment, source untouched → picks up at
`debian/rules build`, recompiling nothing (make sees a warm tree);
- source modified since the failure → still reuses chroot, apt state
and installed build-deps; `debian/rules build` recompiles only what
the modification affects;
- `debian/control` or patch series changed → environment-level state
(build-deps, quilt) is redone; the chroot and object files still
carry over when possible.
Non-goal: distributing or sharing sessions between machines; resuming
`pkh build` (the native source pipeline — it is seconds-fast already);
resuming inside ssh/schroot contexts (sessions are a local-unshare
feature; other drivers keep today's behavior).
## Design
### 1. Build sessions: named, journaled, discoverable
A **session** is one (package, series, arch, cross) build environment
plus its journal. Replace the anonymous `pkh-<timestamp>` chroot
directory with a session root:
```
/var/tmp/pkh/sessions/<slug>/
session.json # manifest + phase journal (see below)
chroot/ # the chroot tree (was /tmp/pkh-<ts>)
tree-upper/ # overlayfs upperdir for the staged package tree
pkh-overlay/ # overlay workdirs (as today)
```
`<slug>` is derived from the session identity (package, series, arch,
cross) plus a short content hash, so a rerun can find a previous
session by identity without guessing timestamps. One live session per
identity — the newest attempt *replaces* the previous one (same
chroot environment, fresher state; keeping per-attempt history would
mean one full chroot per attempt, which is a cache nobody wants). A
locked session (concurrent build) is never replaced (§6). What the
session list shows for a tree is therefore one entry per identity
(series/arch/cross combination), each carrying its latest attempt's
id.
Moving off `/tmp` is deliberate: sessions must survive a reboot to be
worth keeping, and `/tmp` is often tmpfs (a kernel chroot on tmpfs is
RAM). `/var/tmp` is the conventional persistent-scratch location; the
base directory is overridable (`PKH_SESSIONS_DIR` env, then config).
Sessions are a cache, and `pkh prune` is their primary GC — if
systemd-tmpfiles sweeps `/var/tmp` on some setup, that is an
acceptable, if blunt, secondary cleanup.
`session.json` records:
```jsonc
{
"id": "20260926T143505", // build-start timestamp, UTC; changes on every attempt
"host_tree": "/home/me/linux", // tree the session was built from (list scoping)
"identity": {
"package": "linux", "series": "stonking",
"arch": "arm64", "cross": true
},
"created": "...", "last_used": "...",
"chroot": {
"tarball": "stonking-arm64-buildd.tar.xz",
"tarball_sha256": "...", // reuse only if the cached tarball is the same
"ready": true // device nodes + /proc done
},
"phases": {
"apt_update": { "at": "..." }, // journal-only: apt update always reruns on resume
"essentials": { "at": "..." },
"patches": { "stamp": "<hash of debian/patches>", "applied": true },
"build_deps": { "stamp": "<hash of control+arch+cross+ppa+inject+pocket>", "at": "..." },
"build": { "at": "...", "result": "failed" }
},
"tree": {
"version": "7.3.0-5.6~local2", // changelog version of the last attempt
"host_snapshot": "<path or inline list>", // for deletion propagation
}
}
```
The journal is written **after** each phase completes (and after a
phase fails, recording the failure), so a crash or Ctrl+C at any point
leaves a truthful journal. Discovery is: scan
`$PKH_SESSIONS_DIR/*/session.json`, match identity.
### 2. Two layers of resume
The journal separates **environment resume** (expensive, insensitive to
source edits) from **build resume** (sensitive to every edit):
| Phase | Reused when | Redone when |
|---|---|---|
| Chroot extract, nodes, /proc | chroot present, `ready`, same tarball hash | never for the same identity (otherwise the session is unusable) |
| `apt-get update` | — (always rerun on resume) | — |
| Essentials | done (verify cheaply via `dpkg-query` at most) | chroot redone |
| quilt push | `debian/patches` hash unchanged | hash changed → `quilt pop -a` + re-push |
| Build-deps | control/arch/cross/ppa/inject/pocket stamp unchanged | stamp changed → the resolver runs again (it is idempotent; apt installs only what is missing) |
| `debian/rules build` | always *attempted* in the warm tree | — |
| `fakeroot debian/rules binary` | always rerun | — |
Key consequences:
- **`apt-get update` is always rerun on resume.** It is the cheapest
phase, and stale package lists fail confusingly ("candidate version
not found") on fast-moving suites — there is nothing worth caching
here. The journal entry exists for observability only.
- **A changelog version bump** (`pkh chlog` between attempts) invalidates
build resume but not environment resume: the chroot, apt lists and
build-deps carry over. The staged tree is re-populated for the new
version (see §3); the session's `tree.version` tracks which attempt
the upperdir belongs to, and a version change discards the upperdir
(object files reference paths/flags of the old version).
- **The build phase is never skipped**, only made incremental — make
decides. pkh's job is to hand `debian/rules build` a tree whose
previously produced objects are still there and whose sources match
the host.
- Before rerunning the binary phase on a resume, clear
`debian/files` (and `debian/*.log`, `debian/substvars`) from the
staged tree so artifact collection (`collect_binary_artifacts`) only
sees the new attempt's outputs — today's "no globbing stale files"
contract is preserved.
### 3. Tree propagation on resume
Today each build stages a fresh copy/overlay of the host tree. On
resume the staged tree already exists inside the session; the problem
is syncing it with a possibly-modified host tree.
- **Overlay contexts (the default when overlayfs is available):** remount
the overlay with the *same* upperdir. Host modifications are visible
immediately (lowerdir is read live); objects written in the upperdir
during the previous attempt are still on top. Host-side *deletions*
are the one gap — overlayfs only hides a lower file via a whiteout in
the upper, and nothing creates whiteouts for files the user deleted
on the host. Fix: the session stores a snapshot of the host tree's
file list at first staging; on resume, files present in the upper
(or in the snapshot) but gone from the host get an explicit whiteout
(`mknod <path> c 0 0` inside the upper). This is a bounded,
manifest-driven operation, not a tree walk of the whole chroot.
- **Copy fallback (no overlayfs):** `rsync -a --delete` of the host
tree over the staged tree, *excluding* the build artifacts make
produced (this needs per-package exclusion knowledge and is best
effort). Deletions propagate naturally here. Sessions on the copy
path get build resume only for trees where the exclusion set is
sane; the honest fallback is: copy path → environment resume only.
The overlay logic stays inside the unshare driver, matching the
principle established in `plans/overlayfs-integration.md`: callers
stage a tree through `ensure_available()` and never learn how.
### 4. Interrupt behavior
Ctrl+C currently removes the chroot through the interrupt hook. Change:
**an interrupted build keeps the session**, journaling the interrupted
phase as incomplete — an intentional Ctrl+C on a 30-minute build is a
"pause", and deleting the environment on pause defeats the feature.
The cleanup hook (`sigint_cleanup_chroot`) becomes session-aware: it
still unmounts the overlays and `/proc` (leaving mounted state in a
session is what makes stale sessions dangerous), but keeps the tree
and leaves a `interrupted: true` marker in the journal so the next run
knows the session needs no special recovery (phases after the last
completed one simply rerun).
Users who want the old hard-discard behavior on interrupt get it via
`pkh prune`. The failure path is unchanged in spirit: failed builds
already keep their chroot; they now keep a *usable* one.
### 5. CLI surface and policy
**Recording is always on; reuse is opt-in.** The two are deliberately
separated, because the first attempt cannot know it will fail: a
plain `pkh deb` writes and maintains the session (journal, kept
chroot on failure or interrupt — today's keep-on-failure, made
usable) but never reads one. Only `pkh deb --resume` adopts an
existing session. The default run therefore always rechecks
everything from scratch — no stale-build risk unless the user asks
for one.
- `pkh deb` — today's behavior, plus session recording: any existing
session of the same identity is ignored and replaced by this run's
outcome. On failure or interrupt the session is kept (as today);
on success it is **removed**, unless `--keep` is given.
- `pkh deb --keep` — also keep the session after a successful build,
so a later `--resume` can iterate on it (the edit–rebuild loop).
Composes with `--resume`: `--resume --keep` chains sessions across
iterations; a resumed build without `--keep` consumes the session
on success — the natural end of the loop.
- `pkh deb --resume [<id>]` — adopt a session and continue it,
announcing what is being skipped ("Resuming session
20260926T143505 for linux/stonking-arm64: chroot, package lists
and build-deps reused").
- Without an id: the newest session recorded from this tree
(manifest `host_tree` matches the tree being built), whatever its
last outcome — failure, interrupt, or a `--keep` success.
- With an id (a build-start timestamp as shown by `pkh deb list`):
that exact session. A unique unambiguous prefix is accepted; an
ambiguous one is an error listing the candidates.
- Selector conflicts are errors, not surprises: if `-a`/`-s`/
`--cross`/`--ppa`/`--inject` are given explicitly and disagree
with the adopted session, refuse and point at `pkh deb list`. A
session for another series/arch is a *different* build
environment; silently building it under the requested selectors
would be exactly the stale-build risk this design avoids.
- With no session to adopt (none recorded, pruned, or the tree
moved): say so and build from scratch — never silently.
- `pkh deb list` — a subcommand, not a flag: `pkh deb` with flags
always means "start a build", and listing is a different action.
It prints the sessions recorded from the current tree, one row per
identity (§1), newest attempt first:
```
$ pkh deb list
Sessions for /home/me/linux:
ID PACKAGE VERSION TARGET LAST RUN AGE
20260926T143505 linux 7.3.0-5.6~local2 stonking/arm64 failed 2h
20260925T090012 linux 7.3.0-5.5~local1 stonking/arm64* success 1d
20260924T171100 linux 7.3.0-5.6~local2 stonking/riscv64* failed 2d
* cross build. Resume with: pkh deb --resume [<id>]
```
`pkh deb list` never touches build machinery: it reads the session
manifests under the sessions directory and renders. As an optional
subcommand of `deb` (clap allows subcommands alongside args), the
build flags stay on `deb` itself and `list` takes none of them.
- `pkh prune` learns sessions: by default removes
sessions untouched for longer than a retention window (7 days) and
any session whose journal is missing/corrupt; `--all` removes every
session. Prune's report distinguishes "session (resumable build)"
from the other residual categories, since sessions are large and
users must be able to see and reclaim them.
### 6. Correctness guards
A session is only reused when **all** of these hold; otherwise it is
discarded (replaced) with a logged reason:
- identity matches (package, series, arch, cross);
- the cached chroot tarball hash matches the one the chroot was built
from;
- `chroot/` passes an integrity probe (marker file, `chroot/bin/sh`
exists and executes);
- pkh version compatibility: `session.json` records the pkh version
and manifest schema version; a mismatched schema discards the
session (the chroot is generic enough that only schema-relevant
changes matter, but being conservative is cheap).
- no other live session for the same identity (lock file in the
session root, in the spirit of the existing tarball lockfile).
Concurrent builds of the same identity take the lock and fall back to
a fresh ephemeral session (current behavior) rather than waiting —
correctness over caching.
One more guard on the resume path, at the CLI layer: explicit
selectors (`-a`, `-s`, `--cross`, `--ppa`, `--inject`) that disagree
with the adopted session refuse the build (§5) — the session is only
ever reused under the selectors it was created with.
## Implementation sketch
New `src/deb/session.rs` — session identity, manifest
(serde) read/write, discovery, locking, slug generation. Pure logic,
fully unit-testable.
- `src/deb/ephemeral.rs` — `EphemeralContextGuard` learns a `resume`
path: when a session is adopted, skip download/extract and reuse the
existing chroot (still verifying `ready` and remounting `/proc` if a
previous run unmounted it); on teardown, hand the tree to the
session instead of `rm -rf`-ing it. Chroot dir moves from
`create_temp_dir()` to the session layout — the local driver's temp
naming stays for non-session uses.
- `src/deb/local.rs` — journal writes around each phase; the
skip-or-run decisions per the table in §2; `debian/files` cleanup
before the binary phase on resume.
- `src/context/unshare.rs` — upperdir reuse + whiteout propagation for
host deletions (§3); `ensure_available()` gains a
"stage into existing session tree" mode.
- `src/interrupt.rs` + `src/deb/ephemeral.rs` — the interrupt hook
keeps the session (§4).
- `src/prune.rs` — session discovery, retention, reporting.
- `src/main.rs` — `--keep`, `--resume [<id>]` argument wiring,
the `pkh deb list` subcommand and selector-conflict checks;
`README.md` roadmap
update (this retires part of the "deb: asynchronous build,
detachable and monitorable" itch: a persistent session is also the
natural attach point for detached builds later).
Staged rollout, each step independently shippable:
1. Sessions with manifest + `pkh deb list` + adoption of failed-build
chroots via `--resume` (environment resume only — biggest win, no
overlay changes).
2. Build resume: upperdir reuse + whiteout propagation.
3. Interrupt keeps session; `--keep` after success; prune integration.
## Testing
- Unit: manifest round-trip, slug/identity matching, whiteout plan
computation (host snapshot vs current host listing), skip-decision
table given journals with various stamps.
- Integration (small fixture package, `#[cfg(test)]` style already
used by `deb` tests): fail `debian/rules build` on file B after
compiling file A; resume and assert A is *not* recompiled (make log
via the tee log); modify A; resume and assert only A recompiles;
modify `debian/control`; assert build-deps rerun and objects
survive; `pkh prune` GC behavior; a plain `pkh deb` ignoring an
existing session (fresh rebuild) while `--resume` adopts it.
- Unit/CLI: `pkh deb list` rendering against fixture manifests (scoping by
`host_tree`, cross marker, ordering); `--resume <id>` exact and
prefix resolution, ambiguity error; selector-conflict refusals;
`--keep` keeping the session on success while a plain build removes
it.
- Chroot reuse tests need the unshare/mount machinery — follow the
existing pattern of `#[ignore]` tests for deliberate ad-hoc runs
where root/unshare is required.
## Decisions
- **Resume is opt-in (`--resume`), never the silent default.** A
plain `pkh deb` always rechecks everything from scratch, so the
no-flag behavior can never build anything stale; recording stays on
by default so the follow-up `--resume` after a failure has a
session to adopt.
- **Keeping a session after success is opt-in (`--keep`).** Today's
disk profile — success cleans up — stays the default; the
edit–rebuild iteration loop is served by `pkh deb --resume --keep`.
Failures and interrupts keep their session unconditionally, as
today.
- **Sessions are addressed by id (build-start timestamp), listed via
the `pkh deb list` subcommand.** Listing is a subcommand, not a
mode-switching flag: `pkh deb` with flags always starts a build.
One live session per identity keeps the cache
bounded (a per-attempt history would be one full chroot per
attempt); the list therefore shows one row per series/arch/cross
combination for the current tree, each carrying its latest attempt's
id. Explicit selectors that disagree with the adopted session are
an error, never a silent environment switch.
- **Ctrl+C keeps the session**, same as a failed build: an interrupt
on a long build is a pause, and there is nothing inherent in "stop
now" that means "discard half an hour of environment setup". The
hard-discard behavior stays reachable through `pkh prune`.
- **`apt-get update` always reruns on resume** (no stamp, no TTL) —
see §2.
- **Sessions live under `/var/tmp/pkh/sessions`** (overridable via
`PKH_SESSIONS_DIR` then config), and are cleaned by `pkh prune`
(retention window by default, `--all` for everything). Cleanup by
systemd-tmpfiles from time to time is accepted; sessions are a
cache.
---
# Implementation Notes (as built)
Implemented across `src/deb/session.rs` (new), `src/deb/{mod,local,
ephemeral}.rs`, `src/context/{api,unshare}.rs`, `src/prune.rs` and
`src/main.rs`. Deviations from the design above, all deliberate:
- **Phase stamps gate less than specced.** The journal records every
phase (with the patch-tree and control stamps), but on resume only
the expensive non-idempotent work is actually skipped: the chroot
bootstrap (extraction, device nodes, `/proc`) and, via the reused
overlay upperdir, the compiled objects. `apt-get update`, the
essentials install, the quilt push and the build-dep resolution
always rerun — each is idempotent and seconds-cheap, and rerunning
them removes a whole class of stale-state bugs. The stamps still
drive the quilt `pop -a` decision (patch tree changed since the
recorded attempt) and the version-change handling.
- **The advisory lock lives outside the session root**
(`<sessions root>/<slug>.lock`): a teardown deletes the root while
still holding the lock, and a lock file inside it would be removed
under the holder — a concurrent opener would then create and lock a
fresh inode, and mutual exclusion silently dies (observed with two
concurrent same-identity e2e builds before the move).
- **`pkh prune` session GC is confined to the production `prune()`.**
`prune_in()` (the testable core) deliberately does not scan the real
sessions root: the test suite runs prune tests and e2e builds
concurrently, and the prune tests would delete live sessions
mid-build (observed). `prune_in_roots()` takes the sessions root as
an explicit opt-in parameter.
- **Fallbacks, in order:** no local base context → the historical
anonymous temp chroot (no session); session locked by a concurrent
build → same; overlay mount unsupported/failed on resume → fresh
copy staging (environment resume only, artifacts discarded); no
host-tree snapshot → upper wiped and re-snapshotted.
# Addendum: resume correctness (post-merge findings)
Two real-world failures against a kept kernel session, both fixed:
- **The kernel's config chain is stamp-blind** — `stamp-prepare-%`
(`debian/rules.d/2-binary-arch.mk`) exports `.config` from the
annotations with no config prerequisite, so a resumed build silently
kept the previous attempt's configuration; the config edit never
reached the .deb even though `debian/rules build` ran. Dropping the
stamp cache unconditionally would be a package-specific decision, so
it is data-driven: the kernels declare `resume_clear: [debian/stamps]`
in `data/quirks.yml`, and resumed builds remove those tree-relative
paths before `debian/rules build`. The inner kbuild keeps its own
state, so the re-run wrappers stay cheap and only config-affected
objects recompile.
- **A SIGKILLed build (OOM) poisons the next one.** The crashed attempt
leaves its overlay mounts and /proc bind mount behind, and overlayfs
creates root-owned `work` state inside the workdir; the next PLAIN
build (recording is always on: it replaces the session of its
identity) failed to clear the leftovers with a permission error.
`clear_dir()` now unmounts everything under each entry at depth
(re-reading /proc/mounts, tolerating mount stacks from consecutive
crashes) and escalates through sudo; if the tree still cannot be
cleared, the plain build reports it and continues WITHOUT a session
instead of failing or layering over a half-cleared tree.
-115
View File
@@ -1,115 +0,0 @@
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,
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: classic
apps:
pkh:
command: bin/pkh
parts:
pkh-part:
plugin: rust
source: .
override-pull: |
craftctl default
# 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
# mount/umount moved to their own package (split from util-linux)
- mount
- schroot
- openssh-client
- tar
- xz-utils
- bzip2
stage:
- -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 {} +
-382
View File
@@ -1,382 +0,0 @@
//! APT keyring management for mmdebstrap and PPA packages
//!
//! Provides functions to ensure that archive keyrings are available
//! 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 {
signing_key_fingerprint: String,
}
/// Download keyrings to a shared keyring directory and return the directory path
///
/// This function downloads keyrings to a user-writable cache directory
/// instead of the system apt keyring directory, allowing non-root usage.
/// The returned directory path can be passed to mmdebstrap via --keyring=.
///
/// For Debian keyrings (which are ASCII-armored .asc files), the keys are
/// converted to binary GPG format using gpg --dearmor.
///
/// For 'sid' and 'experimental', this downloads keyrings from the 3 latest
/// releases since sid needs keys from all recent releases.
///
/// # Arguments
/// * `ctx` - Optional context to use
/// * `series` - The distribution series (e.g., "noble", "sid")
///
/// # Returns
/// The path to the keyring directory containing all downloaded keyring files
pub async fn download_cache_keyrings(
ctx: Option<Arc<context::Context>>,
series: &str,
) -> Result<PathBuf, Box<dyn Error>> {
let ctx = ctx.unwrap_or_else(context::current);
// Obtain keyring URLs from distro_info
let keyring_urls = distro_info::get_keyring_urls(series).await?;
log::debug!("Downloading keyrings from: {:?}", keyring_urls);
// 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();
// 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}"));
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()?;
}
for keyring_url in keyring_urls {
// Extract the original filename from the keyring URL
let filename = keyring_url
.split('/')
.next_back()
.unwrap_or("pkh-{}.gpg")
.replace("{}", series);
let download_path = keyring_dir.join(&filename);
// Determine the binary keyring path
let binary_path = if filename.ends_with(".asc") {
// ASCII-armored key: convert to .gpg
let binary_filename = filename.strip_suffix(".asc").unwrap_or(&filename);
keyring_dir.join(format!("{}.gpg", binary_filename))
} else {
download_path.clone()
};
// Skip download if the binary keyring already exists
if !ctx.exists(&binary_path)? {
// Download the keyring using curl
let mut curl_cmd = ctx.command("curl");
curl_cmd
.arg("-s")
.arg("-f")
.arg("-L")
.arg(&keyring_url)
.arg("--output")
.arg(&download_path);
let status = curl_cmd.status()?;
if !status.success() {
return Err(format!("Failed to download keyring from {}", keyring_url).into());
}
// If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format
if filename.ends_with(".asc") {
log::debug!("Converting ASCII-armored key to binary GPG format");
let mut gpg_cmd = ctx.command("gpg");
gpg_cmd
.arg("--dearmor")
.arg("--output")
.arg(&binary_path)
.arg(&download_path);
let status = gpg_cmd.status()?;
if !status.success() {
return Err("Failed to convert keyring to binary format"
.to_string()
.into());
}
// Remove the original .asc file
let _ = ctx.command("rm").arg("-f").arg(&download_path).status();
}
log::info!(
"Successfully downloaded keyring for {} to {}",
series,
binary_path.display()
);
} else {
log::debug!(
"Keyring already exists at {}, skipping download",
binary_path.display()
);
}
// 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!(
"Keyrings for {} available in {}",
series,
keyring_dir.display()
);
Ok(keyring_dir)
}
/// 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
/// * `ppa_owner` - PPA owner (username)
/// * `ppa_name` - PPA name
///
/// # Returns
/// Result indicating success or failure
pub async fn download_trust_ppa_key(
ctx: Option<Arc<context::Context>>,
ppa_owner: &str,
ppa_name: &str,
) -> Result<(), Box<dyn Error>> {
let ctx = ctx.unwrap_or_else(context::current);
// Create trusted.gpg.d directory if it doesn't exist
let trusted_gpg_d = "/etc/apt/trusted.gpg.d";
if !ctx.exists(Path::new(trusted_gpg_d))? {
ctx.command("mkdir").arg("-p").arg(trusted_gpg_d).status()?;
}
let key_filename = format!("{}-{}.asc", ppa_owner, ppa_name);
let key_path = format!("{}/{}", trusted_gpg_d, key_filename);
log::debug!(
"Retrieving PPA key for {}/{} using Launchpad API",
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 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 {}/{}: HTTP {}",
ppa_owner,
ppa_name,
response.status()
)
.into());
}
// Parse the JSON response to extract the signing key fingerprint
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!(
"Failed to parse JSON response from Launchpad API for {}/{}: {}",
ppa_owner, ppa_name, e
)
})?;
let fingerprint = ppa_response.signing_key_fingerprint;
log::debug!("Found PPA signing key fingerprint: {}", fingerprint);
// Download the actual key from the keyserver using the fingerprint
let keyserver_url = keyserver_lookup_url(&fingerprint);
log::debug!("Downloading key from keyserver: {}", keyserver_url);
let mut curl_cmd = ctx.command("curl");
curl_cmd
.arg("-s")
.arg("-f")
.arg("-L")
.arg(&keyserver_url)
.arg("--output")
.arg(&key_path);
let status = curl_cmd.status()?;
if !status.success() {
return Err(format!(
"Failed to download PPA key from keyserver for fingerprint {}",
fingerprint
)
.into());
}
log::info!(
"Successfully downloaded and installed PPA key for {}/{} (fingerprint: {}) to {}",
ppa_owner,
ppa_name,
fingerprint,
key_path
);
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());
}
}
-4
View File
@@ -1,4 +0,0 @@
pub mod keyring;
/// Release-file signature and checksum verification for repositories
pub mod release;
pub mod sources;
-1303
View File
File diff suppressed because it is too large Load Diff
-765
View File
@@ -1,765 +0,0 @@
//! APT sources.list management
//! Provides a simple structure for managing APT repository sources
//!
//! 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, 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, 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 {
/// 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);
}
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 raw = data.lines().next()?.trim();
if raw.is_empty() {
return None;
}
// 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
if let Some(start_bracket) = line.find('[')
&& let Some(end_bracket) = line.find(']')
{
let bracket_content = &line[start_bracket + 1..end_bracket];
// Parse parameters inside brackets
for param in bracket_content.split_whitespace() {
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");
}
}
// Remove the bracket section from the line
line_without_brackets = line[..start_bracket].to_string() + &line[end_bracket + 1..];
}
// Trim and split the remaining line
let line_without_brackets = line_without_brackets.trim();
let parts: Vec<&str> = line_without_brackets.split_whitespace().collect();
// 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,
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 {
if !self.enabled {
result.push_str("# ");
}
result.push_str(self.kind.as_str());
// Bracket options: architectures, signing keyring and trust
let mut options = Vec::new();
if !self.architectures.is_empty() {
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(']');
}
// Add URI and suite
result.push(' ');
result.push_str(&self.uri);
result.push(' ');
result.push_str(suite);
// Add components
if !self.components.is_empty() {
result.push(' ');
result.push_str(&self.components.join(" "));
}
result.push('\n');
}
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> {
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')
.flat_map(SourceEntry::from_legacy)
.collect()
}
/// Load sources from context (or current context by default)
///
/// 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+)
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
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 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)?;
}
Ok(())
}
/// 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(());
}
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(())
}
/// 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]));
}
}
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() {
let deb822 = "\
Types: deb\n\
URIs: http://fr.archive.ubuntu.com/ubuntu/\n\
Suites: questing questing-updates questing-backports\n\
Components: main restricted universe multiverse\n\
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\
Architectures: amd64\n\
\n\
Types: deb\n\
URIs: http://security.ubuntu.com/ubuntu/\n\
Suites: questing-security\n\
Components: main restricted universe multiverse\n\
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\
Architectures: amd64\n\
\n\
Types: deb\n\
URIs: http://ports.ubuntu.com/ubuntu-ports/\n\
Suites: questing questing-updates questing-backports\n\
Components: main restricted universe multiverse\n\
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n\
Architectures: riscv64\n\
";
let sources = parse_deb822(deb822);
assert_eq!(sources.len(), 3);
assert_eq!(sources[0].uri, "http://fr.archive.ubuntu.com/ubuntu/");
assert_eq!(sources[0].architectures, vec!["amd64"]);
assert_eq!(
sources[0].suite,
vec!["questing", "questing-updates", "questing-backports"]
);
assert_eq!(
sources[0].components,
vec!["main", "restricted", "universe", "multiverse"]
);
assert_eq!(sources[1].uri, "http://security.ubuntu.com/ubuntu/");
assert_eq!(sources[1].architectures, vec!["amd64"]);
assert_eq!(sources[1].suite, vec!["questing-security"]);
assert_eq!(
sources[1].components,
vec!["main", "restricted", "universe", "multiverse"]
);
assert_eq!(sources[2].uri, "http://ports.ubuntu.com/ubuntu-ports/");
assert_eq!(sources[2].architectures.len(), 1);
assert_eq!(sources[2].architectures, vec!["riscv64"]);
assert_eq!(
sources[2].suite,
vec!["questing", "questing-updates", "questing-backports"]
);
assert_eq!(
sources[2].components,
vec!["main", "restricted", "universe", "multiverse"]
);
}
#[tokio::test]
async fn test_parse_legacy() {
let legacy = "\
deb [signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\" arch=amd64] http://archive.ubuntu.com/ubuntu resolute main universe\n\
deb [arch=amd64,i386 signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\"] http://archive.ubuntu.com/ubuntu resolute-updates main\n\
deb [signed-by=\"/usr/share/keyrings/ubuntu-archive-keyring.gpg\"] http://security.ubuntu.com/ubuntu resolute-security main\n\
";
let sources = parse_legacy(legacy);
assert_eq!(sources.len(), 3);
assert_eq!(sources[0].uri, "http://archive.ubuntu.com/ubuntu");
assert_eq!(sources[0].suite, vec!["resolute"]);
assert_eq!(sources[0].components, vec!["main", "universe"]);
assert_eq!(sources[0].architectures, vec!["amd64"]);
assert_eq!(sources[1].uri, "http://archive.ubuntu.com/ubuntu");
assert_eq!(sources[1].suite, vec!["resolute-updates"]);
assert_eq!(sources[1].components, vec!["main"]);
assert_eq!(sources[1].architectures, vec!["amd64", "i386"]);
assert_eq!(sources[2].uri, "http://security.ubuntu.com/ubuntu");
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"
);
}
}
+25
View File
@@ -0,0 +1,25 @@
use std::error::Error;
use std::path::Path;
use std::process::Command;
pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new("."));
let status = Command::new("dpkg-buildpackage")
.current_dir(cwd)
.args(["-S", "-I", "-i", "-nc", "-d"])
.status()?;
if !status.success() {
return Err(format!("dpkg-buildpackage failed with status: {}", status).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
// We are not testing the build part, as for now this is just a wrapper
// around dpkg-buildpackage.
}
-655
View File
@@ -1,655 +0,0 @@
//! 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}"
);
}
}
-577
View File
@@ -1,577 +0,0 @@
//! 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);
}
}
-163
View File
@@ -1,163 +0,0 @@
//! 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");
}
}
-385
View File
@@ -1,385 +0,0 @@
//! 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(""))
);
}
}
-440
View File
@@ -1,440 +0,0 @@
//! 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
View File
File diff suppressed because it is too large Load Diff
+112 -1137
View File
File diff suppressed because it is too large Load Diff
+26 -385
View File
@@ -5,62 +5,13 @@ 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;
use super::unshare::UnshareDriver;
/// Outcome of an overlay-aware staging
/// ([`ContextDriver::ensure_available_with_overlay`]).
#[derive(Debug, Clone)]
pub struct OverlayStaging {
/// The staged path as it appears inside the context.
pub path: PathBuf,
/// Whether the staging reused the persistent overlay upperdir (build
/// artifacts of a previous attempt are visible); `false` means a fresh
/// copy was staged instead.
pub overlay: bool,
}
/// A ContextDriver is the interface for the logic happening inside a context
pub trait ContextDriver {
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf>;
/// Stage `src` at `dest_root` over the given persistent overlay
/// upperdir/workdir (host-side paths), for resumable build sessions.
///
/// The default implementation rejects the request; drivers that cannot
/// overlay-mount fall back to a plain copy in their caller.
fn ensure_available_with_overlay(
&self,
_src: &Path,
_dest_root: &str,
_upper: &Path,
_work: &Path,
) -> io::Result<OverlayStaging> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"overlay staging is not supported by this context driver",
))
}
fn retrieve_path(&self, src: &Path, dest: &Path) -> io::Result<()>;
fn list_files(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn run(
@@ -70,23 +21,6 @@ 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,
@@ -98,19 +32,6 @@ pub trait ContextDriver {
fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()>;
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.
fn cleanup(&self) -> io::Result<()> {
Ok(()) // default no-op
}
}
/// Represents an execution environment (Local or via SSH).
@@ -120,114 +41,36 @@ pub trait ContextDriver {
#[serde(tag = "type")]
#[derive(Default)]
pub enum ContextConfig {
/// Local context: actions executed locally
#[serde(rename = "local")]
#[default]
Local,
/// SSH context: actions over an SSH connection
#[serde(rename = "ssh")]
Ssh {
/// Host for the SSH connection
host: String,
/// User for the SSH connection
user: Option<String>,
/// TCP port for the SSH connection
port: Option<u16>,
},
/// Schroot context: using `schroot`
#[serde(rename = "schroot")]
Schroot {
/// Name of the schroot
name: String,
/// Optional parent context for the Schroot context
parent: Option<String>,
},
/// Unshare context: chroot with dropped permissions (using `unshare`)
#[serde(rename = "unshare")]
Unshare {
/// Path to use for chrooting
path: String,
/// Optional parent context for the Unshare context
parent: Option<String>,
},
}
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
pub config: ContextConfig,
/// Parent context for the context
///
/// For example, you could have a chroot context over an ssh connection
pub parent: Option<Arc<Context>>,
/// ContextDriver for the context, implementing the logic for actions
driver: Mutex<Option<Box<dyn ContextDriver + Send + Sync>>>,
}
impl Context {
/// Create a context from configuration
///
/// 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 {
pub fn new(config: ContextConfig) -> Self {
let parent = match &config {
ContextConfig::Schroot {
parent: Some(parent_name),
..
@@ -235,40 +78,25 @@ impl Context {
| ContextConfig::Unshare {
parent: Some(parent_name),
..
} => parent_name.clone(),
_ => {
return Ok(Self {
config,
parent: None,
driver: Mutex::new(None),
});
} => {
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)))
}
_ => 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 {
Self {
config,
parent: Some(Arc::new(parent?)),
parent,
driver: Mutex::new(None),
})
}
}
/// Create a context with an explicit parent context
pub fn with_parent(config: ContextConfig, parent: Arc<Context>) -> Self {
Self {
config,
@@ -277,25 +105,13 @@ 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![
("LANG".to_string(), "C".to_string()),
("LC_ALL".to_string(), "C".to_string()),
],
env: Vec::new(),
cwd: None,
sink: None,
}
}
@@ -310,26 +126,6 @@ impl Context {
.ensure_available(src, dest_root)
}
/// Stage `src` at `dest_root` reusing the given overlay upperdir and
/// workdir (host-side paths), so build artifacts written inside the
/// context during a previous attempt survive into this one. Falls back
/// to a fresh copy when the driver does not support overlay staging or
/// the mount fails (the returned [`OverlayStaging::overlay`] says
/// which happened).
pub fn ensure_available_with_overlay(
&self,
src: &Path,
dest_root: &str,
upper: &Path,
work: &Path,
) -> io::Result<OverlayStaging> {
self.driver()
.as_ref()
.unwrap()
.ensure_available_with_overlay(src, dest_root, upper, work)
}
/// Create a temp directory inside context
pub fn create_temp_dir(&self) -> io::Result<String> {
self.driver().as_ref().unwrap().create_temp_dir()
}
@@ -347,42 +143,18 @@ impl Context {
self.driver().as_ref().unwrap().list_files(path)
}
/// Copy a path inside context
pub fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
self.driver().as_ref().unwrap().copy_path(src, dest)
}
/// Read a file inside context
pub fn read_file(&self, path: &Path) -> io::Result<String> {
self.driver().as_ref().unwrap().read_file(path)
}
/// Write a file inside context
pub fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
self.driver().as_ref().unwrap().write_file(path, content)
}
/// Check if a file or directory exists inside context
pub fn exists(&self, path: &Path) -> io::Result<bool> {
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<()> {
self.driver().as_ref().unwrap().cleanup()
}
/// Create and obtain a specific driver for the context
pub fn driver(
&self,
) -> std::sync::MutexGuard<'_, Option<Box<dyn ContextDriver + Send + Sync>>> {
@@ -403,17 +175,13 @@ impl Context {
ContextConfig::Unshare { path, .. } => Box::new(UnshareDriver {
path: path.clone(),
parent: self.parent.clone(),
overlay_mounts: std::sync::Mutex::new(Vec::new()),
}),
};
// 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 = Some(driver);
}
driver_lock
}
/// Clone a context
pub fn clone_raw(&self) -> Self {
Self {
config: self.config.clone(),
@@ -436,17 +204,15 @@ pub struct ContextCommand<'a> {
args: Vec<String>,
env: Vec<(String, String)>,
cwd: Option<String>,
sink: Option<Arc<dyn LineSink>>,
}
impl<'a> ContextCommand<'a> {
/// Add an argument to current command
pub fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
self.args.push(arg.as_ref().to_string_lossy().to_string());
self
}
/// Add multiple command arguments
// Support chaining args
pub fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
@@ -458,7 +224,6 @@ impl<'a> ContextCommand<'a> {
self
}
/// Set environment variable for command
pub fn env<K, V>(&mut self, key: K, val: V) -> &mut Self
where
K: AsRef<OsStr>,
@@ -471,7 +236,6 @@ impl<'a> ContextCommand<'a> {
self
}
/// Set multiple environment variables for command
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
@@ -484,150 +248,27 @@ impl<'a> ContextCommand<'a> {
self
}
/// Set current working directory for command
pub fn current_dir<P: AsRef<OsStr>>(&mut self, dir: P) -> &mut Self {
self.cwd = Some(dir.as_ref().to_string_lossy().to_string());
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();
let driver_guard = self.context.driver();
let driver = driver_guard.as_ref().unwrap();
let result = match &self.sink {
Some(sink) => driver.run_captured(
self.context.driver().as_ref().unwrap().run(
&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
pub fn output(&mut self) -> io::Result<std::process::Output> {
let program = self.program.clone();
self.context
.driver()
.as_ref()
.unwrap()
.run_output(&self.program, &self.args, &self.env, self.cwd.as_deref())
.map_err(|e| contextualize_spawn_error(&program, e))
}
}
/// Wrap an I/O error from launching a command with a more helpful message.
///
/// In particular, a `NotFound` error (e.g. "No such file or directory") is almost always
/// caused by the requested program not being installed or not on `PATH`; we make that
/// explicit instead of leaking the raw OS error.
fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
if e.kind() == io::ErrorKind::NotFound {
io::Error::new(
e.kind(),
format!(
"Could not run '{program}': {e}. \
The program does not seem to be installed or is not on PATH; \
install the corresponding package and retry.",
),
)
} else {
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");
// Capture output
pub fn output(&mut self) -> io::Result<std::process::Output> {
self.context.driver().as_ref().unwrap().run_output(
&self.program,
&self.args,
&self.env,
self.cwd.as_deref(),
)
}
}
-125
View File
@@ -1,125 +0,0 @@
//! 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()),
]
);
}
}
+4 -138
View File
@@ -1,13 +1,9 @@
/// Local context: execute commands locally
/// Context driver: Does nothing
use super::api::{ContextDriver, LineSink, Stream};
use super::capture::pump;
use super::api::ContextDriver;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::SystemTime;
use std::process::Command;
pub struct LocalDriver;
@@ -24,32 +20,8 @@ impl ContextDriver for LocalDriver {
}
fn create_temp_dir(&self) -> io::Result<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_millis();
let mut attempt = 0;
loop {
let work_dir_name = if attempt == 0 {
format!("pkh-{base_timestamp}")
} else {
format!("pkh-{base_timestamp}-{attempt}")
};
let temp_dir_path = std::env::temp_dir().join(&work_dir_name);
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),
}
}
let temp_dir = tempfile::Builder::new().prefix("pkh-").tempdir()?;
Ok(temp_dir.keep().to_string_lossy().to_string())
}
fn retrieve_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
@@ -80,51 +52,6 @@ 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,
@@ -151,39 +78,14 @@ impl ContextDriver for LocalDriver {
fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
std::fs::write(path, content)
}
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<()> {
// Reproduce symlinks as symlinks rather than following them, so that
// dangling/absolute symlinks do not abort the copy.
if std::fs::symlink_metadata(src)?.file_type().is_symlink() {
let target = std::fs::read_link(src)?;
let _ = std::fs::remove_file(dest);
return symlink(&target, dest);
}
if src.is_dir() {
std::fs::create_dir_all(dest)?;
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)?;
}
@@ -192,39 +94,3 @@ 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();
}
}
}
+43 -163
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
@@ -26,7 +26,6 @@ impl Default for Config {
}
}
/// Helper managing contexts
pub struct ContextManager {
context: RwLock<Arc<Context>>,
config_path: PathBuf,
@@ -48,109 +47,39 @@ impl ContextManager {
fs::create_dir_all(config_dir)?;
let config_path = config_dir.join("contexts.json");
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")
})
}
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()
};
Ok(Self {
context: RwLock::new(Arc::new(initial)),
context: RwLock::new(Arc::new(Self::make_context(
config.context.as_str(),
&config,
))),
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()
}
/// Make a ContextManager using a specific configuration path
pub fn with_path(path: PathBuf) -> Self {
let config = Config::default();
Self {
// '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"),
)),
context: RwLock::new(Arc::new(Self::make_context("local", &config))),
config_path: path,
config: RwLock::new(config),
}
}
/// Save current context configuration to disk
pub fn save(&self) -> io::Result<()> {
let config = self.config.read().unwrap();
let content = serde_json::to_string_pretty(&*config)
@@ -159,25 +88,15 @@ impl ContextManager {
Ok(())
}
/// 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())
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)
}
/// List contexts from configuration
pub fn list_contexts(&self) -> Vec<String> {
self.config
.read()
@@ -188,7 +107,6 @@ impl ContextManager {
.collect()
}
/// Add a context to configuration
pub fn add_context(&self, name: &str, config: ContextConfig) -> io::Result<()> {
self.config
.write()
@@ -198,89 +116,51 @@ impl ContextManager {
self.save()
}
/// 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",
));
}
// 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 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();
Some(config.contexts.clone())
} else {
None
}
};
if let Some(contexts) = fallback_contexts {
self.set_current_ephemeral(Self::make_context("local", &contexts)?);
}
self.save()?;
Ok(())
self.set_current_ephemeral(Self::make_context("local", &config));
}
/// Set current context from name (modifying configuration)
pub fn set_current(&self, name: &str) -> io::Result<()> {
// 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 '{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
///
/// 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>>) {
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(
io::ErrorKind::NotFound,
format!("Context '{}' not found", name),
))
}
}
pub fn set_current_ephemeral(&self, context: Context) {
*self.context.write().unwrap() = context.into();
}
/// Obtain current context handle
pub fn current(&self) -> Arc<Context> {
self.context.read().unwrap().clone()
}
/// Obtain current context name
/// Will not work for ephemeral context (obtained from config)
pub fn current_name(&self) -> String {
self.config.read().unwrap().context.clone()
}
+3 -294
View File
@@ -1,73 +1,22 @@
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, LineSink, OverlayStaging, 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 api::{Context, ContextCommand, ContextConfig};
pub use manager::ContextManager;
use std::sync::Arc;
/// Obtain global context manager
pub fn manager() -> &'static ContextManager {
&manager::MANAGER
}
/// Obtain current context
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::*;
@@ -80,7 +29,7 @@ mod tests {
let src_file = temp_dir.path().join("src.txt");
fs::write(&src_file, "local").unwrap();
let ctx = Context::new(ContextConfig::Local).unwrap();
let ctx = Context::new(ContextConfig::Local);
let dest = ctx.ensure_available(&src_file, "/tmp").unwrap();
// Should return a path that exists and has the same content
@@ -159,147 +108,10 @@ 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).unwrap();
let ctx = Context::new(ContextConfig::Local);
let file_path = temp_dir.path().join("test.txt");
let content = "hello world";
@@ -333,107 +145,4 @@ mod tests {
"subcontent"
);
}
/// Copying a tree containing a dangling symlink must reproduce the link
/// itself rather than failing to follow it (which previously surfaced as
/// a bare "No such file or directory (os error 2)").
#[test]
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).unwrap();
let src_dir = temp_dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
// A regular file alongside the symlink, to ensure normal copies still work.
std::fs::write(src_dir.join("real.txt"), "data").unwrap();
// A dangling symlink pointing to a non-existent target.
symlink("/nonexistent/target", src_dir.join("dangling")).unwrap();
let dest_dir = temp_dir.path().join("dest");
ctx.copy_path(&src_dir, &dest_dir).unwrap();
// The regular file was copied.
assert_eq!(ctx.read_file(&dest_dir.join("real.txt")).unwrap(), "data");
// The symlink was reproduced as a symlink (not followed).
let meta = std::fs::symlink_metadata(dest_dir.join("dangling")).unwrap();
assert!(meta.file_type().is_symlink());
assert_eq!(
std::fs::read_link(dest_dir.join("dangling")).unwrap(),
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());
}
}
+83 -198
View File
@@ -1,7 +1,6 @@
/// Schroot context: execute commands in a schroot session
/// Not tested, will need more work!
use super::api::{Context, ContextConfig, ContextDriver, LineSink};
use super::shell::shell_quote;
use super::api::ContextDriver;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -12,12 +11,13 @@ 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(|| {
// ContextConfig::Local has no parent, so this cannot fail.
Arc::new(Context::new(ContextConfig::Local).expect("Local context cannot fail"))
})
self.parent
.clone()
.unwrap_or_else(|| Arc::new(Context::new(ContextConfig::Local)))
}
fn ensure_session(&self) -> io::Result<String> {
@@ -72,80 +72,6 @@ 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 {
@@ -193,26 +119,47 @@ 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));
}
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()
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_output(
@@ -223,8 +170,41 @@ impl ContextDriver for SchrootDriver {
cwd: Option<&str>,
) -> io::Result<std::process::Output> {
let session_id = self.ensure_session()?;
let cmd_args = Self::schroot_args(&session_id, false, program, args, env, cwd);
self.parent().command("schroot").args(cmd_args).output()
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()
}
fn create_temp_dir(&self) -> io::Result<String> {
@@ -269,13 +249,9 @@ impl ContextDriver for SchrootDriver {
&[
"-c".to_string(),
format!(
// `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())
"echo -ne '{}' > '{}'",
content.replace("'", "'\\''"),
path.to_string_lossy()
),
],
&[],
@@ -286,95 +262,4 @@ impl ContextDriver for SchrootDriver {
}
Ok(())
}
fn exists(&self, path: &Path) -> io::Result<bool> {
let status = self.run(
"test",
&["-e".to_string(), path.to_string_lossy().to_string()],
&[],
None,
)?;
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"
);
}
}
-109
View File
@@ -1,109 +0,0 @@
//! 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);
}
}
+35 -187
View File
@@ -1,8 +1,6 @@
/// SSH context: execute commands over an SSH connection
/// Context driver: Copies over SFTP with ssh2, executes commands over ssh2 channels
use super::api::{ContextDriver, LineSink, Stream};
use super::capture::pump;
use super::shell::shell_quote;
use super::api::ContextDriver;
use log::debug;
use ssh2;
use std::fs;
@@ -13,7 +11,6 @@ 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);
@@ -54,41 +51,6 @@ 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)?;
@@ -142,7 +104,22 @@ impl ContextDriver for SshDriver {
// Construct command line with env vars
// TODO: No, use ssh2 channel.set_env
let cmd_line = Self::build_command_line(env, cwd, program, args);
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
}
debug!("Executing SSH command: {}", cmd_line);
@@ -162,40 +139,6 @@ 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,
@@ -206,8 +149,23 @@ 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 (same quoting as `run`)
let cmd_line = Self::build_command_line(env, cwd, program, args);
// 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
}
channel.exec(&cmd_line).map_err(io::Error::other)?;
@@ -257,11 +215,7 @@ 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 {} {}",
shell_quote(&src.to_string_lossy()),
shell_quote(&dest.to_string_lossy())
);
let cmd = format!("cp -a {:?} {:?}", src, dest);
debug!("Executing remote copy: {}", cmd);
channel.exec(&cmd).map_err(io::Error::other)?;
channel.wait_close().map_err(io::Error::other)?;
@@ -288,32 +242,8 @@ 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(())
}
fn exists(&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)?;
match sftp.stat(path) {
Ok(_) => Ok(true),
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 {
@@ -325,14 +255,6 @@ 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)?;
}
@@ -342,18 +264,6 @@ 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(())
}
@@ -382,65 +292,3 @@ 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");
}
}
+29 -486
View File
@@ -1,167 +1,46 @@
use super::api::{Context, ContextCommand, ContextDriver, LineSink};
use super::shell::shell_quote;
use super::api::{Context, ContextCommand, ContextDriver};
use log::debug;
use std::fs;
use std::io;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
pub struct UnshareDriver {
pub path: String,
pub parent: Option<Arc<super::api::Context>>,
/// Host-side paths where overlay filesystems are mounted, tracked for cleanup.
pub overlay_mounts: Mutex<Vec<PathBuf>>,
}
/// Recursively copy a directory and all its contents.
///
/// Symlinks are copied as symlinks (preserving the link target rather than
/// following it), so that dangling links do not abort the copy.
/// Recursively copy a directory and all its contents
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
// Create the destination directory
std::fs::create_dir_all(dest).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to create directory '{}': {}", dest.display(), e),
)
})?;
std::fs::create_dir_all(dest)?;
// Iterate through the source directory
let read = std::fs::read_dir(src).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read directory '{}': {}", src.display(), e),
)
})?;
for entry in read {
let entry = entry.map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read entry in '{}': {}", src.display(), e),
)
})?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
// Handle symlinks explicitly: reproduce the link itself instead of
// following it. This avoids failing on dangling/absolute symlinks.
let metadata = match std::fs::symlink_metadata(&src_path) {
Ok(m) => m,
Err(e) => {
return Err(io::Error::new(
e.kind(),
format!("Failed to read metadata of '{}': {}", src_path.display(), e),
));
}
};
// 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(
e.kind(),
format!("Failed to read symlink '{}': {}", src_path.display(), e),
)
})?;
// Remove an existing destination entry (e.g. from a previous attempt)
let _ = fs::remove_file(&dest_path);
symlink(&target, &dest_path).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"Failed to create symlink '{}' -> '{}': {}",
dest_path.display(),
target.display(),
e
),
)
})?;
} else if src_path.is_dir() {
if src_path.is_dir() {
// Recursively copy subdirectories
copy_dir_recursive(&src_path, &dest_path)?;
} else {
// Copy regular files, preserving the source modification and
// access times. This is important for autotools/gnulib-based
// packages (e.g. 'hello' from Debian sid) that ship pre-generated
// files alongside their prerequisites: if the copy resets the
// mtime to "now", the prerequisites appear as new as the
// generated targets and `make` tries to regenerate them using
// tools (like gperf) that are not declared build-dependencies.
copy_file_with_times(&src_path, &dest_path)?;
// Copy files
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
/// Copy a single regular file from `src` to `dest`, preserving the source's
/// modification and access times.
///
/// `std::fs::copy` resets the destination mtime to "now", which breaks
/// timestamp-based build systems (autotools/gnulib) that ship pre-generated
/// files alongside their prerequisites. Restoring the original timestamps
/// prevents `make` from needlessly regenerating those files with tools that
/// may not be installed (e.g. `gperf`).
fn copy_file_with_times(src: &Path, dest: &Path) -> io::Result<()> {
std::fs::copy(src, dest).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"Failed to copy '{}' to '{}': {}",
src.display(),
dest.display(),
e
),
)
})?;
// Restore the original timestamps on the destination file.
let metadata = std::fs::metadata(src).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to read metadata of '{}': {}", src.display(), e),
)
})?;
let mut times = std::fs::FileTimes::new();
let mut have_times = false;
if let Ok(mtime) = metadata.modified() {
times = times.set_modified(mtime);
have_times = true;
}
if let Ok(atime) = metadata.accessed() {
times = times.set_accessed(atime);
have_times = true;
}
if have_times && let Ok(dest_file) = std::fs::File::open(dest) {
let _ = dest_file.set_times(times).map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to set times on '{}': {}", dest.display(), e),
)
});
}
Ok(())
}
/// Check whether the overlay filesystem is available on this system.
fn is_overlayfs_available() -> bool {
let content = std::fs::read_to_string("/proc/filesystems").unwrap_or_default();
content.contains("overlay\n") || content.contains("overlay\t")
}
impl ContextDriver for UnshareDriver {
fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result<PathBuf> {
// Construct the destination path inside the chroot
let dest_dir = Path::new(&self.path).join(dest_root.trim_start_matches('/'));
debug!(
"unshare/ensure_available: copy '{}' to '{}'",
src.display(),
dest_dir.display()
);
// Ensure the destination directory exists
std::fs::create_dir_all(&dest_dir)?;
@@ -174,34 +53,7 @@ impl ContextDriver for UnshareDriver {
// Construct the full destination path
let dest_path = dest_dir.join(filename);
// Try overlayfs for directories first — avoids a potentially expensive full copy
if src.is_dir() && is_overlayfs_available() {
match self.mount_overlay(src, &dest_path, None, None) {
Ok(()) => {
debug!(
"Mounted overlay for {} at {}",
src.display(),
dest_path.display()
);
return Ok(Path::new(dest_root).join(filename));
}
Err(e) => {
debug!(
"Overlay mount failed for '{}', falling back to copy: {}",
src.display(),
e
);
}
}
}
// Fallback: copy the file or directory into the chroot
debug!(
"unshare/ensure_available: copy '{}' to '{}'",
src.display(),
dest_dir.display()
);
// Copy the file or directory into the chroot
if src.is_dir() {
copy_dir_recursive(src, &dest_path)?;
debug!(
@@ -210,7 +62,7 @@ impl ContextDriver for UnshareDriver {
dest_path.display()
);
} else {
copy_file_with_times(src, &dest_path)?;
std::fs::copy(src, &dest_path)?;
debug!("Copied file {} to {}", src.display(), dest_path.display());
}
@@ -218,71 +70,6 @@ impl ContextDriver for UnshareDriver {
Ok(Path::new(dest_root).join(filename))
}
fn ensure_available_with_overlay(
&self,
src: &Path,
dest_root: &str,
upper: &Path,
work: &Path,
) -> io::Result<super::api::OverlayStaging> {
let dest_dir = Path::new(&self.path).join(dest_root.trim_start_matches('/'));
std::fs::create_dir_all(&dest_dir)?;
let filename = src
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid source path"))?;
let dest_path = dest_dir.join(filename);
// The upper/work dirs are the persistent ones of a build session:
// mounting them again exposes the previous attempt's build
// artifacts over the (live) host tree. Mount failure is an ERROR,
// never a silent copy fallback: a copy would merge the host tree
// into the stale staged tree of the previous attempt (old build
// stamps, dropped host deletions) — the caller decides to wipe and
// re-stage instead.
if !src.is_dir() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"overlay staging only applies to directories",
));
}
if !is_overlayfs_available() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"overlayfs is not available on this system",
));
}
self.mount_overlay(src, &dest_path, Some(upper), Some(work))?;
debug!(
"Mounted overlay for {} at {} (reused upper {})",
src.display(),
dest_path.display(),
upper.display()
);
Ok(super::api::OverlayStaging {
path: Path::new(dest_root).join(filename),
overlay: true,
})
}
fn cleanup(&self) -> io::Result<()> {
let mounts = self.overlay_mounts.lock().unwrap();
for mount_path in mounts.iter() {
debug!("Unmounting overlay at {}", mount_path.display());
let is_root = crate::utils::root::is_root().unwrap_or(false);
let mut cmd = self
.parent()
.command(if is_root { "umount" } else { "sudo" });
if !is_root {
cmd.arg("umount");
}
let status = cmd.arg(mount_path.to_string_lossy().to_string()).status()?;
if !status.success() {
log::warn!("Failed to unmount overlay at {}", mount_path.display());
}
}
Ok(())
}
fn retrieve_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
let host_src = Path::new(&self.path).join(src.to_string_lossy().trim_start_matches('/'));
self.parent().retrieve_path(&host_src, dest)
@@ -314,22 +101,6 @@ 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,
@@ -341,45 +112,27 @@ 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()
// Create a temporary directory inside the chroot
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
.as_secs();
let mut attempt = 0;
loop {
let work_dir_name = if attempt == 0 {
format!("pkh-build-{base_timestamp}")
} else {
format!("pkh-build-{base_timestamp}-{attempt}")
};
let work_dir_name = format!("pkh-build-{}", timestamp);
let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name);
let work_dir_inside_chroot = format!("/tmp/{work_dir_name}");
// Create the directory on the host filesystem
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
std::fs::create_dir_all(&host_path)?;
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),
}
// Return the path as it appears inside the chroot
return Ok(work_dir_inside_chroot);
}
Ok(work_dir_inside_chroot)
}
fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
@@ -397,16 +150,6 @@ impl ContextDriver for UnshareDriver {
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
self.parent().write_file(&host_path, content)
}
fn exists(&self, path: &Path) -> io::Result<bool> {
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 {
@@ -416,117 +159,6 @@ impl UnshareDriver {
.expect("UnshareDriver requires a parent context")
}
/// Mount `src` as an overlay at `dest_path` inside the chroot.
///
/// `upper`/`work` override the per-mount random directories: the
/// resumable-session staging passes its persistent upperdir so build
/// artifacts survive across attempts. On success, the overlay is
/// tracked in `overlay_mounts` for later cleanup. On failure, the
/// caller should fall back to the copy-based approach.
fn mount_overlay(
&self,
src: &Path,
dest_path: &Path,
upper: Option<&Path>,
work: Option<&Path>,
) -> io::Result<()> {
// Create unique upper/work dirs inside the chroot unless the caller
// pinned them (session reuse).
let overlay_base = Path::new(&self.path).join("pkh-overlay");
let id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let upper_dir = upper
.map(Path::to_path_buf)
.unwrap_or_else(|| overlay_base.join(format!("upper-{}", id)));
let work_dir = work
.map(Path::to_path_buf)
.unwrap_or_else(|| overlay_base.join(format!("work-{}", id)));
fs::create_dir_all(&upper_dir)?;
fs::create_dir_all(&work_dir)?;
fs::create_dir_all(dest_path)?;
// Canonicalize the source path so overlayfs can find it reliably
let src_canonical = src.canonicalize().map_err(|e| {
io::Error::new(
e.kind(),
format!("Failed to canonicalize '{}': {}", src.display(), e),
)
})?;
// Mount overlay from host (requires CAP_SYS_ADMIN, same as bind_mount_proc).
// Use .output() instead of .status() so that mount errors are captured
// rather than printed to stderr — the caller will fall back to a copy.
let is_root = crate::utils::root::is_root().unwrap_or(false);
let mut cmd = self
.parent()
.command(if is_root { "mount" } else { "sudo" });
if !is_root {
cmd.arg("mount");
}
let output = cmd
.arg("-t")
.arg("overlay")
.arg("overlay")
.arg("-o")
.arg(format!(
"lowerdir={},upperdir={},workdir={}",
src_canonical.display(),
upper_dir.display(),
work_dir.display()
))
.arg(dest_path.to_string_lossy().to_string())
.output()?;
if !output.status.success() {
// Clean up dirs we created (never the caller-pinned persistent
// upper/work of a session: they hold the previous attempt's
// build artifacts)
if upper.is_none() {
let _ = fs::remove_dir_all(&upper_dir);
let _ = fs::remove_dir_all(&work_dir);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"Overlay mount of '{}' at '{}' failed: {}",
src.display(),
dest_path.display(),
stderr.trim()
)));
}
debug!(
"Overlay-mounted {} at {} (upper: {}, work: {})",
src.display(),
dest_path.display(),
upper_dir.display(),
work_dir.display()
);
// Track for cleanup
self.overlay_mounts
.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(())
}
fn command(
&self,
program: &str,
@@ -534,21 +166,11 @@ impl UnshareDriver {
env: &[(String, String)],
cwd: Option<&str>,
) -> ContextCommand<'_> {
let mut cmd = self.parent().command("unshare");
let mut cmd = self.parent().command("sudo");
cmd.args(env.iter().map(|(k, v)| format!("{k}={v}")));
cmd.envs(env.iter().cloned());
cmd.arg("--map-user=65536")
.arg("--map-group=65536")
.arg("--pid")
.arg("--ipc")
.arg("--uts")
.arg("--user")
.arg("--cgroup")
.arg("--map-auto")
.arg("-r")
.arg("--mount")
.arg("--fork")
cmd.arg("unshare")
.arg("--mount-proc")
.arg("-R")
.arg(&self.path);
@@ -556,87 +178,8 @@ impl UnshareDriver {
cmd.arg("-w").arg(dir);
}
// 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
cmd.arg("--")
.arg("bash")
.arg("-c")
.arg(build_namespace_script(program, args));
cmd.arg(program).args(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
View File
@@ -1,72 +0,0 @@
//! 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;
+257 -261
View File
@@ -1,19 +1,134 @@
use crate::context::Context;
use log::debug;
use crate::context;
use crate::context::{Context, ContextConfig};
use std::collections::HashMap;
use std::error::Error;
use std::sync::Arc;
/// 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>) {
use std::path::PathBuf;
pub struct EphemeralContextGuard {
previous_context: String,
chroot_path: PathBuf,
}
impl EphemeralContextGuard {
pub fn new(series: &str) -> Result<Self, Box<dyn Error>> {
let current_context_name = context::manager().current_name();
// Create a temporary directory for the chroot
let chroot_path_str = context::current().create_temp_dir()?;
let chroot_path = PathBuf::from(chroot_path_str);
log::debug!(
"Creating new chroot for {} at {}...",
series,
chroot_path.display()
);
let status = context::current()
.command("sudo")
.arg("mmdebstrap")
.arg("--variant=buildd")
.arg(series)
.arg(chroot_path.to_string_lossy().to_string())
.status()?;
if !status.success() {
// Clean up on failure
let _ = std::fs::remove_dir_all(&chroot_path);
return Err(format!("mmdebstrap failed for series {}", series).into());
}
// Mount '/dev' inside the chroot
let status = context::current()
.command("sudo")
.arg("mount")
.arg("--bind")
.arg("/dev")
.arg(format!("{}/dev", chroot_path.display()))
.status()?;
if !status.success() {
// Clean up on failure
let _ = std::fs::remove_dir_all(&chroot_path);
return Err("Failed to mount /dev inside chroot".into());
}
// 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()),
}));
Ok(Self {
previous_context: current_context_name,
chroot_path,
})
}
}
impl Drop for EphemeralContextGuard {
fn drop(&mut self) {
log::debug!("Cleaning up ephemeral context...");
// 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);
}
// Unmount '/dev' inside the chroot
let status = context::current()
.command("sudo")
.arg("umount")
.arg(format!("{}/dev", &self.chroot_path.display()))
.status();
if status.is_err() || !status.unwrap().success() {
// If we fail to umount, then we can't remove (would remove /dev/xx on host)
log::error!("Failed to umount /dev inside chroot. Not cleaning up.");
return;
}
// Remove chroot directory
// We use the restored context to execute the cleanup command
let result = context::current()
.command("sudo")
.arg("rm")
.arg("-rf")
.arg(&self.chroot_path)
.status();
match result {
Ok(status) => {
if !status.success() {
log::error!(
"Failed to remove chroot directory {}",
self.chroot_path.display()
);
}
}
Err(e) => {
log::error!(
"Failed to execute cleanup command for {}: {}",
self.chroot_path.display(),
e
);
}
}
}
}
/// Set environment variables for cross-compilation
pub fn setup_environment(
env: &mut HashMap<String, String>,
arch: &str,
) -> Result<(), Box<dyn Error>> {
let dpkg_architecture = String::from_utf8(
context::current()
.command("dpkg-architecture")
.arg(format!("-a{}", arch))
.output()?
.stdout,
)?;
let env_var_regex = regex::Regex::new(r"(?<key>.*)=(?<value>.*)").unwrap();
for l in output.lines() {
let Some(capture) = env_var_regex.captures(l) else {
debug!("Skipping unexpected dpkg-architecture output line: '{l}'");
continue;
};
for l in dpkg_architecture.lines() {
let capture = env_var_regex.captures(l).unwrap();
let key = capture.name("key").unwrap().as_str().to_string();
let value = capture.name("value").unwrap().as_str().to_string();
@@ -23,288 +138,169 @@ fn parse_dpkg_architecture_output(output: &str, env: &mut HashMap<String, String
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());
env.insert("DEB_BUILD_OPTIONS".to_string(), "nocheck".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
///
/// 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,
pocket: Option<&str>,
ctx: Arc<Context>,
) -> Result<(), Box<dyn Error>> {
/// This also handles the 'ports.ubuntu.com' vs 'archive.ubuntu.com' on Ubuntu
pub fn ensure_repositories(arch: &str, series: &str) -> Result<(), Box<dyn Error>> {
let ctx = context::current();
let local_arch = crate::get_current_arch();
// Add target ('host') architecture
let status = ctx
.command("dpkg")
ctx.command("dpkg")
.arg("--add-architecture")
.arg(arch)
.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());
}
.status()?;
// 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()))?;
// Handle DEB822 format (Ubuntu 24.04+)
let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources";
let has_deb822 = ctx
.command("test")
.arg("-f")
.arg(deb822_path)
.status()?
.success();
// 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 {
// 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;
if has_deb822 {
ensure_repositories_deb822(&ctx, arch, &local_arch, series, deb822_path)?;
} else {
ensure_repositories_legacy(&ctx, arch, &local_arch, series, "/etc/apt/sources.list")?;
}
// Scope to local_arch if not already scoped
if source.architectures.is_empty() {
source.architectures.push(local_arch.clone());
}
// Ensure all components are present
for comp in &components {
if !source.components.contains(comp) {
source.components.push(comp.clone());
}
}
// Ensure all suites (pockets) are enabled
for suite in &required_suites {
if !source.suite.contains(suite) {
source.suite.push(suite.clone());
}
}
}
// 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_target {
// Add the target architecture's mirror (ports for the non-local
// architectures on Ubuntu)
let mirror_entry = crate::apt::sources::SourceEntry {
enabled: true,
kind: crate::apt::sources::SourceKind::Deb,
components: components.clone(),
architectures: vec![arch.to_string()],
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(mirror_entry);
}
// 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::*;
fn ensure_repositories_deb822(
ctx: &context::Context,
arch: &str,
local_arch: &str,
series: &str,
deb822_path: &str,
) -> Result<(), Box<dyn Error>> {
// Scope existing to local_arch if not already scoped
ctx.command("sed")
.arg("-i")
.arg(format!("/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/ {{ n; /^Architectures:/ ! i Architectures: {} }}", local_arch))
.arg(deb822_path)
.status()?;
#[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";
// Ensure all components are enabled for the primary architecture
ctx.command("sed")
.arg("-i")
.arg("/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/,/Components:/ s/^Components:.*/Components: main restricted universe multiverse/")
.arg(deb822_path)
.status()?;
let mut env = HashMap::new();
parse_dpkg_architecture_output(output, &mut env);
// Ensure all suites (pockets) are enabled for the primary architecture
// Excluding 'proposed' as it contains unstable software
let suites = format!("{series} {series}-updates {series}-backports {series}-security");
ctx.command("sed")
.arg("-i")
.arg(format!(
"/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/,/Suites:/ s/^Suites:.*/Suites: {}/",
suites
))
.arg(deb822_path)
.status()?;
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-")
// Add ports if not already present
let has_ports = ctx
.command("grep")
.arg("-q")
.arg("ports.ubuntu.com")
.arg(deb822_path)
.status()?
.success();
if !has_ports {
let ports_block = format!(
"\nTypes: deb\nURIs: http://ports.ubuntu.com/ubuntu-ports\nSuites: {series} {series}-updates {series}-backports {series}-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nArchitectures: {arch}\n"
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", ports_block, deb822_path))
.status()?;
}
Ok(())
}
#[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";
fn ensure_repositories_legacy(
ctx: &context::Context,
arch: &str,
local_arch: &str,
series: &str,
sources_path: &str,
) -> Result<(), Box<dyn Error>> {
// Scope archive.ubuntu.com and security.ubuntu.com to local_arch if not already scoped
ctx.command("sed")
.arg("-i")
.arg(format!(
r"/archive.ubuntu.com\|security.ubuntu.com/ {{ /arch=/ ! {{ /^deb \[/ ! s/^deb /deb [arch={}] /; /^deb \[/ s/^deb \[\([^]]*\)\]/deb [arch={} \1]/ }} }}",
local_arch, local_arch
))
.arg(sources_path)
.status()?;
let mut env = HashMap::new();
parse_dpkg_architecture_output(output, &mut env);
// Ensure all components (main restricted universe multiverse) are present for all archive/security lines
ctx.command("sed")
.arg("-i")
.arg(r"/archive.ubuntu.com\|security.ubuntu.com/ s/\( main\)\?\([ ]\+restricted\)\?\([ ]\+universe\)\?\([ ]\+multiverse\)\?$/ main restricted universe multiverse/")
.arg(sources_path)
.status()?;
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);
}
// Ensure all pockets exist. If not, we append them.
for pocket in ["", "-updates", "-backports", "-security"] {
let suite = format!("{}{}", series, pocket);
let has_suite = ctx
.command("grep")
.arg("-q")
.arg(format!(" {}", suite))
.arg(sources_path)
.status()?
.success();
/// 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")
if !has_suite {
let line = format!(
"deb [arch={}] http://archive.ubuntu.com/ubuntu/ {} main restricted universe multiverse",
local_arch, suite
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", line, sources_path))
.status()?;
}
}
// Add ports repository to sources.list if not already present
let has_ports = ctx
.command("grep")
.arg("-q")
.arg("ports.ubuntu.com")
.arg(sources_path)
.status()?
.success();
if !has_ports {
let ports_lines = format!(
"deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series} main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-updates main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-backports main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-security main restricted universe multiverse"
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", ports_lines, sources_path))
.status()?;
}
Ok(())
}
-1124
View File
File diff suppressed because it is too large Load Diff
+43 -1409
View File
File diff suppressed because it is too large Load Diff
+66 -1246
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
/// Sbuild binary package building
/// Call 'sbuild' with the dsc file to build the package with unshare
use crate::context;
use std::error::Error;
pub fn build(
package: &str,
_version: &str,
arch: &str,
series: &str,
build_root: &str,
cross: bool,
) -> Result<(), Box<dyn Error>> {
let ctx = context::current();
let mut cmd = ctx.command("sbuild");
cmd.current_dir(format!("{}/{}", build_root, package));
cmd.arg("--chroot-mode=unshare");
if cross {
cmd.arg(format!("--host={}", arch));
} else {
cmd.arg(format!("--arch={}", arch));
}
cmd.arg(format!("--dist={}", series));
// Add output directory argument
cmd.arg(format!("--build-dir={}", build_root));
let status = cmd.status()?;
if !status.success() {
return Err(format!("sbuild failed with status: {}", status).into());
}
Ok(())
}
-1484
View File
File diff suppressed because it is too large Load Diff
-1027
View File
File diff suppressed because it is too large Load Diff
-438
View File
@@ -1,438 +0,0 @@
//! 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
);
}
}
-519
View File
@@ -1,519 +0,0 @@
//! 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"]);
}
}
-485
View File
@@ -1,485 +0,0 @@
//! 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 = &paras[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
View File
File diff suppressed because it is too large Load Diff
-276
View File
@@ -1,276 +0,0 @@
//! `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);
}
}
-34
View File
@@ -1,34 +0,0 @@
//! 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
//! - [`tests_control`]: DEP-8 test metadata (`debian/tests/control`)
pub mod arch;
pub mod changelog;
pub mod checksums;
pub mod control;
pub mod deps;
pub mod files;
pub mod tests_control;
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 tests_control::{Test, TestsControl};
pub use version::DebianVersion;
-266
View File
@@ -1,266 +0,0 @@
//! DEP-8 as-installed test metadata (`debian/tests/control`).
//!
//! Parses the test control file into runnable test descriptions: one
//! [`Test`] per `Tests:` script and per `Test-Command:` value, carrying
//! the stanza fields they share (`Depends:`, `Restrictions:`,
//! `Tests-Directory:`). The `@`-family substitution of `Depends:` needs
//! the list of built binaries and is runner-side logic — this module
//! only hands over the raw value.
use std::path::Path;
use crate::debian::control::parse_paragraphs;
/// One runnable DEP-8 test.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Test {
/// Test name: the `Tests:` script path as written, the `test-name:`
/// value, or the `command1`, `command2`, ... enumeration of
/// `Test-Command:` stanzas. The enumeration advances once per
/// `Test-Command:` stanza whether or not `test-name:` overrides the
/// name.
pub name: String,
/// Script path relative to the tests directory (`Tests:` stanzas):
/// `debian/tests/` or the stanza's [`Test::tests_directory`], with
/// subdirectories as written (`upstream/foo`).
pub script: Option<String>,
/// Inline shell command run under `bash -e` (`Test-Command:` stanzas).
pub command: Option<String>,
/// Raw `Depends:` value as written; `None` when absent, meaning the
/// DEP-8 default `@` (all built binaries).
pub depends: Option<String>,
/// Declared restrictions, in declaration order, as written
/// (e.g. `allow-stderr`).
pub restrictions: Vec<String>,
/// `Tests-Directory:` replacing the `debian/tests` path segment for
/// this stanza's scripts (relative path).
pub tests_directory: Option<String>,
}
impl Test {
/// Whether the test declares `restriction` (case-insensitive).
pub fn has_restriction(&self, restriction: &str) -> bool {
self.restrictions
.iter()
.any(|r| r.eq_ignore_ascii_case(restriction))
}
}
/// Parsed `debian/tests/control`: the runnable tests, in declaration
/// order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TestsControl {
/// One entry per `Tests:` script and per `Test-Command:` stanza.
pub tests: Vec<Test>,
}
impl TestsControl {
/// Parse the test control file at `path`.
pub fn parse(path: &Path) -> Result<TestsControl, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
Ok(TestsControl::parse_content(&content)?)
}
/// Parse the test control file from its content.
///
/// Every non-empty stanza must carry exactly one of `Tests:` or
/// `Test-Command:` (a script list expands to one test per script,
/// sharing the stanza fields); `test-name:` is only valid on a
/// `Test-Command:` stanza. A file without stanzas parses to an empty
/// [`TestsControl`] — whether the package declares tests at all is
/// the caller's check (`Testsuite: autopkgtest`).
pub fn parse_content(content: &str) -> Result<TestsControl, String> {
let mut tests_control = TestsControl::default();
let mut command_index = 0usize;
for (index, paragraph) in parse_paragraphs(content).iter().enumerate() {
let stanza = index + 1;
let tests_field = non_empty(paragraph.get("Tests"));
let command_field = non_empty(paragraph.get("Test-Command"));
let test_name = non_empty(paragraph.get("test-name"));
let depends = non_empty(paragraph.get("Depends")).map(String::from);
let restrictions: Vec<String> = non_empty(paragraph.get("Restrictions"))
.map(|value| value.split_whitespace().map(String::from).collect())
.unwrap_or_default();
let tests_directory = non_empty(paragraph.get("Tests-Directory")).map(String::from);
match (tests_field, command_field) {
(Some(_), Some(_)) => {
return Err(format!(
"stanza {stanza}: Tests and Test-Command are mutually exclusive"
));
}
(None, None) => {
return Err(format!(
"stanza {stanza}: missing a Tests or Test-Command field"
));
}
(Some(scripts), None) => {
if test_name.is_some() {
return Err(format!(
"stanza {stanza}: test-name only applies to a Test-Command stanza"
));
}
for script in scripts.split_whitespace() {
tests_control.tests.push(Test {
name: script.to_string(),
script: Some(script.to_string()),
command: None,
depends: depends.clone(),
restrictions: restrictions.clone(),
tests_directory: tests_directory.clone(),
});
}
}
(None, Some(command)) => {
command_index += 1;
let name = test_name
.map(String::from)
.unwrap_or_else(|| format!("command{command_index}"));
tests_control.tests.push(Test {
name,
script: None,
command: Some(command.to_string()),
depends: depends.clone(),
restrictions: restrictions.clone(),
tests_directory: tests_directory.clone(),
});
}
}
}
Ok(tests_control)
}
}
/// A field value with its surrounding whitespace trimmed; `None` when the
/// field is absent or whitespace-only.
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
/// A realistic control file: a restrictions stanza, a multi-script
/// stanza sharing its fields, a Tests-Directory stanza and two
/// Test-Command stanzas (one enumerated, one named).
#[test]
fn parse_golden_multi_stanza() {
let control = TestsControl::parse_content(
"# a leading comment\n\
\n\
Tests: smoke\n\
Restrictions: allow-stderr needs-root\n\
Depends: @, build-essential\n\
\n\
Tests: one two\n\
Depends: @\n\
\n\
Tests: checks/cli\n\
Tests-Directory: tests\n\
\n\
Test-Command: hello --version\n\
Depends: @builddeps@\n\
\n\
Test-Command: python3 -c \"print('ok')\"\n\
test-name: py-smoke\n\
Restrictions: superficial skippable\n",
)
.unwrap();
let names: Vec<&str> = control.tests.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["smoke", "one", "two", "checks/cli", "command1", "py-smoke"]
);
let smoke = &control.tests[0];
assert_eq!(smoke.script.as_deref(), Some("smoke"));
assert_eq!(smoke.depends.as_deref(), Some("@, build-essential"));
assert!(smoke.has_restriction("NEEDS-ROOT"));
assert!(!smoke.has_restriction("skippable"));
// The multi-script stanza's fields are shared by every test.
assert_eq!(control.tests[1].depends, control.tests[2].depends);
assert_eq!(control.tests[2].script.as_deref(), Some("two"));
let cli = &control.tests[3];
assert_eq!(cli.tests_directory.as_deref(), Some("tests"));
assert_eq!(cli.depends, None); // absent: the runner defaults to @
let enumerated = &control.tests[4];
assert_eq!(enumerated.command.as_deref(), Some("hello --version"));
assert_eq!(enumerated.script, None);
let named = &control.tests[5];
assert_eq!(named.name, "py-smoke");
assert!(named.has_restriction("skippable") && named.has_restriction("superficial"));
}
/// The command enumeration advances per Test-Command stanza, even
/// when test-name overrides the name.
#[test]
fn command_numbering_skips_named_stanzas() {
let control = TestsControl::parse_content(
"Test-Command: a\n\
test-name: named\n\
\n\
Test-Command: b\n",
)
.unwrap();
assert_eq!(
control
.tests
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>(),
["named", "command2"]
);
}
/// Continuation lines join field values; comments inside a value are
/// not possible, but comment lines between fields are ignored.
#[test]
fn continuation_lines_join_values() {
let control = TestsControl::parse_content(concat!(
"Tests: smoke\n",
"Depends: @,\n",
" glib-network-service,\n",
"# comment line\n",
" locales-all\n",
))
.unwrap();
assert_eq!(
control.tests[0].depends.as_deref(),
Some("@,\nglib-network-service,\nlocales-all")
);
}
/// An empty file (or only comments) parses to no tests.
#[test]
fn empty_file_parses_to_no_tests() {
assert!(TestsControl::parse_content("").unwrap().tests.is_empty());
assert!(
TestsControl::parse_content("# nothing\n\n")
.unwrap()
.tests
.is_empty()
);
}
/// Malformed stanzas are errors, naming the stanza.
#[test]
fn malformed_stanzas_are_errors() {
for (content, expected) in [
("Depends: @\n", "missing a Tests or Test-Command"),
("Tests: a\nTest-Command: b\n", "mutually exclusive"),
(
"Test-Command: b\ntest-name: x\n\nTests: a\ntest-name: y\n",
"only applies",
),
] {
let error = TestsControl::parse_content(content).unwrap_err();
assert!(error.contains(expected), "{error}");
}
}
}
-422
View File
@@ -1,422 +0,0 @@
//! 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));
}
}
-1200
View File
File diff suppressed because it is too large Load Diff
-310
View File
@@ -1,310 +0,0 @@
//! 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);
}
}
-745
View File
@@ -1,745 +0,0 @@
//! 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}"
);
}
}
+3 -66
View File
@@ -1,73 +1,10 @@
//! pkh: Debian packaging helper
//!
//! pkh allows working with Debian packages, with multiple actions/submodules
#![deny(missing_docs)]
/// Handle apt data (apt sources)
pub mod apt;
/// Build a Debian source package (into a .dsc)
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, test metadata, 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;
/// Run a source package's DEP-8 as-installed tests (`pkh test`)
pub mod test;
/// 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;
pub mod deb;
pub mod package_info;
pub mod pull;
/// 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;
/// Optional callback function (taking 4 arguments)
/// - Name of the current main operation (e.g. pulling package)
/// - Name of the current nested operation (e.g. cloning git repo)
/// - Progress, position, index of current operation (e.g. amount of data downloaded)
/// - Total amount for current operation (e.g. size of the file to download)
pub type ProgressCallback<'a> = Option<&'a dyn Fn(&str, &str, usize, usize)>;
/// Returns the architecture of current CPU, debian-compatible
-106
View File
@@ -1,106 +0,0 @@
//! 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
);
}
}
}
}
}
-4
View File
@@ -1,4 +0,0 @@
//! Check areas; one module per area, registered in [`crate::lint::check::CHECKS`].
/// Pkh-native workflow checks (git-centric trees, PPA uploads).
pub mod pkh;
-148
View File
@@ -1,148 +0,0 @@
//! 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"));
}
}
-98
View File
@@ -1,98 +0,0 @@
//! 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 })
}
-118
View File
@@ -1,118 +0,0 @@
//! 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
View File
@@ -1,186 +0,0 @@
//! `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");
}
}
-450
View File
@@ -1,450 +0,0 @@
//! 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);
}
}
-85
View File
@@ -1,85 +0,0 @@
//! 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()
}
}
}
-531
View File
@@ -1,531 +0,0 @@
//! 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
View File
@@ -1,640 +0,0 @@
//! 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");
}
}
+124 -1180
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More