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

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
From crates.io:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install pkh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build from source (the same system packages are needed either way):
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt install pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||||
|
git clone https://git.vhaudiquet.fr/vhaudiquet/pkh.git
|
||||||
|
cd pkh
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
At runtime pkh shells out to the Debian packaging toolchain (git,
|
||||||
|
dpkg-dev, quilt, mmdebstrap, lintian, pristine-tar, ...): install the
|
||||||
|
ones your workflows use, or build the classic snap from
|
||||||
|
`snap/snapcraft.yaml` (`snapcraft pack`), which carries them.
|
||||||
|
|
||||||
## Usage and features
|
## Usage and features
|
||||||
|
|
||||||
### Basic concepts
|
### Basic concepts
|
||||||
@@ -25,12 +49,19 @@ Options:
|
|||||||
Commands and workflows include:
|
Commands and workflows include:
|
||||||
```
|
```
|
||||||
Commands:
|
Commands:
|
||||||
|
new Scaffold a new Debian source package (buildable right away)
|
||||||
pull Pull a source package from the archive or git
|
pull Pull a source package from the archive or git
|
||||||
chlog Auto-generate changelog entry, editing it, committing it afterwards
|
chlog Auto-generate changelog entry, editing it, committing it afterwards
|
||||||
build Build the source package (into a .dsc)
|
build Build the source package (into a .dsc)
|
||||||
deb Build the source package into binary package (.deb)
|
|
||||||
put Upload the built source package to a PPA
|
put Upload the built source package to a PPA
|
||||||
|
deb Build the source package into binary package (.deb)
|
||||||
|
lint Lint the package (lintian wrapper + pkh-native checks)
|
||||||
|
prune Prune residual pkh build artifacts and caches
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Print help
|
||||||
|
-V, --version Print version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@@ -66,68 +97,25 @@ That is a lot of different tools and operations. With pkh, the same workflow:
|
|||||||
pkh pull hello # needs -d ubuntu if you are not running Ubuntu
|
pkh pull hello # needs -d ubuntu if you are not running Ubuntu
|
||||||
# Apply the patch to the package
|
# Apply the patch to the package
|
||||||
...
|
...
|
||||||
pkh commit -m "Applied patch xxx"
|
git add debian/patches/xxx.patch
|
||||||
|
git commit -m "Applied patch xxx"
|
||||||
pkh chlog
|
pkh chlog
|
||||||
|
git add debian/changelog
|
||||||
|
git commit -m "d/changelog"
|
||||||
# Test that the package builds
|
# Test that the package builds
|
||||||
pkh build
|
pkh build
|
||||||
pkh deb
|
pkh deb
|
||||||
# Upload the package to a ppa
|
# Upload the package to a ppa
|
||||||
pkh put --ppa user/hello_xxx
|
pkh put --ppa user/hello_xxx
|
||||||
# Push previously commited changes
|
# Push the commits to your fork
|
||||||
git push xxx user-fork
|
git push xxx user-fork
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap: features needed for 1.0
|
## Future improvement ideas
|
||||||
|
|
||||||
Basically, wrapping the basic debian workflows.
|
|
||||||
Missing features:
|
|
||||||
- [ ] `pkh pull`
|
|
||||||
- [x] Obtain package sources from git
|
|
||||||
- [x] Obtain package sources from the archive (fallback)
|
|
||||||
- [x] Obtain package source from PPA (--ppa)
|
|
||||||
- [ ] Obtain a specific version of the package
|
|
||||||
- [x] Fetch the correct git branch for series on Ubuntu
|
|
||||||
- [ ] Try to fetch the correct git branch for series on Debian, or fallback to the archive
|
|
||||||
- [ ] `pkh chlog`
|
|
||||||
- [x] Auto-generate changelog entry
|
|
||||||
- [ ] Extra flags: backport, non-maintainer upload, no change rebuild, ...
|
|
||||||
- [ ] Commit changelog entry
|
|
||||||
- [ ] `pkh build`
|
|
||||||
- [x] Build the source package
|
|
||||||
- [ ] `pkh deb`
|
|
||||||
- [x] Build the binary package
|
|
||||||
- [x] Build for a specific architecture
|
|
||||||
- [ ] Three build modes:
|
|
||||||
- [ ] Build locally (discouraged)
|
|
||||||
- [x] Build using unshare chroot, with binary emulation (default)
|
|
||||||
- [x] Cross-compilation
|
|
||||||
- [ ] Async build
|
|
||||||
- [ ] `pkh status`
|
|
||||||
- [ ] Show build status
|
|
||||||
- [ ] `pkh put`
|
|
||||||
- [x] Upload the source package to a PPA (native SFTP, no `dput` dependency)
|
|
||||||
- [ ] Upload the source package to the archive
|
|
||||||
- [ ] `pkh commit`
|
|
||||||
- [ ] Commit the changes to git
|
|
||||||
- [ ] `pkh lint`
|
|
||||||
- [ ] Lint the package
|
|
||||||
- [ ] `pkh test`
|
|
||||||
- [ ] Run autopkgtest
|
|
||||||
- [ ] Provide options: local (discouraged), chroot, VM?, ppa
|
|
||||||
- [ ] Async test
|
|
||||||
|
|
||||||
## Nice-to-have features
|
|
||||||
|
|
||||||
- [ ] 'pkh pull'
|
|
||||||
- [ ] Cache the Sources.gz files, to improve speed
|
|
||||||
- [ ] Work in an already downloaded package, to git pull and re-fetch orig tar gz
|
|
||||||
- [ ] 'pkh context'
|
|
||||||
- [x] Select, add, remove, list contexts
|
|
||||||
- [x] Context-scoped command execution
|
|
||||||
- [ ] Context-scoped deb and test commands
|
|
||||||
- [ ] Per-architecture contexts
|
|
||||||
- [ ] Per-series contexts
|
|
||||||
- [x] ssh contexts
|
|
||||||
- [ ] docker, lxc contexts?
|
|
||||||
- [ ] context push, context pop: context stack
|
|
||||||
|
|
||||||
|
- pull: try to fetch the correct git branch for series on Debian
|
||||||
|
- deb: asynchronous build, detachable and monitorable
|
||||||
|
- put: allow uploads to Debian or Ubuntu archives
|
||||||
|
- test: add 'pkh test' to run autopkgtests
|
||||||
|
- pull: cache Sources.gz files to improve speed
|
||||||
|
- pull: 'pkh pull' in a package tree should git pull and re-fetch orig tgz
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
## Static data needed for pkh operations
|
||||||
|
## Instead of hardcoding the data in code, data files allow to quickly
|
||||||
|
## update and maintain such data in one unique place
|
||||||
|
## The goal is to have the minimal possible set of data necessary
|
||||||
|
## to grab the actual data. For example we don't want to store every Ubuntu
|
||||||
|
## or Debian series, but rather pointers to where that data lives: each dist
|
||||||
|
## entry below carries its series sources (the local distro-info CSV, with
|
||||||
|
## the network URL as fallback).
|
||||||
|
##
|
||||||
|
## Per-dist keys beyond the series pointers:
|
||||||
|
## mirrors: the archive mirrors, each a URL serving a set of
|
||||||
|
## architectures: `primary` (the main archive, whose url
|
||||||
|
## doubles as the dist's base URL) and, where they exist,
|
||||||
|
## the others (`ports`). `security_url` is the sibling
|
||||||
|
## host serving the -security pocket for the same arches
|
||||||
|
## (ports mirrors serve their own security); `archs` is
|
||||||
|
## an explicit list, or the `all` sentinel when one
|
||||||
|
## mirror serves every architecture (Debian's case — an
|
||||||
|
## exhaustive list would rot each time an arch is added).
|
||||||
|
## Host matching treats a URI as official when its host
|
||||||
|
## equals a mirror host or is a subdomain of it, so the
|
||||||
|
## country mirrors (fr.archive.ubuntu.com) count too.
|
||||||
|
## components: the archive components (main, universe, contrib, ...)
|
||||||
|
## a cross-build environment enables on official sources.
|
||||||
|
## Live archive operations keep resolving components from
|
||||||
|
## Release files; this is the offline default.
|
||||||
|
## cross_pockets: the pockets a cross-build environment enables for a
|
||||||
|
## series (`<series>-updates`, ...). Deliberately not the
|
||||||
|
## `pockets` key: that one is the *search order* of pull,
|
||||||
|
## where backports must not fold in.
|
||||||
|
## suite_aliases: the changelog suite names that alias a series
|
||||||
|
## codename: Debian packages conventionally target
|
||||||
|
## 'unstable' where the series data carries 'sid'.
|
||||||
|
## Mapped suite name -> series codename; the two
|
||||||
|
## names identify the same series, and the selector
|
||||||
|
## offers the aliased entry as '<suite> (<series>)'.
|
||||||
|
## build_profiles: the vendor's default DEB_BUILD_PROFILES (Ubuntu
|
||||||
|
## activates derivative.ubuntu noudeb, Debian none),
|
||||||
|
## mirroring what Dpkg::BuildProfiles resolves when the
|
||||||
|
## variable is unset.
|
||||||
|
dist:
|
||||||
|
debian:
|
||||||
|
mirrors:
|
||||||
|
primary:
|
||||||
|
url: https://deb.debian.org/debian
|
||||||
|
# One mirror serves every architecture.
|
||||||
|
archs: all
|
||||||
|
components: [main, contrib, non-free, non-free-firmware]
|
||||||
|
cross_pockets: [updates, backports, security]
|
||||||
|
build_profiles: []
|
||||||
|
archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc
|
||||||
|
pockets:
|
||||||
|
- updates
|
||||||
|
- security
|
||||||
|
- proposed-updates
|
||||||
|
# Debian changelogs conventionally target 'unstable'; the series data
|
||||||
|
# knows the same series as 'sid'.
|
||||||
|
suite_aliases:
|
||||||
|
unstable: sid
|
||||||
|
sections:
|
||||||
|
# Valid Section values for debian/control: the Debian policy section
|
||||||
|
# list unioned with the sections observed in the live Ubuntu archive.
|
||||||
|
# Archives reject uploads carrying an unknown section; only the part
|
||||||
|
# before a '/' (the subsection) is validated.
|
||||||
|
- admin
|
||||||
|
- cli-mono
|
||||||
|
- comm
|
||||||
|
- database
|
||||||
|
- debian-installer
|
||||||
|
- debug
|
||||||
|
- devel
|
||||||
|
- doc
|
||||||
|
- editors
|
||||||
|
- education
|
||||||
|
- electronics
|
||||||
|
- embedded
|
||||||
|
- fonts
|
||||||
|
- games
|
||||||
|
- gnome
|
||||||
|
- gnu-r
|
||||||
|
- golang
|
||||||
|
- graphics
|
||||||
|
- hamradio
|
||||||
|
- haskell
|
||||||
|
- httpd
|
||||||
|
- interpreters
|
||||||
|
- introspection
|
||||||
|
- java
|
||||||
|
- javascript
|
||||||
|
- kde
|
||||||
|
- kernel
|
||||||
|
- libdevel
|
||||||
|
- libs
|
||||||
|
- lisp
|
||||||
|
- localization
|
||||||
|
- mail
|
||||||
|
- math
|
||||||
|
- metapackages
|
||||||
|
- misc
|
||||||
|
- net
|
||||||
|
- news
|
||||||
|
- ocaml
|
||||||
|
- oldlibs
|
||||||
|
- otherosfs
|
||||||
|
- perl
|
||||||
|
- php
|
||||||
|
- python
|
||||||
|
- ruby
|
||||||
|
- rust
|
||||||
|
- science
|
||||||
|
- shells
|
||||||
|
- sound
|
||||||
|
- tasks
|
||||||
|
- tex
|
||||||
|
- text
|
||||||
|
- translations
|
||||||
|
- utils
|
||||||
|
- vcs
|
||||||
|
- video
|
||||||
|
- web
|
||||||
|
- x11
|
||||||
|
- xfce
|
||||||
|
- zope
|
||||||
|
series:
|
||||||
|
local: /usr/share/distro-info/debian.csv
|
||||||
|
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv
|
||||||
|
ubuntu:
|
||||||
|
mirrors:
|
||||||
|
primary:
|
||||||
|
url: https://archive.ubuntu.com/ubuntu
|
||||||
|
# Sibling host serving the -security pocket for the same arches.
|
||||||
|
security_url: http://security.ubuntu.com/ubuntu
|
||||||
|
archs: [amd64, i386]
|
||||||
|
ports:
|
||||||
|
# Everything else lives on the ports archive, which also serves
|
||||||
|
# its own -security pocket (no security_url needed).
|
||||||
|
url: http://ports.ubuntu.com/ubuntu-ports
|
||||||
|
archs: [armhf, arm64, ppc64el, riscv64, s390x]
|
||||||
|
components: [main, restricted, universe, multiverse]
|
||||||
|
cross_pockets: [updates, backports, security]
|
||||||
|
build_profiles: [derivative.ubuntu, noudeb]
|
||||||
|
archive_keyring: https://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg
|
||||||
|
pockets:
|
||||||
|
- updates
|
||||||
|
- security
|
||||||
|
- proposed
|
||||||
|
sections:
|
||||||
|
# Same list as debian (see the comment there)
|
||||||
|
- admin
|
||||||
|
- cli-mono
|
||||||
|
- comm
|
||||||
|
- database
|
||||||
|
- debian-installer
|
||||||
|
- debug
|
||||||
|
- devel
|
||||||
|
- doc
|
||||||
|
- editors
|
||||||
|
- education
|
||||||
|
- electronics
|
||||||
|
- embedded
|
||||||
|
- fonts
|
||||||
|
- games
|
||||||
|
- gnome
|
||||||
|
- gnu-r
|
||||||
|
- golang
|
||||||
|
- graphics
|
||||||
|
- hamradio
|
||||||
|
- haskell
|
||||||
|
- httpd
|
||||||
|
- interpreters
|
||||||
|
- introspection
|
||||||
|
- java
|
||||||
|
- javascript
|
||||||
|
- kde
|
||||||
|
- kernel
|
||||||
|
- libdevel
|
||||||
|
- libs
|
||||||
|
- lisp
|
||||||
|
- localization
|
||||||
|
- mail
|
||||||
|
- math
|
||||||
|
- metapackages
|
||||||
|
- misc
|
||||||
|
- net
|
||||||
|
- news
|
||||||
|
- ocaml
|
||||||
|
- oldlibs
|
||||||
|
- otherosfs
|
||||||
|
- perl
|
||||||
|
- php
|
||||||
|
- python
|
||||||
|
- ruby
|
||||||
|
- rust
|
||||||
|
- science
|
||||||
|
- shells
|
||||||
|
- sound
|
||||||
|
- tasks
|
||||||
|
- tex
|
||||||
|
- text
|
||||||
|
- translations
|
||||||
|
- utils
|
||||||
|
- vcs
|
||||||
|
- video
|
||||||
|
- web
|
||||||
|
- x11
|
||||||
|
- xfce
|
||||||
|
- zope
|
||||||
|
series:
|
||||||
|
local: /usr/share/distro-info/ubuntu.csv
|
||||||
|
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Forge hosts recognized by `pkh new` origin detection, with the
|
||||||
|
## release-tarball URL templates of each: `Forge::parse`
|
||||||
|
## (src/new/origin.rs) matches a git remote's host against the map keys,
|
||||||
|
## and the tarball download substitutes {owner}, {repo} and {tag} into the
|
||||||
|
## templates. Like host_keys.yml, this file exists so that static
|
||||||
|
## endpoints are data: adding a forge is a YAML entry, not a code change
|
||||||
|
## (self-hosted instances are deliberately absent — the download URL
|
||||||
|
## shapes differ per instance).
|
||||||
|
##
|
||||||
|
## tarball_templates are tried sequentially in file order, best candidate
|
||||||
|
## first (GitHub prefers the codeload direct link: no redirect).
|
||||||
|
## `kind` documents the forge family the URL shapes belong to; the
|
||||||
|
## templates fully describe the URLs, so nothing branches on it (yet) —
|
||||||
|
## but it must be one of the known kinds, enforced at load time.
|
||||||
|
##
|
||||||
|
## Where the values come from: each forge's release-archive download URL
|
||||||
|
## shapes, verified against the live forges —
|
||||||
|
## github: codeload.github.com/<owner>/<repo>/tar.gz/refs/tags/<tag>
|
||||||
|
## and github.com/<owner>/<repo>/archive/refs/tags/<tag>.tar.gz
|
||||||
|
## gitlab: gitlab.com/<owner>/<repo>/-/archive/<tag>/<repo>-<tag>.tar.gz
|
||||||
|
|
||||||
|
forges:
|
||||||
|
github.com:
|
||||||
|
kind: github
|
||||||
|
tarball_templates:
|
||||||
|
- https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}
|
||||||
|
- https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz
|
||||||
|
gitlab.com:
|
||||||
|
kind: gitlab
|
||||||
|
tarball_templates:
|
||||||
|
- https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Keyserver lookup endpoint used to fetch PPA signing keys.
|
||||||
|
## Like host_keys.yml, this file exists so that a static endpoint is data,
|
||||||
|
## updatable in one reviewable place, instead of hardcoded in the source —
|
||||||
|
## the URL was previously duplicated in the apt keyring and release
|
||||||
|
## modules. Sparse on purpose: it grows if keyserver pools or alternates
|
||||||
|
## ever need to be tried.
|
||||||
|
##
|
||||||
|
## The template carries its variable part as a {fingerprint} placeholder,
|
||||||
|
## substituted by the accessor of src/apt/keyring.rs with plain string
|
||||||
|
## replacement.
|
||||||
|
##
|
||||||
|
## Where the value comes from: keyserver.ubuntu.com, Ubuntu's OpenPKS
|
||||||
|
## (formerly SKS) keyserver; op=get with search=0x<fingerprint> is the
|
||||||
|
## documented machine interface fetching one key by fingerprint
|
||||||
|
## (https://keyserver.ubuntu.com).
|
||||||
|
|
||||||
|
lookup_template: "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{fingerprint}"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Launchpad service endpoints: the REST API, the PPA SFTP upload queue,
|
||||||
|
## the PPA package-content host and the Ubuntu source-package git web UI.
|
||||||
|
## Like host_keys.yml, this file exists so that static endpoints are data,
|
||||||
|
## updatable in one reviewable place, instead of hardcoded in the source —
|
||||||
|
## the API and content URLs were previously triplicated across modules.
|
||||||
|
##
|
||||||
|
## Templates carry their variable parts as {name} placeholders ({owner},
|
||||||
|
## {ppa}, {package}), substituted by the accessors of src/launchpad.rs
|
||||||
|
## with plain string replacement.
|
||||||
|
##
|
||||||
|
## Where the values come from:
|
||||||
|
## api_base: the Launchpad REST API root (https://launchpad.net/docs/api/)
|
||||||
|
## ssh_*: the PPA upload queue, as expanded by dput-ng's
|
||||||
|
## ppa:user/ppa profile (ppa.launchpad.net:22, incoming
|
||||||
|
## ~<user>/<ppa>)
|
||||||
|
## ftp_*: the same upload queue over anonymous FTP, dput-ng's
|
||||||
|
## plain ppa: profile: the transport pkh degrades to
|
||||||
|
## when the SSH connection itself never comes up
|
||||||
|
## content_host_template: ppa.launchpadcontent.net serves PPA apt
|
||||||
|
## repositories since the 2022 move off ppa.launchpad.net
|
||||||
|
## git_web_template: Launchpad's CGit mirrors of Ubuntu source packages
|
||||||
|
## (git.launchpad.net/ubuntu/+source/<package>)
|
||||||
|
|
||||||
|
api_base: https://api.launchpad.net/1.0
|
||||||
|
ssh_host: ppa.launchpad.net
|
||||||
|
ssh_port: 22
|
||||||
|
## The anonymous FTP upload queue dput-ng's plain ppa: profile uses:
|
||||||
|
## pkh degrades to it when the SSH connection itself never comes up.
|
||||||
|
ftp_host: ppa.launchpad.net
|
||||||
|
ftp_port: 21
|
||||||
|
incoming_template: "~{owner}/{ppa}"
|
||||||
|
content_host_template: https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu
|
||||||
|
git_web_template: https://git.launchpad.net/ubuntu/+source/{package}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
## License knowledge of `pkh new`, in one place: the wizard menu labels
|
||||||
|
## (src/new/questions.rs), the spellings accepted by License::parse and the
|
||||||
|
## SPDX URL template (src/new/options.rs), and the license-file sniffing
|
||||||
|
## inputs (src/new/detect.rs) all read this table, so the three lists —
|
||||||
|
## previously kept in sync by comments only — cannot drift apart anymore.
|
||||||
|
## Adding or changing a curated license is one entry below.
|
||||||
|
##
|
||||||
|
## Keys:
|
||||||
|
## id: SPDX identifier: written to debian/copyright, returned
|
||||||
|
## by the license sniff and substituted into
|
||||||
|
## license_url_template (minus a trailing '+' of the
|
||||||
|
## "or later" spellings)
|
||||||
|
## menu: label offered by the wizard license question (the
|
||||||
|
## free-text "Other (enter a SPDX identifier)" entry
|
||||||
|
## stays in Rust — it is UX, not data)
|
||||||
|
## spellings: inputs accepted by License::parse, matched
|
||||||
|
## case-insensitively; each entry must accept its own id
|
||||||
|
## (a consistency test in options.rs locks ids, spellings
|
||||||
|
## and the License enum together)
|
||||||
|
## detect_markers: marker sets driving the LICENSE/COPYING text sniff of
|
||||||
|
## detect.rs. A set matches when every marker of `all`
|
||||||
|
## occurs in the lowercased license text and none of
|
||||||
|
## `unless` does; an entry matches when any of its sets
|
||||||
|
## does. The sets are written to be mutually exclusive:
|
||||||
|
## the `unless` markers keep multi-license texts on the
|
||||||
|
## entry carrying the stronger reference (a MIT-named
|
||||||
|
## file also quoting the GPL or the Apache license is a
|
||||||
|
## GPL/Apache file) and keep GPL sets off LGPL texts,
|
||||||
|
## whose name contains theirs. The entry order below
|
||||||
|
## (menu order) therefore only breaks ties.
|
||||||
|
##
|
||||||
|
## detect_files (top level): the candidate license file names the sniff
|
||||||
|
## reads, in preference order, shared by every license (the case-variant
|
||||||
|
## directory scan around them stays in Rust).
|
||||||
|
##
|
||||||
|
## license_url_template: the SPDX license page URL, with {id} substituted
|
||||||
|
## for the debian/copyright reference paragraph.
|
||||||
|
##
|
||||||
|
## The behavioral lock for the markers is the LICENSE_TEXTS test table in
|
||||||
|
## src/new/detect.rs: a bad marker edit fails those tests, not packages.
|
||||||
|
|
||||||
|
license_url_template: https://spdx.org/licenses/{id}.html
|
||||||
|
|
||||||
|
detect_files:
|
||||||
|
- LICENSE
|
||||||
|
- LICENSE.md
|
||||||
|
- LICENSE.txt
|
||||||
|
- COPYING
|
||||||
|
- COPYING.txt
|
||||||
|
|
||||||
|
# Entries in wizard-menu order.
|
||||||
|
licenses:
|
||||||
|
- id: MIT
|
||||||
|
menu: MIT
|
||||||
|
spellings: [MIT]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- mit license
|
||||||
|
unless:
|
||||||
|
- apache license
|
||||||
|
- general public license
|
||||||
|
- all:
|
||||||
|
- permission is hereby granted, free of charge
|
||||||
|
unless:
|
||||||
|
- apache license
|
||||||
|
- general public license
|
||||||
|
- id: Apache-2.0
|
||||||
|
menu: Apache-2.0
|
||||||
|
spellings: [Apache-2.0]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- apache license
|
||||||
|
- version 2
|
||||||
|
- id: GPL-2.0+
|
||||||
|
menu: GPL-2.0+
|
||||||
|
spellings: [GPL-2.0+]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- general public license
|
||||||
|
unless:
|
||||||
|
- version 3
|
||||||
|
- lesser general public license
|
||||||
|
- id: GPL-3.0+
|
||||||
|
menu: GPL-3.0+
|
||||||
|
spellings: [GPL-3.0+]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- general public license
|
||||||
|
- version 3
|
||||||
|
unless:
|
||||||
|
- lesser general public license
|
||||||
|
- id: LGPL-2.1+
|
||||||
|
menu: LGPL-2.1+
|
||||||
|
spellings: [LGPL-2.1+]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- lesser general public license
|
||||||
|
unless:
|
||||||
|
- version 3
|
||||||
|
- all:
|
||||||
|
- lesser general public license
|
||||||
|
- version 2.1
|
||||||
|
- id: LGPL-3.0+
|
||||||
|
menu: LGPL-3.0+
|
||||||
|
spellings: [LGPL-3.0+]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- lesser general public license
|
||||||
|
- version 3
|
||||||
|
unless:
|
||||||
|
- version 2.1
|
||||||
|
- id: BSD-2-Clause
|
||||||
|
menu: BSD-2-Clause
|
||||||
|
spellings: [BSD-2-Clause]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- redistribution and use in source and binary forms
|
||||||
|
unless:
|
||||||
|
- endorse or promote
|
||||||
|
- isc license
|
||||||
|
- permission to use, copy, modify, and/or distribute this software
|
||||||
|
- id: BSD-3-Clause
|
||||||
|
menu: BSD-3-Clause
|
||||||
|
spellings: [BSD-3-Clause]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- redistribution and use in source and binary forms
|
||||||
|
- endorse or promote
|
||||||
|
unless:
|
||||||
|
- isc license
|
||||||
|
- permission to use, copy, modify, and/or distribute this software
|
||||||
|
- id: ISC
|
||||||
|
menu: ISC
|
||||||
|
spellings: [ISC]
|
||||||
|
detect_markers:
|
||||||
|
- all:
|
||||||
|
- isc license
|
||||||
|
- all:
|
||||||
|
- permission to use, copy, modify, and/or distribute this software
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
|
linux:
|
||||||
|
deb:
|
||||||
|
- series: [resolute]
|
||||||
|
dependencies:
|
||||||
|
replace:
|
||||||
|
llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||||
|
linux-riscv:
|
||||||
|
deb:
|
||||||
|
- series: [resolute]
|
||||||
|
dependencies:
|
||||||
|
replace:
|
||||||
|
llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||||
|
|
||||||
|
# Add more packages and their quirks as needed
|
||||||
|
# example-package:
|
||||||
|
# pull:
|
||||||
|
# - series: [noble]
|
||||||
|
# package_directory:
|
||||||
|
# - linux-main
|
||||||
|
# deb:
|
||||||
|
# - series: [resolute]
|
||||||
|
# dependencies:
|
||||||
|
# replace:
|
||||||
|
# llvm-21-dev: llvm-21-dev:native <!stage1>
|
||||||
|
# - series: [stonking]
|
||||||
|
# dependencies:
|
||||||
|
# replace:
|
||||||
|
# llvm-22-dev: llvm-22-dev:native <!stage1>
|
||||||
|
# parameters:
|
||||||
|
# key: value
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: pkh
|
||||||
|
description: 'Drive pkh, a Debian/Ubuntu packaging helper: pull source packages, generate changelog entries, build .dsc/.deb, lint, and upload to a PPA. Use it whenever the task touches Debian or Ubuntu packaging: patching an existing package, preparing an SRU, backport or NMU, scaffolding a new .deb, rebuilding for a PPA, or uploading a source package. Trigger on "update the changelog", "package this", or a bare package name, even when the user never mentions Debian.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# pkh
|
||||||
|
|
||||||
|
`pkh` wraps the Debian packaging toolchain (`dch`, `dpkg-buildpackage`,
|
||||||
|
`sbuild`, `dpkg-source`, `quilt`, `lintian`, PPA uploads) in one CLI.
|
||||||
|
The subcommands share one set of option names, so `-s` always targets
|
||||||
|
the series and `--ppa` always names the PPA. Each step also does more
|
||||||
|
than the raw tool it replaces: `pull` fetches the orig tarball with the
|
||||||
|
source, `chlog` commits the entry it writes, `deb` sets up a chroot and
|
||||||
|
installs the build dependencies.
|
||||||
|
|
||||||
|
Check the install with `pkh --version`. Each command lists its flags
|
||||||
|
with `pkh <command> --help`, so check there instead of guessing. pkh
|
||||||
|
shells out to host tools (git, dpkg-dev, quilt, mmdebstrap, lintian,
|
||||||
|
pristine-tar, schroot, ...). Install the ones your workflow uses, or
|
||||||
|
use the classic snap, which carries them.
|
||||||
|
|
||||||
|
## Shared options
|
||||||
|
|
||||||
|
| Option | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `-d, --dist <dist>` | Target distribution, `debian` or `ubuntu` |
|
||||||
|
| `-s, --series <series>` | Target series, for example `resolute` or `noble` |
|
||||||
|
| `-v, --version <version>` | Target package version |
|
||||||
|
| `-a, --arch <arch>` | Target architecture, for example `amd64` or `riscv64` |
|
||||||
|
| `-p, --pocket <pocket>` | Distribution pocket: `updates`, `security`, `proposed` |
|
||||||
|
| `--ppa <user/ppa>` | Act on the named PPA |
|
||||||
|
|
||||||
|
Defaults come from the host vendor, its development series, and its
|
||||||
|
architecture. When the target differs, pass the flags: packaging for
|
||||||
|
Ubuntu on a Debian host needs `-d ubuntu`, and a series or architecture
|
||||||
|
that differs from the host needs `-s` or `-a`.
|
||||||
|
|
||||||
|
## Patch an Ubuntu package
|
||||||
|
|
||||||
|
```
|
||||||
|
pkh pull hello # source and orig tarball; add -d ubuntu off an Ubuntu host
|
||||||
|
# edit the package, committing each patch to git
|
||||||
|
pkh chlog # generates the entry, opens it for editing, commits it
|
||||||
|
git add debian/changelog && git commit -m "d/changelog"
|
||||||
|
pkh build # source package, written next to the tree
|
||||||
|
pkh deb # binary build in a chroot with build deps installed
|
||||||
|
pkh lint # lintian plus pkh-native checks
|
||||||
|
pkh put --ppa user/hello_xxx # uploads the .changes file from the build
|
||||||
|
git push xxx user-fork # push the branch to your fork
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `pkh chlog` and `pkh build` from the root of the source tree; they
|
||||||
|
act on the package in the current directory.
|
||||||
|
|
||||||
|
## Command reference
|
||||||
|
|
||||||
|
- `pkh new [name]` scaffolds a buildable source package. `--lang`
|
||||||
|
picks the build system (`rust`, `python`, `meson`, `cmake`,
|
||||||
|
`autotools`, `go`, `shell`, `makefile`); `--source <PATH>` packages
|
||||||
|
existing sources instead. `--upstream-version` and `--revision` set
|
||||||
|
the version. `--description`, `--homepage`, `--license <SPDX>`,
|
||||||
|
`--command`, `--maintainer "Name <email>"`, and `--depends` fill in
|
||||||
|
the package metadata, with the maintainer defaulting to
|
||||||
|
`DEBFULLNAME`/`DEBEMAIL` and then git config. `--quilt` and
|
||||||
|
`--native` choose the source format; `--orig-from
|
||||||
|
release|git|path|snapshot` and `--orig-path` control the orig
|
||||||
|
tarball. The changelog starts as `UNRELEASED`; `--release` targets
|
||||||
|
`--series` instead. `--defaults` answers every remaining question
|
||||||
|
with its default, which keeps the run non-interactive.
|
||||||
|
|
||||||
|
- `pkh pull <package>` fetches a source package from the archive or
|
||||||
|
git. `--archive` skips git. `--ppa user/ppa` and `--repository
|
||||||
|
<suite-url>` pull from a PPA or an external flat repository instead.
|
||||||
|
`-d`, `-s`, `-v`, and `-p` target an exact source.
|
||||||
|
|
||||||
|
- `pkh chlog` generates the changelog entry from the commits since the
|
||||||
|
last version tag, opens it for editing, and commits it. `--backport`,
|
||||||
|
`--nmu`, and `--rebuild` apply the matching numbering scheme
|
||||||
|
(`3.1-1ubuntu2~24.04.1`, `1.0-1.1`, `1.0-1build1`); `-v` sets an
|
||||||
|
explicit version instead.
|
||||||
|
|
||||||
|
- `pkh build` produces the .dsc. `--orig auto|always|never` controls
|
||||||
|
whether the upload includes the orig tarball; the default, `auto`,
|
||||||
|
includes it only when the upstream version changed.
|
||||||
|
|
||||||
|
- `pkh deb` builds the binary packages in an isolated context with the
|
||||||
|
build dependencies installed. `--ppa` (repeatable) adds dependency
|
||||||
|
sources, `--inject <package|.deb>` preinstalls a package, and `-j`
|
||||||
|
caps parallel jobs. `--cross` cross-compiles instead of using
|
||||||
|
qemu-binfmt, but most packages cannot cross-compile, so prefer qemu.
|
||||||
|
Leave `--mode` unset unless you need a specific build context.
|
||||||
|
|
||||||
|
- `pkh lint [path]` runs lintian plus the pkh-native checks. `--json`
|
||||||
|
emits a machine-readable report and `--list-tags` prints the native
|
||||||
|
tag catalog. `--fail-on` sets the severities that fail the run
|
||||||
|
(errors by default), `--suppress-tags` ignores tags, `--check` runs a
|
||||||
|
single native check, and `--info`, `--pedantic`, and
|
||||||
|
`--experimental` add detail. `--repack` packs the tree fresh instead
|
||||||
|
of reusing the existing build output.
|
||||||
|
|
||||||
|
- `pkh put [changes]` uploads a .changes file to `--ppa user/ppa`.
|
||||||
|
With no argument it uploads the .changes from this package's last
|
||||||
|
build, found next to the source tree. `--force` re-uploads a file
|
||||||
|
that was already uploaded.
|
||||||
|
|
||||||
|
- `pkh prune` removes build artifacts and caches. Run it with
|
||||||
|
`--dry-run` first to list them. `--all` also deletes the cached
|
||||||
|
chroot tarballs, which take long to download again, so use it when
|
||||||
|
you need the disk space.
|
||||||
|
|
||||||
|
## Notes for agent runs
|
||||||
|
|
||||||
|
- Pass `-d`, `-s`, and `-a` whenever the target differs from the host,
|
||||||
|
so runs are reproducible.
|
||||||
|
- Keep runs non-interactive. Pass explicit flags, use `pkh new
|
||||||
|
--defaults`, and set `EDITOR` before `pkh chlog` (`EDITOR=true` keeps
|
||||||
|
the generated text). Commands may ask short questions on the
|
||||||
|
terminal; flags avoid most prompts.
|
||||||
|
- Pass `RUST_LOG=debug` for pkh's own logs. `--verbose` on `pkh build`
|
||||||
|
and `pkh deb` prints raw tool output instead of the live view.
|
||||||
|
- pkh writes the build artifacts (.dsc, .changes, logs) next to the
|
||||||
|
source tree. `pkh put` finds them without arguments, and `pkh prune`
|
||||||
|
removes them again.
|
||||||
|
- Run `pkh lint` before `pkh put`. It exits nonzero when findings reach
|
||||||
|
the `--fail-on` level, which defaults to errors.
|
||||||
|
- pkh intercepts Ctrl+C, runs its cleanup hooks, and exits with status
|
||||||
|
130. `pkh prune` removes anything left over.
|
||||||
|
|
||||||
|
The upstream repository is https://git.vhaudiquet.fr/vhaudiquet/pkh.
|
||||||
|
Its README has longer workflow examples.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
bin_PROGRAMS = {command}
|
||||||
|
{command}_SOURCES = hello.c
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
AC_INIT([{name}], [{upstream_version}])
|
||||||
|
AM_INIT_AUTOMAKE([foreign])
|
||||||
|
AC_PROG_CC
|
||||||
|
AC_CONFIG_FILES([Makefile])
|
||||||
|
AC_OUTPUT
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Placeholder for {name}, generated by `pkh new`. */
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("Hello from {command}!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## The `autotools` template: a C project with a configure.ac built through
|
||||||
|
## debhelper's auto-detection (dh runs autoreconf itself when it finds
|
||||||
|
## configure.ac, debhelper >= 10 — no override needed). The skeleton bodies
|
||||||
|
## below are static data (the first source build runs `autoreconf`,
|
||||||
|
## integrated in the dh sequence, so no generated configure script is
|
||||||
|
## committed); the logic half — the AC_INIT probe and the GNU-gettext
|
||||||
|
## detection (appended to Build-Depends) — lives in
|
||||||
|
## src/new/templates/autotools.rs. hello.c.tpl duplicates the shared C
|
||||||
|
## skeleton of the other C/C++ template directories (see
|
||||||
|
## meson/manifest.yml for why).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: autotools
|
||||||
|
label: C/C++ (Autotools)
|
||||||
|
detect:
|
||||||
|
files: [configure.ac]
|
||||||
|
build_depends:
|
||||||
|
- autoconf
|
||||||
|
- automake
|
||||||
|
- libtool
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@"
|
||||||
|
files:
|
||||||
|
- path: configure.ac
|
||||||
|
template: configure.ac.tpl
|
||||||
|
- path: Makefile.am
|
||||||
|
template: Makefile.am.tpl
|
||||||
|
- path: hello.c
|
||||||
|
template: hello.c.tpl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project({name} VERSION {upstream_version})
|
||||||
|
|
||||||
|
add_executable({command} hello.c)
|
||||||
|
install(TARGETS {command} RUNTIME DESTINATION bin)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Placeholder for {name}, generated by `pkh new`. */
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("Hello from {command}!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## The `cmake` template: a C/C++ project built with CMake through the
|
||||||
|
## debhelper cmake buildsystem. The skeleton bodies below are static data;
|
||||||
|
## the logic half — the project() probe and the wizard's pkg-config
|
||||||
|
## opt-in (appended to Build-Depends) — lives in src/new/templates/cmake.rs.
|
||||||
|
## hello.c.tpl duplicates the shared C skeleton of the other C/C++
|
||||||
|
## template directories (see meson/manifest.yml for why).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: cmake
|
||||||
|
label: C/C++ (CMake)
|
||||||
|
detect:
|
||||||
|
files: [CMakeLists.txt]
|
||||||
|
build_depends:
|
||||||
|
- cmake
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@ --buildsystem=cmake"
|
||||||
|
files:
|
||||||
|
- path: CMakeLists.txt
|
||||||
|
template: CMakeLists.txt.tpl
|
||||||
|
- path: hello.c
|
||||||
|
template: hello.c.tpl
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{name} - empty base tree scaffolded by `pkh new`; there is intentionally no upstream build system here.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## The `empty` template: a metapackage (non-empty Depends list) or an
|
||||||
|
## empty base package with no build system at all — pure `dh $@` plumbing
|
||||||
|
## as a starting point for hand-written rules. Pure data: no hooks, the
|
||||||
|
## metapackage Depends payload travels in the wizard answers, and the
|
||||||
|
## only upstream file is the stub README marking the tree as
|
||||||
|
## intentionally empty.
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: empty
|
||||||
|
label: Metapackage / empty base (no build system)
|
||||||
|
detect:
|
||||||
|
files: []
|
||||||
|
build_depends: []
|
||||||
|
architecture: all
|
||||||
|
rules_dh_line: "dh $@"
|
||||||
|
files:
|
||||||
|
- path: README
|
||||||
|
template: README.tpl
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module {name}
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Placeholder for {name}, generated by `pkh new`.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hello from {command}!")
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## The `go` template: a Go module built through dh-golang. The logic half
|
||||||
|
## — the go.mod module-line probe and the `{go_import_path}` value below —
|
||||||
|
## lives in src/new/templates/go.rs; the skeleton bodies are static data
|
||||||
|
## (the `go` directive of go.mod stays a literal: nothing about it is
|
||||||
|
## answer-derived, so it has no {placeholder}).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: go
|
||||||
|
label: Go module
|
||||||
|
detect:
|
||||||
|
files: [go.mod]
|
||||||
|
build_depends:
|
||||||
|
- golang-any
|
||||||
|
- dh-golang
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@ --buildsystem=golang"
|
||||||
|
source_fields:
|
||||||
|
XS-Go-Import-Path: "{go_import_path}"
|
||||||
|
files:
|
||||||
|
- path: go.mod
|
||||||
|
template: go.mod.tpl
|
||||||
|
- path: main.go
|
||||||
|
template: main.go.tpl
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CC ?= cc
|
||||||
|
CFLAGS ?= -O2 -Wall -Wextra
|
||||||
|
PREFIX ?= /usr
|
||||||
|
|
||||||
|
all: {command}
|
||||||
|
|
||||||
|
{command}: hello.c
|
||||||
|
$(CC) $(CFLAGS) -o $@ hello.c
|
||||||
|
|
||||||
|
install: {command}
|
||||||
|
install -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f {command}
|
||||||
|
|
||||||
|
.PHONY: all install clean
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Placeholder for {name}, generated by `pkh new`. */
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("Hello from {command}!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{command} usr/bin/{command}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## The `makefile` template: a generic project driven by a plain Makefile.
|
||||||
|
## debhelper's makefile buildsystem runs `make` for the build and
|
||||||
|
## `make install DESTDIR=...` when the Makefile carries an `install:`
|
||||||
|
## target (missing targets are skipped gracefully), so plain `dh $@`
|
||||||
|
## plumbing is enough here. The phony-install hint of
|
||||||
|
## src/new/templates/makefile.rs (whether dh_auto_install will run
|
||||||
|
## `make install` for an existing tree) is the only logic; the skeleton
|
||||||
|
## bodies below are static data (the install mapping is rendered for
|
||||||
|
## skeletons only, whose phony install target is known by construction).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: makefile
|
||||||
|
label: Generic (Makefile)
|
||||||
|
detect:
|
||||||
|
files: [Makefile]
|
||||||
|
build_depends:
|
||||||
|
- build-essential
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@"
|
||||||
|
files:
|
||||||
|
- path: hello.c
|
||||||
|
template: hello.c.tpl
|
||||||
|
- path: Makefile
|
||||||
|
template: Makefile.tpl
|
||||||
|
- path: debian/install
|
||||||
|
template: install.tpl
|
||||||
|
skeleton_only: true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Placeholder for {name}, generated by `pkh new`. */
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("Hello from {command}!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## The `meson` template: a C/C++ project built with Meson through the
|
||||||
|
## debhelper meson buildsystem. The skeleton bodies below are static data;
|
||||||
|
## the logic half — the project() probe and the wizard's pkg-config
|
||||||
|
## opt-in (appended to Build-Depends) — lives in src/new/templates/meson.rs.
|
||||||
|
##
|
||||||
|
## hello.c.tpl is deliberately duplicated (byte-identical) across the
|
||||||
|
## makefile, cmake and autotools template directories: every template
|
||||||
|
## directory is self-contained — the registry embeds each directory's
|
||||||
|
## bodies under its own entry — so a shared body would need
|
||||||
|
## cross-directory references the manifest schema has no machinery for.
|
||||||
|
## The duplication replaces the Rust hello_c() helper meson.rs used to
|
||||||
|
## lend cmake.rs and autotools.rs.
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: meson
|
||||||
|
label: C/C++ (Meson)
|
||||||
|
detect:
|
||||||
|
files: [meson.build]
|
||||||
|
build_depends:
|
||||||
|
- meson
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@ --buildsystem=meson"
|
||||||
|
files:
|
||||||
|
- path: meson.build
|
||||||
|
template: meson.build.tpl
|
||||||
|
- path: hello.c
|
||||||
|
template: hello.c.tpl
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
project('{name}', version: '{upstream_version}', license: '{license}', default_options: ['c_std=c11'])
|
||||||
|
|
||||||
|
executable('{command}', 'hello.c', install: true)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Placeholder for {name}, generated by `pkh new`."""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("Hello from {command}!")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## The `python` template: a PEP 517 project built with pybuild. The
|
||||||
|
## skeleton bodies below are static data on the fresh-skeleton baseline
|
||||||
|
## (the setuptools backend): the module directory and the console-script
|
||||||
|
## entry point are named by the `{module_name}` placeholder python.rs
|
||||||
|
## derives from the package name — dpkg names may carry `+`/`.` and may
|
||||||
|
## start with a digit, none of which a Python module name may. The logic
|
||||||
|
## half — the pyproject.toml/setup.py probe and the Build-Depends /
|
||||||
|
## architecture resolution for existing projects (backend package,
|
||||||
|
## pyproject presence, C-extension hints) — lives in
|
||||||
|
## src/new/templates/python.rs.
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: python
|
||||||
|
label: Python (pyproject.toml / setup.py)
|
||||||
|
detect:
|
||||||
|
files: [pyproject.toml, setup.py, setup.cfg]
|
||||||
|
build_depends:
|
||||||
|
- dh-python
|
||||||
|
- python3-all
|
||||||
|
- pybuild-plugin-pyproject
|
||||||
|
- python3-setuptools
|
||||||
|
architecture: all
|
||||||
|
rules_dh_line: "dh $@ --with python3 --buildsystem=pybuild"
|
||||||
|
files:
|
||||||
|
- path: pyproject.toml
|
||||||
|
template: pyproject.toml.tpl
|
||||||
|
- path: "{module_name}/__init__.py"
|
||||||
|
template: __init__.py.tpl
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "{name}"
|
||||||
|
version = "{upstream_version}"
|
||||||
|
description = "{summary}"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
{command} = "{module_name}:main"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[package]
|
||||||
|
name = "{crate_name}"
|
||||||
|
version = "{upstream_version}"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Placeholder for {name}, generated by `pkh new`.
|
||||||
|
fn main() {
|
||||||
|
println!("Hello from {command}!");
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## The `rust` template: a vendored Cargo build (see the module docs of
|
||||||
|
## src/new/templates/rust.rs for the vendoring strategy). The skeleton
|
||||||
|
## bodies and the vendored-build rules overrides below are static data; the
|
||||||
|
## logic half — the cargo vendor post-write hook, the project probe, and
|
||||||
|
## the `{crate_name}` / `{locked}` / `{artifact}` values of the bodies —
|
||||||
|
## lives in that module (dpkg package names may carry `+`/`.`, which cargo
|
||||||
|
## rejects in crate names, so the skeleton crate name is a derived
|
||||||
|
## placeholder, not the raw `{name}`).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: rust
|
||||||
|
label: Rust (Cargo.toml)
|
||||||
|
detect:
|
||||||
|
files: [Cargo.toml]
|
||||||
|
build_depends:
|
||||||
|
- cargo:native
|
||||||
|
- rustc:native
|
||||||
|
architecture: any
|
||||||
|
rules_dh_line: "dh $@"
|
||||||
|
# The vendored-build overrides appended to debian/rules; `--locked` is only
|
||||||
|
# used when the packaged tree already carries a Cargo.lock (the vendoring
|
||||||
|
# hook patches it in once it creates the lockfile), and the built artifact
|
||||||
|
# of a fresh skeleton is named after its crate.
|
||||||
|
rules_extra_file: rules.extra.tpl
|
||||||
|
gitignore_entries:
|
||||||
|
- vendor/
|
||||||
|
- .cargo/config.toml
|
||||||
|
files:
|
||||||
|
- path: Cargo.toml
|
||||||
|
template: Cargo.toml.tpl
|
||||||
|
- path: src/main.rs
|
||||||
|
template: main.rs.tpl
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
override_dh_auto_build:
|
||||||
|
cargo build --release --offline{locked}
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
install -Dm755 target/release/{artifact} debian/{name}/usr/bin/{command}
|
||||||
|
|
||||||
|
override_dh_auto_test:
|
||||||
|
cargo test --release --offline{locked}
|
||||||
|
|
||||||
|
override_dh_update_autotools_config:
|
||||||
|
|
||||||
|
override_dh_clean:
|
||||||
|
# dh_clean unlinks `*.orig` patch backups, but vendored crates
|
||||||
|
# ship files like `Cargo.toml.orig` that cargo's per-file
|
||||||
|
# checksums require on cold builds (chroots, Launchpad).
|
||||||
|
dh_clean -X .orig
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
cargo clean
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{command}.sh usr/bin/{command}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## The `shell` template: a single interpreted script installed to
|
||||||
|
## /usr/bin with plain `dh $@` plumbing. Detection is not marker-based: the
|
||||||
|
## single-script heuristic of src/new/detect.rs (a lone *.sh or shebang
|
||||||
|
## file) maps here. The probe pre-filling the wizard answers from the
|
||||||
|
## script file name lives in src/new/templates/shell.rs; everything else
|
||||||
|
## is the data below (the skeleton script is executable, the install
|
||||||
|
## mapping exists for skeletons only — packaging an existing tree leaves
|
||||||
|
## the mapping to the user).
|
||||||
|
##
|
||||||
|
## Schema: see src/new/templates/mod.rs.
|
||||||
|
|
||||||
|
id: shell
|
||||||
|
label: Shell script / single interpreted file
|
||||||
|
detect:
|
||||||
|
files: []
|
||||||
|
build_depends: []
|
||||||
|
architecture: all
|
||||||
|
rules_dh_line: "dh $@"
|
||||||
|
files:
|
||||||
|
- path: "{command}.sh"
|
||||||
|
template: script.tpl
|
||||||
|
executable: true
|
||||||
|
- path: debian/install
|
||||||
|
template: install.tpl
|
||||||
|
skeleton_only: true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Placeholder for {name}, generated by `pkh new`.
|
||||||
|
echo "Hello from {command}!"
|
||||||
-155
@@ -1,155 +0,0 @@
|
|||||||
## Static data needed for pkh operations
|
|
||||||
## Instead of hardcoding the data in code, data files allow to quickly
|
|
||||||
## update and maintain such data in one unique place
|
|
||||||
## The goal is to have the minimal possible set of data necessary
|
|
||||||
## to grab the actual data. For example we don't want to store every Ubuntu
|
|
||||||
## or Debian series, but rather an URL where we can properly access that data.
|
|
||||||
dist_info:
|
|
||||||
local: /usr/share/distro-info/{dist}
|
|
||||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/
|
|
||||||
dist:
|
|
||||||
debian:
|
|
||||||
base_url: https://deb.debian.org/debian
|
|
||||||
archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc
|
|
||||||
pockets:
|
|
||||||
- updates
|
|
||||||
- security
|
|
||||||
- proposed-updates
|
|
||||||
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:
|
|
||||||
base_url: https://archive.ubuntu.com/ubuntu
|
|
||||||
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
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
# Quirks configuration for package-specific workarounds
|
|
||||||
# This file defines package-specific quirks that are applied during pull and deb operations
|
|
||||||
|
|
||||||
quirks:
|
|
||||||
|
|
||||||
# Add more packages and their quirks as needed
|
|
||||||
# example-package:
|
|
||||||
# pull:
|
|
||||||
# method: archive
|
|
||||||
# deb:
|
|
||||||
# extra_dependencies:
|
|
||||||
# - another-dependency
|
|
||||||
# parameters:
|
|
||||||
# key: value
|
|
||||||
|
|
||||||
+83
-10
@@ -2,12 +2,17 @@ name: pkh
|
|||||||
base: core24
|
base: core24
|
||||||
summary: pkh is a packaging helper for Debian/Ubuntu packages
|
summary: pkh is a packaging helper for Debian/Ubuntu packages
|
||||||
description: |
|
description: |
|
||||||
pkh aims at wrapping the different debian tools and workflows
|
pkh aims at wrapping the different debian tools and workflows
|
||||||
into one tool, that would have the same interface for everything,
|
into one tool, that would have the same interface for everything,
|
||||||
while being smarter at integrating all workflows.
|
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
|
adopt-info: pkh-part
|
||||||
|
|
||||||
confinement: devmode
|
confinement: classic
|
||||||
|
|
||||||
apps:
|
apps:
|
||||||
pkh:
|
pkh:
|
||||||
@@ -19,24 +24,92 @@ parts:
|
|||||||
source: .
|
source: .
|
||||||
override-pull: |
|
override-pull: |
|
||||||
craftctl default
|
craftctl default
|
||||||
craftctl set version=$(git rev-parse --short=11 HEAD)
|
# Release metadata comes from the crate, not the git state: a build
|
||||||
craftctl set grade="devel"
|
# 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-packages:
|
||||||
- build-essential
|
- build-essential
|
||||||
|
- file
|
||||||
|
- patchelf
|
||||||
- pkg-config
|
- pkg-config
|
||||||
- libssl-dev
|
- libssl-dev
|
||||||
- libgpg-error-dev
|
- libgpg-error-dev
|
||||||
- libgpgme-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:
|
stage-packages:
|
||||||
- libgpgme11t64
|
- libgpgme11t64
|
||||||
- git
|
- git
|
||||||
- curl
|
- curl
|
||||||
|
- gnupg
|
||||||
|
- gpgv
|
||||||
|
- dpkg-dev
|
||||||
|
- quilt
|
||||||
- pristine-tar
|
- pristine-tar
|
||||||
- mmdebstrap
|
- mmdebstrap
|
||||||
|
- lintian
|
||||||
|
- fakeroot
|
||||||
- util-linux
|
- util-linux
|
||||||
- dpkg-dev
|
# mount/umount moved to their own package (split from util-linux)
|
||||||
|
- mount
|
||||||
|
- schroot
|
||||||
|
- openssh-client
|
||||||
|
- tar
|
||||||
|
- xz-utils
|
||||||
|
- bzip2
|
||||||
stage:
|
stage:
|
||||||
- -usr/lib/x86_64-linux-gnu/libicuio.so.74.2
|
- -usr/bin/apt
|
||||||
- -usr/lib/x86_64-linux-gnu/libicutest.so.74.2
|
- -usr/bin/apt-cache
|
||||||
- -usr/lib/x86_64-linux-gnu/libicutu.so.74.2
|
- -usr/bin/apt-cdrom
|
||||||
- -usr/lib/x86_64-linux-gnu/libicui18n.so.74.2
|
- -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 {} +
|
||||||
|
|||||||
+81
-33
@@ -4,6 +4,7 @@
|
|||||||
//! for mmdebstrap operations and for PPA packages by downloading them.
|
//! for mmdebstrap operations and for PPA packages by downloading them.
|
||||||
|
|
||||||
use crate::context;
|
use crate::context;
|
||||||
|
use crate::data::embed_data;
|
||||||
use crate::distro_info;
|
use crate::distro_info;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -11,6 +12,26 @@ use std::os::unix::fs::MetadataExt;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
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
|
/// Launchpad API response structure for PPA information
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LaunchpadPpaResponse {
|
struct LaunchpadPpaResponse {
|
||||||
@@ -68,24 +89,27 @@ pub async fn download_cache_keyrings(
|
|||||||
keyring_dir.display()
|
keyring_dir.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
// Upgrade cache directories created by versions that made them
|
||||||
|
// private: mmdebstrap's unshare-mode hooks cannot read them.
|
||||||
} else {
|
} else {
|
||||||
// Remote contexts (e.g. ssh) have no stat/metadata access through
|
// Remote contexts (e.g. ssh) have no stat/metadata access through
|
||||||
// the context API, so the ownership guard cannot be performed;
|
// the context API, so the ownership guard cannot be performed;
|
||||||
// keep the previous best-effort behavior of tightening the
|
// keep the previous best-effort behavior of tightening the
|
||||||
// directory permissions instead (0700 instead of the former
|
// directory permissions instead (no group/others write).
|
||||||
// world-writable a+rwx).
|
|
||||||
ctx.command("chmod").arg("700").arg(&keyring_dir).status()?;
|
|
||||||
}
|
}
|
||||||
|
ctx.command("chmod").arg("755").arg(&keyring_dir).status()?;
|
||||||
} else {
|
} else {
|
||||||
// Create the directory private to the invoking user (0700). This is
|
// Create the directory readable but not writable by group/others.
|
||||||
// sufficient for mmdebstrap in unshare mode: it runs with the same
|
// mmdebstrap's unshare-mode hooks run under an identity that cannot
|
||||||
// real uid (the user namespace only maps that uid to root, file
|
// read the invoking user's private directories, so 0700 breaks the
|
||||||
// access still happens as the real uid), so no world-accessible
|
// keyring copy into the chroot; the planting guard stays on the
|
||||||
// permissions are needed.
|
// 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")
|
ctx.command("mkdir")
|
||||||
.arg("-p")
|
.arg("-p")
|
||||||
.arg("-m")
|
.arg("-m")
|
||||||
.arg("700")
|
.arg("755")
|
||||||
.arg(&keyring_dir)
|
.arg(&keyring_dir)
|
||||||
.status()?;
|
.status()?;
|
||||||
}
|
}
|
||||||
@@ -157,6 +181,11 @@ pub async fn download_cache_keyrings(
|
|||||||
binary_path.display()
|
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!(
|
log::info!(
|
||||||
@@ -198,7 +227,12 @@ fn validate_keyring_dir(dir_uid: u32, mode: u32, euid: u32) -> Result<(), String
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and import a PPA key using Launchpad API
|
/// 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
|
/// # Arguments
|
||||||
/// * `ctx` - Optional context to use
|
/// * `ctx` - Optional context to use
|
||||||
@@ -229,33 +263,36 @@ pub async fn download_trust_ppa_key(
|
|||||||
ppa_name
|
ppa_name
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get PPA information from Launchpad API to get signing key fingerprint
|
// Get PPA information from the Launchpad API to get the signing key
|
||||||
// Use the correct devel API endpoint
|
// fingerprint. The query is context-independent metadata, so it goes
|
||||||
let api_url = format!(
|
// through the shared HTTP client (timeouts, retries) rather than
|
||||||
"https://api.launchpad.net/1.0/~{}/+archive/ubuntu/{}",
|
// shelling out to curl.
|
||||||
ppa_owner, ppa_name
|
let api_url = crate::launchpad::archive_url(ppa_owner, ppa_name);
|
||||||
);
|
|
||||||
log::debug!("Querying Launchpad API: {}", api_url);
|
log::debug!("Querying Launchpad API: {}", api_url);
|
||||||
|
|
||||||
let api_response = ctx
|
let response = distro_info::http_get_retried(&api_url).await.map_err(|e| {
|
||||||
.command("curl")
|
format!(
|
||||||
.arg("-s")
|
"Failed to query Launchpad API for PPA {}/{}: {}",
|
||||||
.arg("-f")
|
ppa_owner, ppa_name, e
|
||||||
.arg("-H")
|
)
|
||||||
.arg("Accept: application/json")
|
})?;
|
||||||
.arg(&api_url)
|
if !response.status().is_success() {
|
||||||
.output()?;
|
|
||||||
|
|
||||||
if !api_response.status.success() {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to query Launchpad API for PPA {}/{}",
|
"Failed to query Launchpad API for PPA {}/{}: HTTP {}",
|
||||||
ppa_owner, ppa_name
|
ppa_owner,
|
||||||
|
ppa_name,
|
||||||
|
response.status()
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the JSON response to extract the signing key fingerprint
|
// Parse the JSON response to extract the signing key fingerprint
|
||||||
let api_response_str = String::from_utf8_lossy(&api_response.stdout);
|
let api_response_str = response.text().await.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to read the Launchpad API response for PPA {}/{}: {}",
|
||||||
|
ppa_owner, ppa_name, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let ppa_response: LaunchpadPpaResponse =
|
let ppa_response: LaunchpadPpaResponse =
|
||||||
serde_json::from_str(&api_response_str).map_err(|e| {
|
serde_json::from_str(&api_response_str).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
@@ -268,10 +305,7 @@ pub async fn download_trust_ppa_key(
|
|||||||
log::debug!("Found PPA signing key fingerprint: {}", fingerprint);
|
log::debug!("Found PPA signing key fingerprint: {}", fingerprint);
|
||||||
|
|
||||||
// Download the actual key from the keyserver using the fingerprint
|
// Download the actual key from the keyserver using the fingerprint
|
||||||
let keyserver_url = format!(
|
let keyserver_url = keyserver_lookup_url(&fingerprint);
|
||||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{}",
|
|
||||||
fingerprint
|
|
||||||
);
|
|
||||||
log::debug!("Downloading key from keyserver: {}", keyserver_url);
|
log::debug!("Downloading key from keyserver: {}", keyserver_url);
|
||||||
|
|
||||||
let mut curl_cmd = ctx.command("curl");
|
let mut curl_cmd = ctx.command("curl");
|
||||||
@@ -306,12 +340,26 @@ pub async fn download_trust_ppa_key(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_validate_keyring_dir_accepts_private_dir_owned_by_current_user() {
|
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, 0o700, 1000).is_ok());
|
||||||
assert!(validate_keyring_dir(1000, 0o750, 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(1000, 0o1744, 1000).is_ok());
|
||||||
assert!(validate_keyring_dir(0, 0o700, 0).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]
|
#[test]
|
||||||
|
|||||||
+3
-3
@@ -851,7 +851,7 @@ fn parse_ppa_url(ppa_base_url: &str) -> Option<(String, String)> {
|
|||||||
.strip_prefix("https://")
|
.strip_prefix("https://")
|
||||||
.or_else(|| ppa_base_url.strip_prefix("http://"))?;
|
.or_else(|| ppa_base_url.strip_prefix("http://"))?;
|
||||||
let (host, path) = rest.split_once('/')?;
|
let (host, path) = rest.split_once('/')?;
|
||||||
if host != "ppa.launchpadcontent.net" {
|
if host != crate::launchpad::ppa_content_host() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,7 +886,7 @@ pub async fn ppa_keyring_bytes(
|
|||||||
return Ok(cached.clone());
|
return Ok(cached.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let api_url = format!("https://api.launchpad.net/1.0/~{owner}/+archive/ubuntu/{name}");
|
let api_url = crate::launchpad::archive_url(&owner, &name);
|
||||||
let response = crate::distro_info::http_get_retried(&api_url).await?;
|
let response = crate::distro_info::http_get_retried(&api_url).await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -905,7 +905,7 @@ pub async fn ppa_keyring_bytes(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let key_url = format!("https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{fingerprint}");
|
let key_url = crate::apt::keyring::keyserver_lookup_url(&fingerprint);
|
||||||
let armored = fetch_keyring_cached(&key_url).await?;
|
let armored = fetch_keyring_cached(&key_url).await?;
|
||||||
let keyring = dearmor(&armored)
|
let keyring = dearmor(&armored)
|
||||||
.map_err(|e| format!("invalid PGP armor in the key of PPA '{owner}/{name}': {e}"))?;
|
.map_err(|e| format!("invalid PGP armor in the key of PPA '{owner}/{name}': {e}"))?;
|
||||||
|
|||||||
+120
-216
@@ -14,11 +14,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::debian::{
|
use crate::debian::{ChecksumEntry, ControlInfo, FileChecksums, FilesList};
|
||||||
ChecksumEntry, ControlInfo, FileChecksums, FilesList, parse_changelog_entry_from_str,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::parse_checksum_field;
|
|
||||||
|
|
||||||
/// Digests of one artifact.
|
/// Digests of one artifact.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -59,7 +55,7 @@ pub struct BinaryMetadataOptions {
|
|||||||
/// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture`
|
/// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture`
|
||||||
/// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo`
|
/// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo`
|
||||||
/// registration in `debian/files`, and binary-NMU handling (`Source:
|
/// registration in `debian/files`, and binary-NMU handling (`Source:
|
||||||
/// pkg (prev)` + previous `.dsc` redistribution when present).
|
/// pkg (prev)` + `Binary-Only-Changes`, with no source files distributed).
|
||||||
pub fn generate_binary_metadata(
|
pub fn generate_binary_metadata(
|
||||||
ctx: &Arc<Context>,
|
ctx: &Arc<Context>,
|
||||||
package_dir: &Path,
|
package_dir: &Path,
|
||||||
@@ -70,7 +66,10 @@ pub fn generate_binary_metadata(
|
|||||||
// Metadata sources inside the context
|
// Metadata sources inside the context
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
||||||
let entry = parse_changelog_entry_from_str(&changelog_content)?;
|
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_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
||||||
let control = ControlInfo::parse_content(&control_content)?;
|
let control = ControlInfo::parse_content(&control_content)?;
|
||||||
@@ -136,37 +135,24 @@ pub fn generate_binary_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Binary-NMU: redistribute the previous source when present
|
// Binary-NMU: reference the previous source version, textually only
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
let sversion = entry.version.no_epoch();
|
let sversion = entry.version.no_epoch();
|
||||||
let mut source_display = entry.source.clone();
|
let mut source_display = entry.source.clone();
|
||||||
let mut binary_only_changes = None;
|
let mut binary_only_changes = None;
|
||||||
|
|
||||||
if entry.binary_only {
|
if entry.binary_only {
|
||||||
// A binary-only upload must reference the previous source version;
|
// Like dpkg-genchanges/genbuildinfo, a binary-only upload references
|
||||||
// a changelog that cannot yield it is a hard error, like in the
|
// the previous source version in the `Source` field and records the
|
||||||
// source-build path. Reuse the changelog read above instead of
|
// entry in `Binary-Only-Changes`, but distributes NO source files:
|
||||||
// reading the file a second time.
|
// the previous `.dsc` and its tarballs already sit in the archive,
|
||||||
let changelog_path = package_dir.join("debian/changelog");
|
// and are not re-uploaded even when present next to the tree.
|
||||||
let prev = crate::debian::changelog::parse_previous_version_from_str(&changelog_content)
|
if let Some(prev) = &previous_entry {
|
||||||
.map_err(|e| {
|
source_display = format!("{} ({})", entry.source, prev.version.full());
|
||||||
format!(
|
|
||||||
"cannot parse the previous version from '{}': {e}",
|
|
||||||
changelog_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if let Some(prev) = prev {
|
|
||||||
source_display = format!("{} ({})", entry.source, prev);
|
|
||||||
binary_only_changes = Some(format!(
|
binary_only_changes = Some(format!(
|
||||||
"{}\n\n -- {} <{}> {}",
|
"{}\n\n -- {} <{}> {}",
|
||||||
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
||||||
));
|
));
|
||||||
let prev_version = crate::debian::DebianVersion::parse(&prev)?;
|
|
||||||
let dsc_name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
|
|
||||||
let dsc_path = upload_dir.join(&dsc_name);
|
|
||||||
if ctx.exists(&dsc_path)? {
|
|
||||||
include_dsc_artifacts(ctx, upload_dir, &dsc_name, &mut checksums)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +291,7 @@ pub fn generate_binary_metadata(
|
|||||||
date: entry.date_raw.clone(),
|
date: entry.date_raw.clone(),
|
||||||
source: source_display,
|
source: source_display,
|
||||||
binaries,
|
binaries,
|
||||||
|
binary_only: entry.binary_only,
|
||||||
built_for_profiles: opts.profiles.clone(),
|
built_for_profiles: opts.profiles.clone(),
|
||||||
architecture: arch_values.join(" "),
|
architecture: arch_values.join(" "),
|
||||||
version: entry.version.full(),
|
version: entry.version.full(),
|
||||||
@@ -408,90 +395,6 @@ fn hashes_in_context(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the `.dsc` checksums (and its referenced tarballs) into the
|
|
||||||
/// checksum registry, mirroring how binary-NMU uploads redistribute the
|
|
||||||
/// previous source.
|
|
||||||
fn include_dsc_artifacts(
|
|
||||||
ctx: &Arc<Context>,
|
|
||||||
upload_dir: &Path,
|
|
||||||
dsc_name: &str,
|
|
||||||
checksums: &mut FileChecksums,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?;
|
|
||||||
let para = crate::debian::control::parse_paragraphs(
|
|
||||||
crate::debian::control::strip_clearsigned_armour(&dsc_content),
|
|
||||||
)
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| format!("'{dsc_name}' is empty"))?;
|
|
||||||
|
|
||||||
// Names and partial checksums are filled from the very same validated
|
|
||||||
// lines, so a listed name can never miss its checksum entry.
|
|
||||||
// Distribution order follows the Checksums fields (Checksums-Sha1 then
|
|
||||||
// Checksums-Sha256), like dpkg-genchanges; the `Files` field only
|
|
||||||
// supplements the md5 digests.
|
|
||||||
let mut names: Vec<String> = Vec::new();
|
|
||||||
let mut partials: BTreeMap<String, super::PartialChecksum> = BTreeMap::new();
|
|
||||||
for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] {
|
|
||||||
let Some(value) = para.get(field) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for cl in parse_checksum_field(field, value)
|
|
||||||
.map_err(|e| format!("cannot parse '{dsc_name}': {e}"))?
|
|
||||||
{
|
|
||||||
let slot = partials.entry(cl.name.clone()).or_default();
|
|
||||||
match field {
|
|
||||||
"Checksums-Sha1" => slot.sha1 = Some(cl.digest),
|
|
||||||
"Checksums-Sha256" => slot.sha256 = Some(cl.digest),
|
|
||||||
_ => slot.md5 = Some(cl.digest),
|
|
||||||
}
|
|
||||||
slot.size = Some(cl.size);
|
|
||||||
if field != "Files" && !names.contains(&cl.name) {
|
|
||||||
names.push(cl.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The .dsc itself is hashed fresh (it may be signed/rewritten); the
|
|
||||||
// tarballs reuse the .dsc-recorded digests, like dpkg-genchanges does.
|
|
||||||
let dsc_hashes =
|
|
||||||
hashes_in_context(ctx, upload_dir, std::slice::from_ref(&dsc_name.to_string()))?;
|
|
||||||
if let Some(h) = dsc_hashes.get(dsc_name) {
|
|
||||||
checksums.insert_entry(
|
|
||||||
dsc_name,
|
|
||||||
ChecksumEntry {
|
|
||||||
size: h.size,
|
|
||||||
md5: h.md5.clone(),
|
|
||||||
sha1: h.sha1.clone(),
|
|
||||||
sha256: h.sha256.clone(),
|
|
||||||
// No SHA-512 digest available (see above).
|
|
||||||
sha512: String::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for name in &names {
|
|
||||||
if name == dsc_name {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let p = partials
|
|
||||||
.get(name)
|
|
||||||
.ok_or_else(|| format!("file '{name}' listed in '{dsc_name}' has no checksum entry"))?;
|
|
||||||
checksums.insert_entry(
|
|
||||||
name,
|
|
||||||
ChecksumEntry {
|
|
||||||
size: p.size.unwrap_or(0),
|
|
||||||
md5: p.md5.clone().unwrap_or_default(),
|
|
||||||
sha1: p.sha1.clone().unwrap_or_default(),
|
|
||||||
sha256: p.sha256.clone().unwrap_or_default(),
|
|
||||||
// The `.dsc` records no SHA-512 (dpkg only writes
|
|
||||||
// sha1/sha256 there).
|
|
||||||
sha512: String::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -529,109 +432,11 @@ mod tests {
|
|||||||
assert!(!environment.contains("DEBIAN_FRONTEND"), "{environment}");
|
assert!(!environment.contains("DEBIAN_FRONTEND"), "{environment}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A minimal previous-version `.dsc` with a 3-column Checksums-Sha1
|
|
||||||
/// field, a 4-column Checksums-Sha256 line and a 3-column `Files`.
|
|
||||||
/// Regression: the old code filled `names` from any line with a third
|
|
||||||
/// column but `partials` only from exactly-3-column lines, so the
|
|
||||||
/// "bogus" name landed in `names` alone and `&partials["bogus"]`
|
|
||||||
/// panicked. It must produce a build error instead.
|
|
||||||
#[test]
|
|
||||||
fn dsc_four_column_checksum_line_errors_instead_of_panicking() {
|
|
||||||
let dsc_name = "hello_1.0-1.dsc";
|
|
||||||
let dsc = "\
|
|
||||||
Format: 3.0 (native)
|
|
||||||
Source: hello
|
|
||||||
Binary: hello
|
|
||||||
Architecture: any
|
|
||||||
Version: 1.0-1
|
|
||||||
Maintainer: A B <a@b.c>
|
|
||||||
Checksums-Sha1:
|
|
||||||
aaa111 12 hello_1.0.orig.tar.xz
|
|
||||||
Checksums-Sha256:
|
|
||||||
bbb222 12 bogus hello_1.0-1.debian.tar.xz
|
|
||||||
Files:
|
|
||||||
ddd333 12 hello_1.0.orig.tar.xz
|
|
||||||
";
|
|
||||||
let base = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
|
|
||||||
|
|
||||||
let ctx = Arc::new(
|
|
||||||
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
||||||
);
|
|
||||||
let mut checksums = FileChecksums::new();
|
|
||||||
let err = include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
|
|
||||||
.expect_err("malformed Checksums-Sha256 line must fail the build");
|
|
||||||
let err = err.to_string();
|
|
||||||
assert!(err.contains("Checksums-Sha256"), "{err}");
|
|
||||||
assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Happy path: tarball entries are assembled from the Checksums fields
|
|
||||||
/// (sha1/sha256) and merged with the legacy 5-column `Files` md5, in
|
|
||||||
/// Checksums-Sha1 order, with the `.dsc` itself hashed fresh first.
|
|
||||||
#[test]
|
|
||||||
fn include_dsc_artifacts_merges_legacy_files_layout() {
|
|
||||||
let dsc_name = "hello_1.0-1.dsc";
|
|
||||||
let tarball = "hello_1.0.orig.tar.xz";
|
|
||||||
let dsc = "\
|
|
||||||
Format: 3.0 (quilt)
|
|
||||||
Source: hello
|
|
||||||
Binary: hello
|
|
||||||
Architecture: any
|
|
||||||
Version: 1.0-1
|
|
||||||
Maintainer: A B <a@b.c>
|
|
||||||
Checksums-Sha1:
|
|
||||||
aaa111 12 hello_1.0.orig.tar.xz
|
|
||||||
Checksums-Sha256:
|
|
||||||
bbb222 12 hello_1.0.orig.tar.xz
|
|
||||||
Files:
|
|
||||||
ddd333 12 devel optional hello_1.0.orig.tar.xz
|
|
||||||
";
|
|
||||||
let base = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc");
|
|
||||||
std::fs::write(base.path().join(tarball), "tarball bytes").expect("write tarball");
|
|
||||||
|
|
||||||
let ctx = Arc::new(
|
|
||||||
crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"),
|
|
||||||
);
|
|
||||||
let mut checksums = FileChecksums::new();
|
|
||||||
include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums)
|
|
||||||
.expect("valid dsc must parse");
|
|
||||||
|
|
||||||
let collected: Vec<(String, crate::debian::ChecksumEntry)> = checksums
|
|
||||||
.iter()
|
|
||||||
.map(|(k, e)| (k.clone(), e.clone()))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
collected
|
|
||||||
.iter()
|
|
||||||
.map(|(k, _)| k.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![dsc_name, tarball],
|
|
||||||
".dsc first, then Checksums-Sha1 order"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The .dsc is hashed fresh from disk.
|
|
||||||
let dsc_entry = &collected[0].1;
|
|
||||||
assert_eq!(dsc_entry.size, dsc.len() as u64);
|
|
||||||
assert_eq!(dsc_entry.md5.len(), 32);
|
|
||||||
assert_eq!(dsc_entry.sha1.len(), 40);
|
|
||||||
assert_eq!(dsc_entry.sha256.len(), 64);
|
|
||||||
|
|
||||||
// The tarball reuses the .dsc-recorded digests, including the
|
|
||||||
// legacy 5-column `Files` md5 (section/priority skipped).
|
|
||||||
let tar_entry = &collected[1].1;
|
|
||||||
assert_eq!(tar_entry.size, 12);
|
|
||||||
assert_eq!(tar_entry.md5, "ddd333");
|
|
||||||
assert_eq!(tar_entry.sha1, "aaa111");
|
|
||||||
assert_eq!(tar_entry.sha256, "bbb222");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A binary-only (binNMU) build whose changelog cannot yield the
|
/// A binary-only (binNMU) build whose changelog cannot yield the
|
||||||
/// previous version (malformed second header, unbalanced parenthesis)
|
/// previous entry (malformed second header, unbalanced parenthesis) must
|
||||||
/// must fail the metadata generation with a diagnostic naming the
|
/// fail the metadata generation with a diagnostic naming the problem,
|
||||||
/// changelog, instead of silently emitting a plain `Source:` `.changes`
|
/// instead of silently emitting a plain `Source:` `.changes` with no
|
||||||
/// with no `Binary-Only-Changes` and no redistributed previous `.dsc`.
|
/// `Binary-Only-Changes` and no previous-version reference.
|
||||||
#[test]
|
#[test]
|
||||||
fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() {
|
fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() {
|
||||||
let changelog = "\
|
let changelog = "\
|
||||||
@@ -683,9 +488,8 @@ Description: test package
|
|||||||
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
||||||
.expect_err("binary-only build with an unparseable changelog must fail");
|
.expect_err("binary-only build with an unparseable changelog must fail");
|
||||||
let err = err.to_string();
|
let err = err.to_string();
|
||||||
assert!(err.contains("debian/changelog"), "{err}");
|
|
||||||
assert!(err.contains("previous version"), "{err}");
|
|
||||||
assert!(err.contains("unbalanced parenthesis"), "{err}");
|
assert!(err.contains("unbalanced parenthesis"), "{err}");
|
||||||
|
assert!(err.contains("1.0-1 unstable"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unreadable `debian/files` (e.g. permissions) must fail the
|
/// An unreadable `debian/files` (e.g. permissions) must fail the
|
||||||
@@ -748,4 +552,104 @@ Description: test package
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
assert!(err.contains("Permission denied"), "{err}");
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,58 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::OrigSourceMode;
|
||||||
|
use crate::debian::changelog::ChangelogEntry;
|
||||||
use crate::debian::checksums::FileChecksums;
|
use crate::debian::checksums::FileChecksums;
|
||||||
use crate::debian::control::{Paragraph, write_paragraph};
|
use crate::debian::control::{Paragraph, write_paragraph};
|
||||||
use crate::debian::files::FilesList;
|
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.
|
/// Everything needed to render a `.changes` file.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChangesInput {
|
pub struct ChangesInput {
|
||||||
@@ -17,6 +65,9 @@ pub struct ChangesInput {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
/// Sorted binary package names with artifacts (empty for source-only).
|
/// Sorted binary package names with artifacts (empty for source-only).
|
||||||
pub binaries: Vec<String>,
|
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.
|
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
|
||||||
pub built_for_profiles: Vec<String>,
|
pub built_for_profiles: Vec<String>,
|
||||||
/// `Architecture` field value in encounter order (e.g. `source`,
|
/// `Architecture` field value in encounter order (e.g. `source`,
|
||||||
@@ -94,6 +145,9 @@ pub fn render_changes(input: &ChangesInput) -> Paragraph {
|
|||||||
let joined = input.binaries.join(" ");
|
let joined = input.binaries.join(" ");
|
||||||
p.set("Binary", &wrap_long(&joined));
|
p.set("Binary", &wrap_long(&joined));
|
||||||
}
|
}
|
||||||
|
if input.binary_only {
|
||||||
|
p.set("Binary-Only", "yes");
|
||||||
|
}
|
||||||
if !input.built_for_profiles.is_empty() {
|
if !input.built_for_profiles.is_empty() {
|
||||||
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
|
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
|
||||||
}
|
}
|
||||||
@@ -159,6 +213,91 @@ pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn st
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn description_formatting() {
|
fn description_formatting() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -196,6 +335,7 @@ mod tests {
|
|||||||
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
|
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
|
||||||
source: "pkg".to_string(),
|
source: "pkg".to_string(),
|
||||||
binaries: vec![],
|
binaries: vec![],
|
||||||
|
binary_only: false,
|
||||||
built_for_profiles: vec![],
|
built_for_profiles: vec![],
|
||||||
architecture: "source".to_string(),
|
architecture: "source".to_string(),
|
||||||
version: "1.0".to_string(),
|
version: "1.0".to_string(),
|
||||||
|
|||||||
+16
-8
@@ -135,15 +135,15 @@ pub fn vendor_from_origins_content(content: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Default build profiles applied by vendor hooks.
|
/// Default build profiles applied by vendor hooks.
|
||||||
///
|
///
|
||||||
/// The Ubuntu vendor module activates `derivative.ubuntu noudeb` by default;
|
/// The distro data carries them (`build_profiles` of the vendor's
|
||||||
/// Debian applies none. This mirrors what `Dpkg::BuildProfiles` resolves when
|
/// distribution in `data/distro_info.yml` — the Ubuntu vendor activates
|
||||||
/// `DEB_BUILD_PROFILES` is unset.
|
/// `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> {
|
pub fn default_build_profiles(vendor: &str) -> Vec<String> {
|
||||||
if vendor.eq_ignore_ascii_case("ubuntu") {
|
crate::distro_info::get_build_profiles(&vendor.to_lowercase()).unwrap_or_default()
|
||||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the active build profiles: explicit `-P` profiles take precedence,
|
/// Resolve the active build profiles: explicit `-P` profiles take precedence,
|
||||||
@@ -378,6 +378,14 @@ mod tests {
|
|||||||
default_build_profiles("ubuntu"),
|
default_build_profiles("ubuntu"),
|
||||||
vec!["derivative.ubuntu".to_string(), "noudeb".to_string()]
|
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]
|
#[test]
|
||||||
|
|||||||
+464
-225
File diff suppressed because it is too large
Load Diff
+1050
-87
File diff suppressed because it is too large
Load Diff
+129
-1
@@ -70,6 +70,12 @@ pub trait ContextDriver {
|
|||||||
fn read_file(&self, path: &Path) -> io::Result<String>;
|
fn read_file(&self, path: &Path) -> io::Result<String>;
|
||||||
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
|
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
|
||||||
fn exists(&self, path: &Path) -> io::Result<bool>;
|
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).
|
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||||
/// Called before the chroot directory is removed.
|
/// Called before the chroot directory is removed.
|
||||||
@@ -117,6 +123,28 @@ pub enum ContextConfig {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ContextConfig {
|
||||||
|
/// Build an SSH context configuration from an endpoint of the form
|
||||||
|
/// `[ssh://][user@]host[:port]`.
|
||||||
|
pub fn from_endpoint(endpoint: &str) -> Result<Self, String> {
|
||||||
|
let re = regex::Regex::new(
|
||||||
|
r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$",
|
||||||
|
)
|
||||||
|
.expect("valid endpoint regex");
|
||||||
|
let cap = re.captures(endpoint).ok_or_else(|| {
|
||||||
|
format!("Invalid endpoint format: '{endpoint}'. Expected [ssh://][user@]host[:port]")
|
||||||
|
})?;
|
||||||
|
let host = cap.name("host").unwrap().as_str().to_string();
|
||||||
|
let user = cap.name("user").map(|m| m.as_str().to_string());
|
||||||
|
let port = cap
|
||||||
|
.name("port")
|
||||||
|
.map(|m| m.as_str().parse::<u16>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| "Invalid port number".to_string())?;
|
||||||
|
Ok(ContextConfig::Ssh { host, user, port })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A context, allowing to run commands, read and write files, etc
|
/// A context, allowing to run commands, read and write files, etc
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
/// Configuration for the context
|
/// Configuration for the context
|
||||||
@@ -221,12 +249,22 @@ impl Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Make a command inside 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<'_> {
|
pub fn command<S: AsRef<OsStr>>(&self, program: S) -> ContextCommand<'_> {
|
||||||
ContextCommand {
|
ContextCommand {
|
||||||
context: self,
|
context: self,
|
||||||
program: program.as_ref().to_string_lossy().to_string(),
|
program: program.as_ref().to_string_lossy().to_string(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
env: Vec::new(),
|
env: vec![
|
||||||
|
("LANG".to_string(), "C".to_string()),
|
||||||
|
("LC_ALL".to_string(), "C".to_string()),
|
||||||
|
],
|
||||||
cwd: None,
|
cwd: None,
|
||||||
sink: None,
|
sink: None,
|
||||||
}
|
}
|
||||||
@@ -281,6 +319,15 @@ impl Context {
|
|||||||
self.driver().as_ref().unwrap().exists(path)
|
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).
|
/// Clean up any resources held by the driver (e.g. unmount overlay filesystems).
|
||||||
/// Called before the chroot directory is removed.
|
/// Called before the chroot directory is removed.
|
||||||
pub fn cleanup(&self) -> io::Result<()> {
|
pub fn cleanup(&self) -> io::Result<()> {
|
||||||
@@ -455,3 +502,84 @@ fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
|
|||||||
io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+52
-14
@@ -24,33 +24,31 @@ impl ContextDriver for LocalDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_temp_dir(&self) -> io::Result<String> {
|
fn create_temp_dir(&self) -> io::Result<String> {
|
||||||
// Generate a unique temporary directory name with random string
|
// Sub-second precision and an atomic create: two concurrent
|
||||||
|
// contexts racing on the same name must never share a directory,
|
||||||
|
// so the loser of a create falls through to the next attempt
|
||||||
|
// instead of probing for existence first (a probe-then-create
|
||||||
|
// window loses exactly when two callers arrive together).
|
||||||
let base_timestamp = SystemTime::now()
|
let base_timestamp = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_millis();
|
||||||
|
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
loop {
|
loop {
|
||||||
let work_dir_name = if attempt == 0 {
|
let work_dir_name = if attempt == 0 {
|
||||||
format!("pkh-{}", base_timestamp)
|
format!("pkh-{base_timestamp}")
|
||||||
} else {
|
} else {
|
||||||
format!("pkh-{}-{}", base_timestamp, attempt)
|
format!("pkh-{base_timestamp}-{attempt}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let temp_dir_path = std::env::temp_dir().join(&work_dir_name);
|
let temp_dir_path = std::env::temp_dir().join(&work_dir_name);
|
||||||
|
|
||||||
// Check if directory already exists
|
match std::fs::create_dir(&temp_dir_path) {
|
||||||
if temp_dir_path.exists() {
|
Ok(()) => return Ok(temp_dir_path.to_string_lossy().to_string()),
|
||||||
attempt += 1;
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => attempt += 1,
|
||||||
continue;
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the directory
|
|
||||||
std::fs::create_dir_all(&temp_dir_path)?;
|
|
||||||
|
|
||||||
// Return the path as a string
|
|
||||||
return Ok(temp_dir_path.to_string_lossy().to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +155,10 @@ impl ContextDriver for LocalDriver {
|
|||||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||||
Ok(path.exists())
|
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<()> {
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||||
@@ -190,3 +192,39 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -385,6 +385,40 @@ mod tests {
|
|||||||
assert!(!dest.join("src/.svn").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
|
/// The overlay-mount path exposes the tree verbatim, so pruning happens
|
||||||
/// after the fact: nested VCS metadata must be removed recursively.
|
/// after the fact: nested VCS metadata must be removed recursively.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -296,6 +296,16 @@ impl ContextDriver for SchrootDriver {
|
|||||||
)?;
|
)?;
|
||||||
Ok(status.success())
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -306,6 +306,14 @@ impl ContextDriver for SshDriver {
|
|||||||
Err(_) => Ok(false),
|
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 {
|
impl SshDriver {
|
||||||
|
|||||||
+26
-17
@@ -296,37 +296,41 @@ impl ContextDriver for UnshareDriver {
|
|||||||
|
|
||||||
fn create_temp_dir(&self) -> io::Result<String> {
|
fn create_temp_dir(&self) -> io::Result<String> {
|
||||||
// Create a temporary directory inside the chroot with unique naming
|
// 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()
|
let base_timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs();
|
.as_millis();
|
||||||
|
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
loop {
|
loop {
|
||||||
let work_dir_name = if attempt == 0 {
|
let work_dir_name = if attempt == 0 {
|
||||||
format!("pkh-build-{}", base_timestamp)
|
format!("pkh-build-{base_timestamp}")
|
||||||
} else {
|
} else {
|
||||||
format!("pkh-build-{}-{}", base_timestamp, attempt)
|
format!("pkh-build-{base_timestamp}-{attempt}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name);
|
let work_dir_inside_chroot = format!("/tmp/{work_dir_name}");
|
||||||
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
|
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
|
||||||
|
|
||||||
// Check if directory already exists
|
match std::fs::create_dir(&host_path) {
|
||||||
if host_path.exists() {
|
Ok(()) => {
|
||||||
attempt += 1;
|
debug!(
|
||||||
continue;
|
"Created work directory: {} (host: {})",
|
||||||
|
work_dir_inside_chroot,
|
||||||
|
host_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the directory on the host filesystem
|
|
||||||
std::fs::create_dir_all(&host_path)?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Created work directory: {} (host: {})",
|
|
||||||
work_dir_inside_chroot,
|
|
||||||
host_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return the path as it appears inside the chroot
|
// Return the path as it appears inside the chroot
|
||||||
return Ok(work_dir_inside_chroot);
|
return Ok(work_dir_inside_chroot);
|
||||||
}
|
}
|
||||||
@@ -352,6 +356,11 @@ impl ContextDriver for UnshareDriver {
|
|||||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||||
self.parent().exists(&host_path)
|
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 {
|
impl UnshareDriver {
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
//! Embedding convention for the static reference data files (`data/*.yml`)
|
||||||
|
//!
|
||||||
|
//! Reference data that changes independently of the code — distro series
|
||||||
|
//! pointers, pinned SSH host keys, package quirks — lives in YAML files
|
||||||
|
//! under `data/` at the repo root instead of hardcoded in the source, so
|
||||||
|
//! it is updatable in one reviewable place.
|
||||||
|
//!
|
||||||
|
//! This module is deliberately not a central registry: each file is
|
||||||
|
//! embedded by the module that owns it (distro_info.rs owns
|
||||||
|
//! data/distro_info.yml, launchpad.rs owns data/launchpad.yml,
|
||||||
|
//! apt/keyring.rs owns data/keyserver.yml, new/origin.rs owns
|
||||||
|
//! data/forges.yml, put/ssh.rs owns data/host_keys.yml, quirks.rs owns
|
||||||
|
//! data/quirks.yml) through the [`embed_data!`] macro below, so data
|
||||||
|
//! and its accessors stay together and a diff touching one domain cannot
|
||||||
|
//! half-touch another. The macro embeds the file at compile time and
|
||||||
|
//! parses it once into a `lazy_static` on first use; since the data ships
|
||||||
|
//! inside the binary, a parse failure is a build-time bug that cannot be
|
||||||
|
//! recovered from at runtime, and the macro panics on it.
|
||||||
|
//!
|
||||||
|
//! Paths and URLs in the data files carry their variable parts as `{name}`
|
||||||
|
//! placeholders, substituted with `str::replace` at the use site — no
|
||||||
|
//! template engine.
|
||||||
|
|
||||||
|
/// Embed one YAML data file as a lazily-parsed static, following the
|
||||||
|
/// convention documented at the module level.
|
||||||
|
///
|
||||||
|
/// Takes the visibility of the generated static (none for private, `pub` or
|
||||||
|
/// `pub(crate)`-style), its name, its struct type (which stays defined in
|
||||||
|
/// the owning module, next to its accessors) and the file path relative to
|
||||||
|
/// the invoking source file (`"../data/distro_info.yml"` from
|
||||||
|
/// `src/distro_info.rs`, `"../../data/host_keys.yml"` from
|
||||||
|
/// `src/put/ssh.rs`, ...), and expands to the house `include_str!` →
|
||||||
|
/// `lazy_static` → parse pattern — only the embed+parse boilerplate is
|
||||||
|
/// generated.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// embed_data! {
|
||||||
|
/// static ref MY_DATA: MyData = "../data/my_data.yml"
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
macro_rules! embed_data {
|
||||||
|
// Internal arm: the visibility arrives wrapped in parentheses (empty for
|
||||||
|
// private statics) because `lazy_static!` only re-matches literal
|
||||||
|
// `pub`/`pub(...)` token sequences, not an opaque forwarded `vis`.
|
||||||
|
(@expand ($($vis:tt)*) static ref $name:ident : $ty:ty = $path:literal) => {
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
// The YAML is include_str!'d at compile time and statically
|
||||||
|
// valid; if it ever failed to parse it would be a build-time bug
|
||||||
|
// that cannot be recovered from at runtime, so panicking here is
|
||||||
|
// acceptable.
|
||||||
|
$($vis)* static ref $name: $ty = serde_yaml::from_str(include_str!($path))
|
||||||
|
.expect(concat!(
|
||||||
|
"built-in ",
|
||||||
|
$path,
|
||||||
|
" data is statically valid and must parse"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(static ref $name:ident : $ty:ty = $path:literal) => {
|
||||||
|
$crate::data::embed_data!(@expand () static ref $name : $ty = $path);
|
||||||
|
};
|
||||||
|
(pub static ref $name:ident : $ty:ty = $path:literal) => {
|
||||||
|
$crate::data::embed_data!(@expand (pub) static ref $name : $ty = $path);
|
||||||
|
};
|
||||||
|
(pub ($($vis:tt)+) static ref $name:ident : $ty:ty = $path:literal) => {
|
||||||
|
$crate::data::embed_data!(@expand (pub ($($vis)+)) static ref $name : $ty = $path);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the macro available through the module path
|
||||||
|
/// (`use crate::data::embed_data;`)
|
||||||
|
pub(crate) use embed_data;
|
||||||
+149
-59
@@ -62,13 +62,60 @@ pub fn setup_environment(
|
|||||||
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
|
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
|
||||||
parse_dpkg_architecture_output(&dpkg_architecture, env);
|
parse_dpkg_architecture_output(&dpkg_architecture, env);
|
||||||
|
|
||||||
|
// In-tree tools locate their libraries with the *host* pkg-config during
|
||||||
|
// cross builds (the kernel's tools/build feature checks derive their
|
||||||
|
// cflags/ldflags from `pkg-config --cflags/--libs`), whose search path
|
||||||
|
// only covers the build architecture's pkgconfig dirs. Point it at the
|
||||||
|
// target's so `libtraceevent` & co resolve to target-arch libraries:
|
||||||
|
// linux-riscv cross builds die in rtla's Makefile.config otherwise, even
|
||||||
|
// with the target -dev packages installed.
|
||||||
|
if let Some(multiarch) = env.get("DEB_HOST_MULTIARCH").cloned() {
|
||||||
|
env.insert(
|
||||||
|
"PKG_CONFIG_LIBDIR".to_string(),
|
||||||
|
format!("/usr/lib/{multiarch}/pkgconfig:/usr/share/pkgconfig"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
|
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
|
||||||
|
|
||||||
Ok(())
|
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
|
/// Ensure that repositories for target architecture are available
|
||||||
/// This also handles the 'ports.ubuntu.com' vs 'archive.ubuntu.com' on Ubuntu
|
///
|
||||||
|
/// On Ubuntu hosts, driven by the bundled distro data
|
||||||
|
/// (`data/distro_info.yml`): the official sources served by the mirror of
|
||||||
|
/// the local architecture (the primary archive and its security sibling)
|
||||||
|
/// are scoped to it and carry every component and cross-build suite, and
|
||||||
|
/// the mirror serving the target architecture (ports, for the non-local
|
||||||
|
/// ones) is added when no existing source serves the arch from it yet.
|
||||||
|
/// Debian hosts are left alone: one mirror serves every architecture, so
|
||||||
|
/// the host's own sources already cover the target — the os-release gate
|
||||||
|
/// below is what makes that a data conclusion instead of hardcoding.
|
||||||
pub fn ensure_repositories(
|
pub fn ensure_repositories(
|
||||||
arch: &str,
|
arch: &str,
|
||||||
series: &str,
|
series: &str,
|
||||||
@@ -97,90 +144,78 @@ pub fn ensure_repositories(
|
|||||||
if !os_release.contains("ID=ubuntu") {
|
if !os_release.contains("ID=ubuntu") {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let dist = "ubuntu";
|
||||||
|
|
||||||
// Load existing sources
|
// Load existing sources
|
||||||
let mut sources = crate::apt::sources::load(Some(ctx.clone()))?;
|
let mut sources = crate::apt::sources::load(Some(ctx.clone()))?;
|
||||||
|
|
||||||
|
// The mirrors serving each side of the cross build (primary for the
|
||||||
|
// local architectures, ports for the others) and the distro data's
|
||||||
|
// components and suites
|
||||||
|
let local_mirror = crate::distro_info::mirror_for_arch(dist, &local_arch)?;
|
||||||
|
let target_mirror = crate::distro_info::mirror_for_arch(dist, arch)?;
|
||||||
|
let components = crate::distro_info::get_dist_components(dist)?;
|
||||||
|
let required_suites = cross_suites(series, pocket, dist)?;
|
||||||
|
|
||||||
// Ensure all components are enabled for the primary architecture
|
// Ensure all components are enabled for the primary architecture
|
||||||
for source in &mut sources {
|
for source in &mut sources {
|
||||||
if source.uri.contains("archive.ubuntu.com") || source.uri.contains("security.ubuntu.com") {
|
// Official sources served by the local mirror (the primary archive
|
||||||
// Scope to local_arch if not already scoped
|
// and its security sibling); ports serves the other architectures
|
||||||
if source.architectures.is_empty() {
|
// and is configured below instead
|
||||||
source.architectures.push(local_arch.clone());
|
if !crate::distro_info::is_mirror_source(local_mirror, &source.uri) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure all components are present
|
// Scope to local_arch if not already scoped
|
||||||
let required_components = ["main", "restricted", "universe", "multiverse"];
|
if source.architectures.is_empty() {
|
||||||
for &comp in &required_components {
|
source.architectures.push(local_arch.clone());
|
||||||
if !source.components.contains(&comp.to_string()) {
|
}
|
||||||
source.components.push(comp.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure all suites (pockets) are enabled, excluding 'proposed'
|
// Ensure all components are present
|
||||||
// unless explicitly requested through the 'pocket' option
|
for comp in &components {
|
||||||
let mut required_suites = vec![
|
if !source.components.contains(comp) {
|
||||||
series.to_string(),
|
source.components.push(comp.clone());
|
||||||
format!("{}-updates", series),
|
|
||||||
format!("{}-backports", series),
|
|
||||||
format!("{}-security", series),
|
|
||||||
];
|
|
||||||
if let Some(p) = pocket {
|
|
||||||
let pocket_suite = format!("{series}-{p}");
|
|
||||||
if !required_suites.contains(&pocket_suite) {
|
|
||||||
required_suites.push(pocket_suite);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for suite in required_suites {
|
}
|
||||||
if !source.suite.contains(&suite) {
|
|
||||||
source.suite.push(suite);
|
// Ensure all suites (pockets) are enabled
|
||||||
}
|
for suite in &required_suites {
|
||||||
|
if !source.suite.contains(suite) {
|
||||||
|
source.suite.push(suite.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if ports repository already exists for the target architecture
|
// Check whether an existing source already serves the target
|
||||||
let has_ports = sources
|
// architecture from its mirror (e.g. the ports mirror for a
|
||||||
.iter()
|
// non-local arch); when cross-building for the local architecture,
|
||||||
.any(|s| s.uri.contains("ports.ubuntu.com") && s.architectures.contains(&arch.to_string()));
|
// the primary sources above already do
|
||||||
|
let has_target = sources.iter().any(|s| {
|
||||||
|
crate::distro_info::is_mirror_source(target_mirror, &s.uri)
|
||||||
|
&& s.architectures.contains(&arch.to_string())
|
||||||
|
});
|
||||||
|
|
||||||
if !has_ports {
|
if !has_target {
|
||||||
// Add ports repository for the target architecture
|
// Add the target architecture's mirror (ports for the non-local
|
||||||
let mut ports_suites = vec![
|
// architectures on Ubuntu)
|
||||||
series.to_string(),
|
let mirror_entry = crate::apt::sources::SourceEntry {
|
||||||
format!("{series}-updates"),
|
|
||||||
format!("{series}-backports"),
|
|
||||||
format!("{series}-security"),
|
|
||||||
];
|
|
||||||
if let Some(p) = pocket {
|
|
||||||
let pocket_suite = format!("{series}-{p}");
|
|
||||||
if !ports_suites.contains(&pocket_suite) {
|
|
||||||
ports_suites.push(pocket_suite);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ports_entry = crate::apt::sources::SourceEntry {
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: crate::apt::sources::SourceKind::Deb,
|
kind: crate::apt::sources::SourceKind::Deb,
|
||||||
components: vec![
|
components: components.clone(),
|
||||||
"main".to_string(),
|
|
||||||
"restricted".to_string(),
|
|
||||||
"universe".to_string(),
|
|
||||||
"multiverse".to_string(),
|
|
||||||
],
|
|
||||||
architectures: vec![arch.to_string()],
|
architectures: vec![arch.to_string()],
|
||||||
uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(),
|
uri: target_mirror.url.clone(),
|
||||||
signed_by: None,
|
signed_by: None,
|
||||||
trusted: None,
|
trusted: None,
|
||||||
suite: ports_suites,
|
suite: required_suites.clone(),
|
||||||
// No origin: saved to the pkh-owned added-sources file
|
// No origin: saved to the pkh-owned added-sources file
|
||||||
origin: None,
|
origin: None,
|
||||||
};
|
};
|
||||||
sources.push(ports_entry);
|
sources.push(mirror_entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated sources: each entry is written back to its origin
|
// 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),
|
// file in its own format (keeping its own Signed-By and Enabled state),
|
||||||
// and the new ports entry goes to the pkh-owned added-sources file
|
// and the new mirror entry goes to the pkh-owned added-sources file
|
||||||
crate::apt::sources::save(Some(ctx.clone()), sources)?;
|
crate::apt::sources::save(Some(ctx.clone()), sources)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -234,4 +269,59 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(env.len(), 2);
|
assert_eq!(env.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The suite list a cross-build environment enables: the series, its
|
||||||
|
/// cross pockets from the distro data (updates, backports, security),
|
||||||
|
/// and the explicitly requested pocket — which is the only way
|
||||||
|
/// 'proposed' gets in.
|
||||||
|
#[test]
|
||||||
|
fn test_cross_suites_from_distro_data() {
|
||||||
|
assert_eq!(
|
||||||
|
cross_suites("noble", None, "ubuntu").unwrap(),
|
||||||
|
vec![
|
||||||
|
"noble".to_string(),
|
||||||
|
"noble-updates".to_string(),
|
||||||
|
"noble-backports".to_string(),
|
||||||
|
"noble-security".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// An explicitly requested pocket is added (not duplicated when it
|
||||||
|
// is already a cross pocket).
|
||||||
|
assert_eq!(
|
||||||
|
cross_suites("noble", Some("proposed"), "ubuntu").unwrap(),
|
||||||
|
vec![
|
||||||
|
"noble".to_string(),
|
||||||
|
"noble-updates".to_string(),
|
||||||
|
"noble-backports".to_string(),
|
||||||
|
"noble-security".to_string(),
|
||||||
|
"noble-proposed".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cross_suites("noble", Some("updates"), "ubuntu").unwrap(),
|
||||||
|
cross_suites("noble", None, "ubuntu").unwrap()
|
||||||
|
);
|
||||||
|
assert!(cross_suites("noble", None, "not-a-distro").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// setup_environment exports the target multiarch pkg-config libdir:
|
||||||
|
/// tools' feature checks run the *host* pkg-config, which must find the
|
||||||
|
/// target's .pc files (rtla hard-errors on libtraceevent otherwise,
|
||||||
|
/// failing linux-riscv cross builds despite the target -dev packages
|
||||||
|
/// being installed).
|
||||||
|
#[test]
|
||||||
|
fn test_setup_environment_exports_cross_pkg_config_libdir() {
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
setup_environment(&mut env, "riscv64", ctx).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.get("PKG_CONFIG_LIBDIR").map(String::as_str),
|
||||||
|
Some("/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
env.get("DEB_BUILD_PROFILES").map(String::as_str),
|
||||||
|
Some("cross")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-297
@@ -1,138 +1,24 @@
|
|||||||
use crate::context::{self, Context, ContextConfig};
|
use crate::context::{self, Context, ContextConfig};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::deb::{Phase, enter_phase};
|
||||||
|
use crate::interrupt::CleanupHookGuard;
|
||||||
|
use crate::report::BuildView;
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::any::Any;
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
use xz2::read::XzDecoder;
|
use xz2::read::XzDecoder;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Process-global cleanup hooks
|
|
||||||
//
|
|
||||||
// On Ctrl-C, the SIGINT handler in `ui::deb` restores the terminal and then
|
|
||||||
// `libc::_exit(130)`s, skipping all destructors — including
|
|
||||||
// [`EphemeralContextGuard::drop`] — which leaks the freshly bootstrapped
|
|
||||||
// chroot under /tmp together with its bind-mounted /proc and any overlayfs
|
|
||||||
// mounts. To make interrupt-time cleanup possible anyway, resources register
|
|
||||||
// a self-contained cleanup hook here; the SIGINT handler drains and runs the
|
|
||||||
// registry right before exiting.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A boxed, send-safe cleanup hook body
|
|
||||||
type CleanupFn = Box<dyn Fn() + Send>;
|
|
||||||
|
|
||||||
/// A pending cleanup hook together with its registry id
|
|
||||||
struct CleanupHook {
|
|
||||||
id: u64,
|
|
||||||
f: CleanupFn,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
|
|
||||||
/// Register a hook to be run by [`run_cleanup_hooks`] (i.e. when the process
|
|
||||||
/// is interrupted), returning a guard whose drop deregisters the hook again
|
|
||||||
fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
|
|
||||||
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
|
|
||||||
CLEANUP_HOOKS.lock().unwrap().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
|
|
||||||
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
|
|
||||||
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();
|
|
||||||
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 from the SIGINT handler right before the process exits. Draining
|
|
||||||
/// uses `try_lock` with a bounded retry instead of a blocking lock: if the
|
|
||||||
/// signal interrupted the main thread while it held [`CLEANUP_HOOKS`] (inside
|
|
||||||
/// register/deregister), blocking on the same non-recursive mutex from the
|
|
||||||
/// handler would deadlock the process. Timing out therefore skips cleanup
|
|
||||||
/// (leaking, as before this registry existed) rather than hanging.
|
|
||||||
pub(crate) 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: Duration = 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 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Interrupt-time chroot cleanup
|
// Interrupt-time chroot cleanup
|
||||||
|
//
|
||||||
|
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
|
||||||
|
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
|
||||||
|
// interrupt sequence skips all destructors, which would otherwise leak the
|
||||||
|
// freshly bootstrapped chroot under /tmp together with its bind-mounted
|
||||||
|
// /proc and any overlayfs mounts.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
|
/// Interrupt-time cleanup of an ephemeral chroot: unmount every host-side
|
||||||
@@ -142,9 +28,9 @@ fn panic_message(panic: &(dyn Any + Send)) -> String {
|
|||||||
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
|
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
|
||||||
/// through the context manager, the ephemeral context's driver (whose
|
/// through the context manager, the ephemeral context's driver (whose
|
||||||
/// `cleanup()` unmounts the tracked overlays) or the base context's command
|
/// `cleanup()` unmounts the tracked overlays) or the base context's command
|
||||||
/// builder: the signal may arrive while the interrupted thread holds any of
|
/// builder: interrupt-time hooks must be self-contained, and those
|
||||||
/// those mutexes, and re-locking them from the signal handler would deadlock.
|
/// machineries may be mid-mutation on the interrupted thread. Instead it
|
||||||
/// Instead it only reads /proc/mounts and spawns umount/rm directly.
|
/// only reads /proc/mounts and spawns umount/rm directly.
|
||||||
///
|
///
|
||||||
/// It also differs from `drop` in that it removes the chroot regardless of
|
/// It also differs from `drop` in that it removes the chroot regardless of
|
||||||
/// the build result: the build was aborted, and leaving a still-mounted
|
/// the build result: the build was aborted, and leaving a still-mounted
|
||||||
@@ -174,31 +60,48 @@ fn sigint_cleanup_chroot(chroot_path: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the chroot tree itself (tolerates a missing directory)
|
// Remove the chroot tree itself (tolerates a missing directory). A
|
||||||
let status = privileged_command("rm", is_root)
|
// child the Ctrl+C interrupted may still be finishing its writeout —
|
||||||
.arg("-rf")
|
// dpkg defers SIGINT until it reaches a safe state — so retry while rm
|
||||||
.arg(chroot_path)
|
// reports the tree non-empty instead of leaving it half-removed.
|
||||||
.status();
|
const RETRIES: usize = 10;
|
||||||
match status {
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||||
Ok(status) if status.success() => {
|
let mut last = None;
|
||||||
|
for attempt in 0..=RETRIES {
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(RETRY_DELAY);
|
||||||
|
}
|
||||||
|
last = Some(
|
||||||
|
privileged_command("rm", is_root)
|
||||||
|
.arg("-rf")
|
||||||
|
.arg(chroot_path)
|
||||||
|
.status(),
|
||||||
|
);
|
||||||
|
if matches!(&last, Some(Ok(status)) if status.success()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match last {
|
||||||
|
Some(Ok(status)) if status.success() => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Removed chroot {} during interrupt cleanup",
|
"Removed chroot {} during interrupt cleanup",
|
||||||
chroot_path.display()
|
chroot_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(status) => {
|
Some(Ok(status)) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to remove chroot {} during interrupt cleanup \
|
"Failed to remove chroot {} during interrupt cleanup \
|
||||||
(rm exited with {status}); run `pkh prune`",
|
(rm exited with {status}); run `pkh prune`",
|
||||||
chroot_path.display()
|
chroot_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Some(Err(e)) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
|
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
|
||||||
chroot_path.display()
|
chroot_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
None => unreachable!("at least one rm attempt ran"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +215,7 @@ impl EphemeralContextGuard {
|
|||||||
series: &str,
|
series: &str,
|
||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
base_ctx: Arc<Context>,
|
base_ctx: Arc<Context>,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
// Save the globally-installed context so Drop can restore exactly
|
// Save the globally-installed context so Drop can restore exactly
|
||||||
// this handle: concurrent builds install their own ephemeral
|
// this handle: concurrent builds install their own ephemeral
|
||||||
@@ -333,15 +236,14 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Register the interrupt-time cleanup hook before any heavy work: if
|
// Register the interrupt-time cleanup hook before any heavy work: if
|
||||||
// the user hits Ctrl-C during bootstrap or the build itself, the
|
// the user hits Ctrl-C during bootstrap or the build itself, the
|
||||||
// SIGINT handler unmounts and removes the chroot through this hook
|
// interrupt watchdog unmounts and removes the chroot through this
|
||||||
// (see `sigint_cleanup_chroot`). This only works for a local base
|
// hook (see `sigint_cleanup_chroot`). This only works for a local
|
||||||
// context: the hook must be self-contained (stored path + direct
|
// base context: the hook must be self-contained (stored path +
|
||||||
// umount/rm subprocesses) and cannot go through `base_ctx`, whose
|
// direct umount/rm subprocesses) and cannot go through `base_ctx`.
|
||||||
// driver mutex may be held by the interrupted thread. For remote or
|
// For remote or nested bases the chroot lives elsewhere, and
|
||||||
// nested bases the chroot lives elsewhere, and leftovers stay
|
// leftovers stay handled by `pkh prune` as before.
|
||||||
// handled by `pkh prune` as before.
|
|
||||||
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
|
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
|
||||||
Some(register_cleanup_hook(Box::new({
|
Some(crate::interrupt::register_cleanup_hook(Box::new({
|
||||||
let chroot_path = chroot_path.clone();
|
let chroot_path = chroot_path.clone();
|
||||||
move || sigint_cleanup_chroot(&chroot_path)
|
move || sigint_cleanup_chroot(&chroot_path)
|
||||||
})))
|
})))
|
||||||
@@ -355,13 +257,21 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Download and extract the chroot tarball
|
// Download and extract the chroot tarball
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui)
|
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// The guard (and its Drop) never materializes on this path, so
|
// On a Ctrl+C the interrupt watchdog owns the tree: keep the
|
||||||
// stop tracking the chroot for interrupt cleanup; as before, a
|
// hook registered (forgetting the guard) so it removes the
|
||||||
// failed bootstrap leaves its partial directory in place.
|
// partial directory, instead of the historical behavior of
|
||||||
drop(cleanup_hook);
|
// leaving it in place. Without an interrupt this is a plain
|
||||||
|
// bootstrap failure and the partial directory stays, as before.
|
||||||
|
if crate::interrupt::interrupted()
|
||||||
|
&& let Some(hook) = cleanup_hook
|
||||||
|
{
|
||||||
|
std::mem::forget(hook);
|
||||||
|
} else {
|
||||||
|
drop(cleanup_hook);
|
||||||
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +318,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
||||||
let ctx_for_devices = ctx.clone();
|
let ctx_for_devices = ctx.clone();
|
||||||
@@ -436,6 +346,11 @@ impl EphemeralContextGuard {
|
|||||||
let poll_interval = 5; // Check every 5 seconds
|
let poll_interval = 5; // Check every 5 seconds
|
||||||
|
|
||||||
while ctx.exists(&lockfile_path)? {
|
while ctx.exists(&lockfile_path)? {
|
||||||
|
// Stop waiting on a Ctrl+C: the interrupt watchdog removes the
|
||||||
|
// (yet empty) chroot and exits without waiting for the poll
|
||||||
|
if crate::interrupt::interrupted() {
|
||||||
|
return Err("Interrupted while waiting for the chroot tarball".into());
|
||||||
|
}
|
||||||
if wait_time >= timeout {
|
if wait_time >= timeout {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Lockfile {} exists and has been present for more than {} seconds. \
|
"Lockfile {} exists and has been present for more than {} seconds. \
|
||||||
@@ -464,10 +379,8 @@ impl EphemeralContextGuard {
|
|||||||
series,
|
series,
|
||||||
arch
|
arch
|
||||||
);
|
);
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::PreparingChroot);
|
||||||
u.phase(Phase::PreparingChroot);
|
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
|
||||||
}
|
|
||||||
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, ui).await?;
|
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Using cached chroot tarball for {} (arch: {:?})",
|
"Using cached chroot tarball for {} (arch: {:?})",
|
||||||
@@ -478,16 +391,12 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Extract tarball to chroot directory
|
// Extract tarball to chroot directory
|
||||||
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::ExtractingChroot);
|
||||||
u.phase(Phase::ExtractingChroot);
|
Self::extract_tarball(&tarball_path, chroot_path, view)?;
|
||||||
}
|
|
||||||
Self::extract_tarball(&tarball_path, chroot_path, ui.as_deref())?;
|
|
||||||
|
|
||||||
// Create device nodes in the chroot
|
// Create device nodes in the chroot
|
||||||
log::debug!("Creating device nodes in chroot...");
|
log::debug!("Creating device nodes in chroot...");
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::FinalizingChroot);
|
||||||
u.phase(Phase::FinalizingChroot);
|
|
||||||
}
|
|
||||||
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
||||||
|
|
||||||
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
||||||
@@ -503,7 +412,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
tarball_path: &Path,
|
tarball_path: &Path,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
||||||
let lockfile_path = tarball_path.with_extension("lock");
|
let lockfile_path = tarball_path.with_extension("lock");
|
||||||
@@ -537,8 +446,8 @@ impl EphemeralContextGuard {
|
|||||||
cmd.arg(series)
|
cmd.arg(series)
|
||||||
.arg(tarball_path.to_string_lossy().to_string());
|
.arg(tarball_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
if let Some(u) = ui {
|
if let Some(s) = view.sink() {
|
||||||
cmd.capture(u.sink());
|
cmd.capture(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
@@ -575,7 +484,7 @@ impl EphemeralContextGuard {
|
|||||||
fn extract_tarball(
|
fn extract_tarball(
|
||||||
tarball_path: &PathBuf,
|
tarball_path: &PathBuf,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
ui: Option<&DebUi>,
|
view: &dyn BuildView,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create the chroot directory
|
// Create the chroot directory
|
||||||
fs::create_dir_all(chroot_path)?;
|
fs::create_dir_all(chroot_path)?;
|
||||||
@@ -590,18 +499,19 @@ impl EphemeralContextGuard {
|
|||||||
// too expensive for multi-hundred-MB chroot tarballs)
|
// too expensive for multi-hundred-MB chroot tarballs)
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for entry in archive.entries()? {
|
for entry in archive.entries()? {
|
||||||
|
// Bail on a Ctrl+C before the interrupt watchdog's rm -rf races
|
||||||
|
// this loop writing entries into the tree being removed
|
||||||
|
if crate::interrupt::interrupted() {
|
||||||
|
return Err("Interrupted while extracting the chroot".into());
|
||||||
|
}
|
||||||
let mut entry = entry?;
|
let mut entry = entry?;
|
||||||
entry.unpack_in(chroot_path)?;
|
entry.unpack_in(chroot_path)?;
|
||||||
count += 1;
|
count += 1;
|
||||||
if count.is_multiple_of(100)
|
if count.is_multiple_of(100) {
|
||||||
&& let Some(u) = ui
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||||
{
|
|
||||||
u.progress_message(&format!("Extracting chroot… ({count} files)"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(u) = ui {
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
||||||
u.progress_message(&format!("Extracting chroot… ({count} files)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -711,6 +621,20 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
impl Drop for EphemeralContextGuard {
|
impl Drop for EphemeralContextGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// On Ctrl+C the interrupt watchdog owns the chroot teardown through
|
||||||
|
// the registered hook: duplicating it here would race the hook's
|
||||||
|
// umount/rm (mounts vanish under each other). Dropping this guard
|
||||||
|
// would normally deregister the hook, so while the watchdog runs it
|
||||||
|
// must be leaked instead to keep it registered (if it was already
|
||||||
|
// drained, forgetting is a harmless no-op).
|
||||||
|
if crate::interrupt::interrupted() {
|
||||||
|
context::manager().set_current_ephemeral(self.previous_context.clone());
|
||||||
|
if let Some(hook) = self.cleanup_hook.take() {
|
||||||
|
std::mem::forget(hook);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Deregister the interrupt-time cleanup hook first: the normal
|
// Deregister the interrupt-time cleanup hook first: the normal
|
||||||
// cleanup below takes care of the chroot, so the hook must not fire
|
// cleanup below takes care of the chroot, so the hook must not fire
|
||||||
// afterwards. (If a SIGINT arrived mid-drop and the hook is already
|
// afterwards. (If a SIGINT arrived mid-drop and the hook is already
|
||||||
@@ -808,132 +732,8 @@ impl Drop for EphemeralContextGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod cleanup_registry_tests {
|
mod chroot_cleanup_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
|
|
||||||
/// Serializes these tests: they drain the process-global registry, and
|
|
||||||
/// unrelated tests (e.g. live end-to-end builds) 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: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
TEST_LOCK
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain the registry and take out only the hooks with the given ids,
|
|
||||||
/// putting everything else back so unrelated registrations (e.g. hooks of
|
|
||||||
/// live end-to-end builds running concurrently) 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().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(Mutex::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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// /proc/mounts path fields use octal escapes for whitespace and
|
/// /proc/mounts path fields use octal escapes for whitespace and
|
||||||
/// backslashes; anything else must be kept verbatim.
|
/// backslashes; anything else must be kept verbatim.
|
||||||
|
|||||||
+668
-150
@@ -1,17 +1,19 @@
|
|||||||
/// Local binary package building
|
/// Local binary package building
|
||||||
/// Directly calling 'debian/rules' in current context
|
/// Directly calling 'debian/rules' in current context
|
||||||
use crate::context::{Context, ContextCommand, LineSink};
|
use crate::context::{Context, ContextCommand, LineSink};
|
||||||
use crate::deb::find_dsc_file;
|
use crate::deb::{Phase, enter_phase, find_dsc_file};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::logfmt::QuiltClassifier;
|
||||||
use crate::ui::logfmt::QuiltClassifier;
|
use crate::report::BuildView;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::apt;
|
use crate::apt;
|
||||||
use crate::deb::cross;
|
use crate::deb::cross;
|
||||||
|
use crate::debian::control::ControlInfo;
|
||||||
|
use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation};
|
||||||
|
|
||||||
/// Attach the capture sink to a command when the live UI is active
|
/// Attach the capture sink to a command when the live UI is active
|
||||||
fn cap<'a>(
|
fn cap<'a>(
|
||||||
@@ -32,14 +34,15 @@ pub async fn build(
|
|||||||
series: &str,
|
series: &str,
|
||||||
pocket: Option<&str>,
|
pocket: Option<&str>,
|
||||||
build_root: &str,
|
build_root: &str,
|
||||||
|
package_dir: &Path,
|
||||||
cross: bool,
|
cross: bool,
|
||||||
ppa: Option<&[&str]>,
|
ppa: &[String],
|
||||||
inject_packages: Option<&[&str]>,
|
inject_packages: &[String],
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
jobs: Option<usize>,
|
jobs: Option<usize>,
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
|
let sink: Option<Arc<dyn LineSink>> = view.sink();
|
||||||
|
|
||||||
// Environment
|
// Environment
|
||||||
let mut env = HashMap::<String, String>::new();
|
let mut env = HashMap::<String, String>::new();
|
||||||
@@ -83,63 +86,64 @@ pub async fn build(
|
|||||||
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
|
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
|
||||||
|
|
||||||
// Add PPA repositories if specified
|
// Add PPA repositories if specified
|
||||||
if let Some(ppas) = ppa {
|
for ppa_str in ppa {
|
||||||
for ppa_str in ppas {
|
let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa_str)?;
|
||||||
// PPA format: user/ppa_name
|
let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name);
|
||||||
let parts: Vec<&str> = ppa_str.split('/').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
let base_url = crate::package_info::ppa_to_base_url(parts[0], parts[1]);
|
|
||||||
|
|
||||||
// Add new PPA source if not found
|
// Add new PPA source if not found
|
||||||
if !sources.iter().any(|s| s.uri.contains(&base_url)) {
|
if !sources.iter().any(|s| s.uri.contains(&base_url)) {
|
||||||
// Get host and target architectures
|
// Get host and target architectures
|
||||||
let host_arch = crate::get_current_arch();
|
let host_arch = crate::get_current_arch();
|
||||||
let target_arch = arch;
|
let target_arch = arch;
|
||||||
|
|
||||||
// Create architectures list with both host and target if different
|
// Create architectures list with both host and target if different
|
||||||
let mut architectures = vec![host_arch.clone()];
|
let mut architectures = vec![host_arch.clone()];
|
||||||
if host_arch != *target_arch {
|
if host_arch != *target_arch {
|
||||||
architectures.push(target_arch.to_string());
|
architectures.push(target_arch.to_string());
|
||||||
}
|
|
||||||
|
|
||||||
// Create suite list with all Ubuntu series
|
|
||||||
let suites = vec![series.to_string()];
|
|
||||||
|
|
||||||
let new_source = crate::apt::sources::SourceEntry {
|
|
||||||
enabled: true,
|
|
||||||
kind: crate::apt::sources::SourceKind::Deb,
|
|
||||||
components: vec!["main".to_string()],
|
|
||||||
architectures: architectures.clone(),
|
|
||||||
signed_by: None,
|
|
||||||
trusted: None,
|
|
||||||
suite: suites,
|
|
||||||
uri: base_url,
|
|
||||||
// No origin: saved to the pkh-owned added-sources file
|
|
||||||
origin: None,
|
|
||||||
};
|
|
||||||
sources.push(new_source);
|
|
||||||
modified = true;
|
|
||||||
added_ppas.push((parts[0], parts[1]));
|
|
||||||
log::info!(
|
|
||||||
"Added PPA: {} for series {} with architectures {:?}",
|
|
||||||
ppa_str,
|
|
||||||
series,
|
|
||||||
architectures
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(
|
|
||||||
format!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str).into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create suite list with all Ubuntu series
|
||||||
|
let suites = vec![series.to_string()];
|
||||||
|
|
||||||
|
let new_source = crate::apt::sources::SourceEntry {
|
||||||
|
enabled: true,
|
||||||
|
kind: crate::apt::sources::SourceKind::Deb,
|
||||||
|
components: vec!["main".to_string()],
|
||||||
|
architectures: architectures.clone(),
|
||||||
|
signed_by: None,
|
||||||
|
trusted: None,
|
||||||
|
suite: suites,
|
||||||
|
uri: base_url,
|
||||||
|
// No origin: saved to the pkh-owned added-sources file
|
||||||
|
origin: None,
|
||||||
|
};
|
||||||
|
sources.push(new_source);
|
||||||
|
modified = true;
|
||||||
|
added_ppas.push((ppa_user, ppa_name));
|
||||||
|
log::info!(
|
||||||
|
"Added PPA: {} for series {} with architectures {:?}",
|
||||||
|
ppa_str,
|
||||||
|
series,
|
||||||
|
architectures
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UBUNTU: Ensure 'universe' repository is enabled
|
// UBUNTU: Ensure the 'universe' component is enabled on official
|
||||||
for source in &mut sources {
|
// Ubuntu sources (many build dependencies live there). The old
|
||||||
if source.uri.contains("ubuntu") && !source.components.contains(&"universe".to_string()) {
|
// `uri.contains("ubuntu")` gate also caught third-party repositories
|
||||||
source.components.push("universe".to_string());
|
// whose URL merely mentions Ubuntu; the mirror-data check leaves them
|
||||||
modified = true;
|
// alone. 'universe' is only added when Ubuntu's component list still
|
||||||
|
// carries it.
|
||||||
|
let ubuntu_components = crate::distro_info::get_dist_components("ubuntu")?;
|
||||||
|
if ubuntu_components.iter().any(|c| c == "universe") {
|
||||||
|
for source in &mut sources {
|
||||||
|
if crate::distro_info::is_official_source("ubuntu", &source.uri)
|
||||||
|
&& !source.components.contains(&"universe".to_string())
|
||||||
|
{
|
||||||
|
source.components.push("universe".to_string());
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +187,7 @@ pub async fn build(
|
|||||||
|
|
||||||
// Update package lists
|
// Update package lists
|
||||||
log::debug!("Updating package lists for local build...");
|
log::debug!("Updating package lists for local build...");
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::UpdatingPackageLists);
|
||||||
u.phase(Phase::UpdatingPackageLists);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
||||||
&sink,
|
&sink,
|
||||||
@@ -224,17 +226,14 @@ pub async fn build(
|
|||||||
cmd.arg(format!("libc6:{arch}"));
|
cmd.arg(format!("libc6:{arch}"));
|
||||||
cmd.arg(format!("libc6-dev:{arch}"));
|
cmd.arg(format!("libc6-dev:{arch}"));
|
||||||
}
|
}
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::InstallingEssentials);
|
||||||
u.phase(Phase::InstallingEssentials);
|
|
||||||
}
|
|
||||||
let status = cap(&mut cmd, &sink).status()?;
|
let status = cap(&mut cmd, &sink).status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Could not install essential packages for the build".into());
|
return Err("Could not install essential packages for the build".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the actual package directory
|
// The package directory was resolved by the caller (the staged copy of
|
||||||
let package_dir =
|
// the tree the user pointed at, or the name-pattern search fallback)
|
||||||
crate::deb::find_package_directory(Path::new(build_root), package, version, &ctx)?;
|
|
||||||
let package_dir_str = package_dir
|
let package_dir_str = package_dir
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or("Invalid package directory path")?;
|
.ok_or("Invalid package directory path")?;
|
||||||
@@ -251,78 +250,34 @@ pub async fn build(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply quilt patches if the package provides a patch series
|
// Apply quilt patches if the package provides a patch series
|
||||||
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
|
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
|
||||||
|
|
||||||
// Install injected packages if specified
|
// Install injected packages if specified
|
||||||
if let Some(packages) = inject_packages {
|
if !inject_packages.is_empty() {
|
||||||
install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?;
|
install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install arch-specific build dependencies
|
// Resolve and install the Build-* dependencies with dpkg's cross
|
||||||
log::debug!("Installing arch-specific build dependencies...");
|
// semantics; this replaces the historical `apt-get build-dep` passes,
|
||||||
if let Some(u) = &ui {
|
// whose `--host-architecture` resolution cannot express the
|
||||||
u.phase(Phase::InstallingBuildDeps);
|
// Multi-Arch-aware variant choice dpkg's checker requires.
|
||||||
}
|
install_build_dependencies(
|
||||||
let mut cmd = ctx.command("apt-get");
|
package,
|
||||||
cmd.current_dir(package_dir_str)
|
version,
|
||||||
.envs(env.clone())
|
arch,
|
||||||
.arg("-y")
|
series,
|
||||||
.arg("build-dep");
|
package_dir_str,
|
||||||
if cross {
|
build_root,
|
||||||
cmd.arg(format!("--host-architecture={arch}"));
|
cross,
|
||||||
}
|
&env,
|
||||||
cmd.arg("--arch-only");
|
ctx.clone(),
|
||||||
let status = cap(&mut cmd, &sink).arg("./").status()?;
|
view,
|
||||||
|
&sink,
|
||||||
// If build-dep fails, we try to explain the failure using dose-debcheck
|
)?;
|
||||||
if !status.success() {
|
|
||||||
if let Some(u) = &ui {
|
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install arch-independant build dependencies, only if the source declares
|
|
||||||
// any: without --arch-only this pass resolves the whole Build-Depends field
|
|
||||||
// too, which is redundant after the first pass and breaks cross builds.
|
|
||||||
let has_indep_deps = match ctx.read_file(&package_dir.join("debian/control")) {
|
|
||||||
Ok(control) => control
|
|
||||||
.lines()
|
|
||||||
.any(|l| l.to_ascii_lowercase().starts_with("build-depends-indep:")),
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!("cannot read debian/control for indep build-deps: {}", e);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if has_indep_deps {
|
|
||||||
log::debug!("Installing arch-independant build dependencies...");
|
|
||||||
let mut cmd = ctx.command("apt-get");
|
|
||||||
cmd.current_dir(package_dir_str)
|
|
||||||
.envs(env.clone())
|
|
||||||
.arg("-y")
|
|
||||||
.arg("build-dep");
|
|
||||||
if cross {
|
|
||||||
cmd.arg(format!("--host-architecture={arch}"));
|
|
||||||
}
|
|
||||||
cmd.arg("./");
|
|
||||||
let status = cap(&mut cmd, &sink).status()?;
|
|
||||||
|
|
||||||
// If build-dep fails, we try to explain the failure using dose-debcheck
|
|
||||||
if !status.success() {
|
|
||||||
if let Some(u) = &ui {
|
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the build step
|
// Run the build step
|
||||||
log::debug!("Building (debian/rules build) package...");
|
log::debug!("Building (debian/rules build) package...");
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::Building);
|
||||||
u.phase(Phase::Building);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("debian/rules")
|
ctx.command("debian/rules")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
@@ -336,9 +291,7 @@ pub async fn build(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the 'binary' step to produce deb
|
// Run the 'binary' step to produce deb
|
||||||
if let Some(u) = &ui {
|
enter_phase(view, Phase::ProducingBinaries);
|
||||||
u.phase(Phase::ProducingBinaries);
|
|
||||||
}
|
|
||||||
let status = cap(
|
let status = cap(
|
||||||
ctx.command("fakeroot")
|
ctx.command("fakeroot")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
@@ -384,6 +337,416 @@ pub async fn build(
|
|||||||
Ok(artifacts)
|
Ok(artifacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One candidate binary package for a dependency name, as reported by
|
||||||
|
/// `apt-cache show`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Candidate {
|
||||||
|
/// Debian architecture of the candidate (`amd64`, `arm64`, `all`, ...).
|
||||||
|
arch: String,
|
||||||
|
/// Multi-Arch attribute; an absent field means `no`.
|
||||||
|
multiarch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure core of the install strategy: the apt install specification that
|
||||||
|
/// satisfies `rel` among the `candidates` available for its package name.
|
||||||
|
///
|
||||||
|
/// The mapping reproduces the dpkg cross semantics that
|
||||||
|
/// [`crate::debian::deps`] evaluates against (`Dpkg::Deps::KnownFacts
|
||||||
|
/// _find_package`), preferring the runnable build-architecture variant
|
||||||
|
/// whenever dpkg's rules accept several:
|
||||||
|
///
|
||||||
|
/// - unqualified: a `Multi-Arch: foreign` build-architecture candidate
|
||||||
|
/// (tools: the variant that runs on the build machine), then the
|
||||||
|
/// host-architecture candidate (the variant dpkg checks unqualified
|
||||||
|
/// dependencies against), then an `Architecture: all` candidate. A
|
||||||
|
/// foreign-arch candidate of a non-foreign package satisfies nothing in
|
||||||
|
/// cross mode — installing it would diverge from the checker;
|
||||||
|
/// - `:native`: the build-architecture candidate (`Architecture: all`
|
||||||
|
/// accepted); a `Multi-Arch: foreign` candidate aborts the lookup,
|
||||||
|
/// like dpkg's checker;
|
||||||
|
/// - `:any`: a `Multi-Arch: allowed` candidate, host architecture first;
|
||||||
|
/// - explicit `:arch`: exactly that candidate;
|
||||||
|
/// - a name with no candidate at all is passed through unqualified, so
|
||||||
|
/// apt resolves virtual packages (`debhelper-compat`, dbus session
|
||||||
|
/// alternatives, ...) to a satisfying provider.
|
||||||
|
///
|
||||||
|
/// `None` when no candidate can satisfy the relation per dpkg semantics:
|
||||||
|
/// the caller falls through to the next alternative of the clause.
|
||||||
|
fn install_spec_for(
|
||||||
|
rel: &PkgRelation,
|
||||||
|
candidates: &[Candidate],
|
||||||
|
build_arch: &str,
|
||||||
|
host_arch: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let qualified = |arch: &str| format!("{}:{}", rel.package, arch);
|
||||||
|
match rel.arch_qualifier.as_deref() {
|
||||||
|
Some("native") => {
|
||||||
|
if candidates
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.arch == build_arch && c.multiarch == "foreign")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == build_arch)
|
||||||
|
.map(|_| qualified(build_arch))
|
||||||
|
.or_else(|| {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == "all")
|
||||||
|
.map(|_| rel.package.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("any") => candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.multiarch == "allowed")
|
||||||
|
.find(|c| c.arch == host_arch)
|
||||||
|
.or_else(|| candidates.iter().find(|c| c.multiarch == "allowed"))
|
||||||
|
.map(|c| qualified(&c.arch)),
|
||||||
|
Some(qual) => candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == qual)
|
||||||
|
.map(|_| qualified(qual)),
|
||||||
|
None => {
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Some(rel.package.clone());
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == build_arch && c.multiarch == "foreign")
|
||||||
|
.map(|_| qualified(build_arch))
|
||||||
|
.or_else(|| {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == host_arch)
|
||||||
|
.map(|_| qualified(host_arch))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.arch == "all")
|
||||||
|
.map(|_| rel.package.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure core of the resolver loop: the next batch of install
|
||||||
|
/// specifications, one alternative per still-unsatisfied clause,
|
||||||
|
/// advancing each clause's cursor past the alternatives that no candidate
|
||||||
|
/// can satisfy per dpkg semantics.
|
||||||
|
fn next_install_pass(
|
||||||
|
clauses: &[Vec<PkgRelation>],
|
||||||
|
unsatisfied: &[usize],
|
||||||
|
cursors: &mut [usize],
|
||||||
|
candidates: &BTreeMap<String, Vec<Candidate>>,
|
||||||
|
build_arch: &str,
|
||||||
|
host_arch: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
for &i in unsatisfied {
|
||||||
|
let empty = Vec::new();
|
||||||
|
while cursors[i] < clauses[i].len() {
|
||||||
|
let rel = &clauses[i][cursors[i]];
|
||||||
|
let cands = candidates.get(&rel.package).unwrap_or(&empty);
|
||||||
|
let spec = install_spec_for(rel, cands, build_arch, host_arch);
|
||||||
|
cursors[i] += 1;
|
||||||
|
if let Some(spec) = spec {
|
||||||
|
specs.push(spec);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
specs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the candidates available in the build context for `names`: one
|
||||||
|
/// entry per (package, architecture). The plain name surfaces the
|
||||||
|
/// build-architecture and `all` candidates; the host-qualified name is
|
||||||
|
/// what surfaces foreign-arch candidates. Names missing from the indexes
|
||||||
|
/// and virtual names simply produce no stanza.
|
||||||
|
fn query_candidates(
|
||||||
|
ctx: &Arc<Context>,
|
||||||
|
names: &[String],
|
||||||
|
build_arch: &str,
|
||||||
|
host_arch: &str,
|
||||||
|
) -> BTreeMap<String, Vec<Candidate>> {
|
||||||
|
let mut query: Vec<String> = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
query.push(name.clone());
|
||||||
|
if host_arch != build_arch {
|
||||||
|
query.push(format!("{name}:{host_arch}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Ok(output) = ctx.command("apt-cache").arg("show").args(&query).output() else {
|
||||||
|
return BTreeMap::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut map: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
|
||||||
|
for para in crate::debian::control::parse_paragraphs(&String::from_utf8_lossy(&output.stdout)) {
|
||||||
|
let Some(name) = para.get("Package") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(arch) = para.get("Architecture") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = map.entry(name.to_string()).or_default();
|
||||||
|
if entry.iter().any(|c| c.arch == arch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.push(Candidate {
|
||||||
|
arch: arch.to_string(),
|
||||||
|
multiarch: para.get("Multi-Arch").unwrap_or("no").to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build architecture inside the build context.
|
||||||
|
fn context_build_arch(ctx: &Arc<Context>) -> String {
|
||||||
|
ctx.command("dpkg")
|
||||||
|
.arg("--print-architecture")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(crate::get_current_arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active build profiles for the dependency evaluation: what the build
|
||||||
|
/// steps run with (`DEB_BUILD_PROFILES`, set to `cross` for cross
|
||||||
|
/// builds), else the vendor defaults.
|
||||||
|
fn build_profiles_for(env: &HashMap<String, String>, ctx: &Arc<Context>) -> Vec<String> {
|
||||||
|
match env.get("DEB_BUILD_PROFILES") {
|
||||||
|
Some(value) => value
|
||||||
|
.split(',')
|
||||||
|
.map(|p| p.trim().to_string())
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.collect(),
|
||||||
|
None => {
|
||||||
|
let vendor = ctx
|
||||||
|
.read_file(Path::new("/etc/dpkg/origins/default"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| crate::build::env::vendor_from_origins_content(&content))
|
||||||
|
.unwrap_or_else(crate::build::env::current_vendor);
|
||||||
|
crate::build::env::resolve_build_profiles(&[], &vendor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installed-package facts inside the build context. An unreadable status
|
||||||
|
/// database (fresh chroot) yields empty facts: everything declared then
|
||||||
|
/// installs.
|
||||||
|
fn load_context_facts(ctx: &Arc<Context>, build_arch: &str, host_arch: &str) -> Facts {
|
||||||
|
match ctx.read_file(Path::new("/var/lib/dpkg/status")) {
|
||||||
|
Ok(content) => Facts::from_status(&content, host_arch, build_arch),
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("cannot read the context dpkg status: {e}");
|
||||||
|
Facts::new(host_arch, build_arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and install the Build-* dependencies with dpkg's cross
|
||||||
|
/// semantics: the clauses of `debian/control` are evaluated natively (the
|
||||||
|
/// `crate::debian::deps` `dpkg-checkbuilddeps` equivalent) against the
|
||||||
|
/// context's installed-package state, and the unsatisfied ones install
|
||||||
|
/// through explicitly architecture-qualified names.
|
||||||
|
///
|
||||||
|
/// Build conflicts abort before anything installs. The loop installs one
|
||||||
|
/// alternative per still-unsatisfied clause per apt transaction and
|
||||||
|
/// re-evaluates after each; a clause whose alternatives no candidate can
|
||||||
|
/// satisfy per dpkg semantics aborts the build. On failure the dose3
|
||||||
|
/// diagnosis runs, like the historical `build-dep` passes.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn install_build_dependencies(
|
||||||
|
package: &str,
|
||||||
|
version: &str,
|
||||||
|
arch: &str,
|
||||||
|
series: &str,
|
||||||
|
package_dir: &str,
|
||||||
|
build_root: &str,
|
||||||
|
cross: bool,
|
||||||
|
env: &HashMap<String, String>,
|
||||||
|
ctx: Arc<Context>,
|
||||||
|
view: &dyn BuildView,
|
||||||
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
enter_phase(view, Phase::InstallingBuildDeps);
|
||||||
|
|
||||||
|
let host_arch = arch.to_string();
|
||||||
|
let build_arch = context_build_arch(&ctx);
|
||||||
|
let profiles = build_profiles_for(env, &ctx);
|
||||||
|
|
||||||
|
let control_content = ctx
|
||||||
|
.read_file(&Path::new(package_dir).join("debian/control"))
|
||||||
|
.map_err(|e| format!("cannot read debian/control: {e}"))?;
|
||||||
|
let control = ControlInfo::parse_content(&control_content)
|
||||||
|
.map_err(|e| format!("invalid debian/control in {package_dir}: {e}"))?;
|
||||||
|
let source = &control.source;
|
||||||
|
let deps_value = ["Build-Depends", "Build-Depends-Arch", "Build-Depends-Indep"]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| source.get(f))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let conflicts_value = [
|
||||||
|
"Build-Conflicts",
|
||||||
|
"Build-Conflicts-Arch",
|
||||||
|
"Build-Conflicts-Indep",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| source.get(f))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
if deps_value.trim().is_empty() && conflicts_value.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parse_opts = ParseOpts {
|
||||||
|
host_arch: host_arch.clone(),
|
||||||
|
build_arch: build_arch.clone(),
|
||||||
|
build_profiles: profiles,
|
||||||
|
reduce_restrictions: true,
|
||||||
|
union: false,
|
||||||
|
build_dep: true,
|
||||||
|
};
|
||||||
|
let mut clauses: Vec<Vec<PkgRelation>> = Deps::parse(&deps_value, &parse_opts)?
|
||||||
|
.clauses()
|
||||||
|
.map(<[PkgRelation]>::to_vec)
|
||||||
|
.collect();
|
||||||
|
// Package-specific workarounds (see data/quirks.yml), before anything
|
||||||
|
// derives candidate queries or install specs from the clauses.
|
||||||
|
crate::quirks::apply_dependency_quirks(package, series, &mut clauses, &parse_opts)?;
|
||||||
|
let conflict_clauses: Vec<Vec<PkgRelation>> = if conflicts_value.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let union_opts = ParseOpts {
|
||||||
|
union: true,
|
||||||
|
..parse_opts.clone()
|
||||||
|
};
|
||||||
|
Deps::parse(&conflicts_value, &union_opts)?
|
||||||
|
.clauses()
|
||||||
|
.map(<[PkgRelation]>::to_vec)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let names: Vec<String> = clauses
|
||||||
|
.iter()
|
||||||
|
.chain(conflict_clauses.iter())
|
||||||
|
.flatten()
|
||||||
|
.map(|rel| rel.package.clone())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let candidates = query_candidates(&ctx, &names, &build_arch, &host_arch);
|
||||||
|
|
||||||
|
let mut cursors = vec![0usize; clauses.len()];
|
||||||
|
loop {
|
||||||
|
let facts = load_context_facts(&ctx, &build_arch, &host_arch);
|
||||||
|
|
||||||
|
// Build conflicts use the same lookup as dependencies in dpkg's
|
||||||
|
// checker: a satisfied conflict clause aborts before anything
|
||||||
|
// installs.
|
||||||
|
let mut violated = Vec::new();
|
||||||
|
for alternatives in &conflict_clauses {
|
||||||
|
for rel in alternatives {
|
||||||
|
if facts.evaluate_relation(rel) == Some(true) {
|
||||||
|
violated.push(rel.output());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !violated.is_empty() {
|
||||||
|
view.suspend();
|
||||||
|
if let Err(e) =
|
||||||
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())
|
||||||
|
{
|
||||||
|
log::debug!("dose-builddebcheck diagnosis failed: {e}");
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"build dependencies conflict with installed packages: {}",
|
||||||
|
violated.join(", ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsatisfied: Vec<usize> = (0..clauses.len())
|
||||||
|
.filter(|&i| {
|
||||||
|
!clauses[i]
|
||||||
|
.iter()
|
||||||
|
.any(|rel| facts.evaluate_relation(rel) == Some(true))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if unsatisfied.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let specs = next_install_pass(
|
||||||
|
&clauses,
|
||||||
|
&unsatisfied,
|
||||||
|
&mut cursors,
|
||||||
|
&candidates,
|
||||||
|
&build_arch,
|
||||||
|
&host_arch,
|
||||||
|
);
|
||||||
|
if specs.is_empty() {
|
||||||
|
view.suspend();
|
||||||
|
if let Err(e) =
|
||||||
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())
|
||||||
|
{
|
||||||
|
log::debug!("dose-builddebcheck diagnosis failed: {e}");
|
||||||
|
}
|
||||||
|
let remaining: Vec<String> = unsatisfied
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
clauses[i]
|
||||||
|
.iter()
|
||||||
|
.map(PkgRelation::output)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return Err(format!(
|
||||||
|
"Could not satisfy build dependencies per dpkg cross semantics: {}",
|
||||||
|
remaining.join(", ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("Installing build dependencies: {:?}", specs);
|
||||||
|
let mut cmd = ctx.command("apt-get");
|
||||||
|
cmd.envs(env.clone()).arg("-y").arg("install");
|
||||||
|
for spec in &specs {
|
||||||
|
cmd.arg(spec);
|
||||||
|
}
|
||||||
|
let status = cap(&mut cmd, sink).status()?;
|
||||||
|
if !status.success() {
|
||||||
|
view.suspend();
|
||||||
|
// Diagnosing a dependency failure the user interrupted themselves
|
||||||
|
// is wasted work
|
||||||
|
if !crate::interrupt::interrupted()
|
||||||
|
&& let Err(e) = dose3_explain_dependencies(
|
||||||
|
package,
|
||||||
|
version,
|
||||||
|
arch,
|
||||||
|
build_root,
|
||||||
|
cross,
|
||||||
|
ctx.clone(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
log::debug!("dose-builddebcheck diagnosis failed: {e}");
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"Could not install build-dependencies for the build: {}",
|
||||||
|
specs.join(" ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Collect the binary artifacts (.deb/.udeb) registered by the build in
|
/// Collect the binary artifacts (.deb/.udeb) registered by the build in
|
||||||
/// `debian/files`, returning their paths inside the build context
|
/// `debian/files`, returning their paths inside the build context
|
||||||
/// (`<build_root>/<filename>`). `debian/files` is the canonical record of
|
/// (`<build_root>/<filename>`). `debian/files` is the canonical record of
|
||||||
@@ -485,7 +848,7 @@ fn apply_quilt_patches(
|
|||||||
package_dir: &str,
|
package_dir: &str,
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
sink: &Option<Arc<dyn LineSink>>,
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let series_path = Path::new(package_dir).join("debian/patches/series");
|
let series_path = Path::new(package_dir).join("debian/patches/series");
|
||||||
@@ -545,12 +908,10 @@ fn apply_quilt_patches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply all patches listed in the series
|
// Apply all patches listed in the series
|
||||||
if let Some(u) = ui {
|
view.phase(
|
||||||
u.phase_with(
|
Phase::ApplyingPatches.label(),
|
||||||
Phase::ApplyingPatches,
|
Box::new(QuiltClassifier::new(total_patches)),
|
||||||
Box::new(QuiltClassifier::new(total_patches)),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut patch_env = env.clone();
|
let mut patch_env = env.clone();
|
||||||
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
||||||
let status = cap(
|
let status = cap(
|
||||||
@@ -621,17 +982,15 @@ fn pin_pocket(pocket_suite: &str, ctx: &Arc<Context>) -> Result<(), Box<dyn Erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn install_injected_packages(
|
fn install_injected_packages(
|
||||||
packages: &[&str],
|
packages: &[String],
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
ui: &Option<Arc<DebUi>>,
|
view: &dyn BuildView,
|
||||||
sink: &Option<Arc<dyn LineSink>>,
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
log::info!("Installing injected packages: {:?}", packages);
|
log::info!("Installing injected packages: {:?}", packages);
|
||||||
|
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::InjectingPackages);
|
||||||
u.phase(Phase::InjectingPackages);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate .deb files from package names
|
// Separate .deb files from package names
|
||||||
let mut deb_files: Vec<String> = Vec::new();
|
let mut deb_files: Vec<String> = Vec::new();
|
||||||
@@ -651,7 +1010,7 @@ fn install_injected_packages(
|
|||||||
);
|
);
|
||||||
deb_files.push(chroot_path.to_string_lossy().to_string());
|
deb_files.push(chroot_path.to_string_lossy().to_string());
|
||||||
} else {
|
} else {
|
||||||
package_names.push(pkg);
|
package_names.push(pkg.as_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +1100,158 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::context::ContextConfig;
|
use crate::context::ContextConfig;
|
||||||
|
|
||||||
|
fn cand(arch: &str, ma: &str) -> Candidate {
|
||||||
|
Candidate {
|
||||||
|
arch: arch.to_string(),
|
||||||
|
multiarch: ma.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rel(s: &str) -> PkgRelation {
|
||||||
|
crate::debian::deps::parse_simple(s, true).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unqualified dependencies install the dpkg-satisfying variant,
|
||||||
|
/// preferring the runnable build-architecture one: Multi-Arch:
|
||||||
|
/// foreign candidates from the build architecture, then the
|
||||||
|
/// host-architecture candidate dpkg checks them against, then
|
||||||
|
/// `Architecture: all`. A foreign-arch candidate of a non-foreign
|
||||||
|
/// package satisfies nothing in cross mode.
|
||||||
|
#[test]
|
||||||
|
fn install_spec_unqualified_prefers_runnable_then_host() {
|
||||||
|
let host = "arm64";
|
||||||
|
let build = "amd64";
|
||||||
|
|
||||||
|
let spec = install_spec_for(
|
||||||
|
&rel("t"),
|
||||||
|
&[cand("amd64", "foreign"), cand("arm64", "same")],
|
||||||
|
build,
|
||||||
|
host,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spec, "t:amd64");
|
||||||
|
|
||||||
|
let spec = install_spec_for(
|
||||||
|
&rel("t"),
|
||||||
|
&[cand("arm64", "same"), cand("amd64", "no")],
|
||||||
|
build,
|
||||||
|
host,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spec, "t:arm64");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t"), &[cand("amd64", "no")], build, host),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
let spec = install_spec_for(&rel("t"), &[cand("all", "foreign")], build, host).unwrap();
|
||||||
|
assert_eq!(spec, "t");
|
||||||
|
|
||||||
|
// Native builds (host == build): a plain Multi-Arch: no candidate
|
||||||
|
// is the host candidate.
|
||||||
|
let spec = install_spec_for(&rel("t"), &[cand("amd64", "no")], "amd64", "amd64").unwrap();
|
||||||
|
assert_eq!(spec, "t:amd64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualified dependencies install exactly the variant their qualifier
|
||||||
|
/// selects per dpkg semantics; virtual names (no candidates) pass
|
||||||
|
/// through unqualified for apt to resolve a provider.
|
||||||
|
#[test]
|
||||||
|
fn install_spec_qualifiers() {
|
||||||
|
let host = "arm64";
|
||||||
|
let build = "amd64";
|
||||||
|
|
||||||
|
// :native: the build-architecture candidate; an Architecture: all
|
||||||
|
// candidate passes under the plain name; a Multi-Arch: foreign
|
||||||
|
// candidate aborts the lookup, like dpkg's checker.
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:native"), &[cand("amd64", "no")], build, host).unwrap(),
|
||||||
|
"t:amd64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:native"), &[cand("all", "no")], build, host).unwrap(),
|
||||||
|
"t"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:native"), &[cand("amd64", "foreign")], build, host),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// :any: a Multi-Arch: allowed candidate, host first.
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(
|
||||||
|
&rel("t:any"),
|
||||||
|
&[cand("amd64", "allowed"), cand("arm64", "allowed")],
|
||||||
|
build,
|
||||||
|
host
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"t:arm64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:any"), &[cand("riscv64", "allowed")], build, host).unwrap(),
|
||||||
|
"t:riscv64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:any"), &[cand("amd64", "same")], build, host),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit qualifier: exactly that candidate.
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:arm64"), &[cand("arm64", "same")], build, host).unwrap(),
|
||||||
|
"t:arm64"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t:arm64"), &[cand("amd64", "same")], build, host),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// Virtual package: debhelper-compat and friends.
|
||||||
|
assert_eq!(
|
||||||
|
install_spec_for(&rel("t (= 13)"), &[], build, host).unwrap(),
|
||||||
|
"t"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolver loop tries the alternatives of a clause in order,
|
||||||
|
/// falling past the ones no candidate can satisfy, and stops
|
||||||
|
/// contributing a clause once its alternatives are exhausted.
|
||||||
|
#[test]
|
||||||
|
fn next_install_pass_falls_through_alternatives() {
|
||||||
|
// `a:arm64` has only an amd64 candidate: no candidate satisfies
|
||||||
|
// the explicit qualifier, so the clause falls through to `t`.
|
||||||
|
let clauses = vec![
|
||||||
|
vec![rel("a:arm64"), rel("t")],
|
||||||
|
vec![rel("u")],
|
||||||
|
vec![rel("v")],
|
||||||
|
];
|
||||||
|
let mut candidates = BTreeMap::new();
|
||||||
|
candidates.insert("a".to_string(), vec![cand("amd64", "same")]);
|
||||||
|
candidates.insert("t".to_string(), vec![cand("arm64", "same")]);
|
||||||
|
candidates.insert("v".to_string(), vec![cand("amd64", "foreign")]);
|
||||||
|
let mut cursors = vec![0; clauses.len()];
|
||||||
|
|
||||||
|
let host = "arm64";
|
||||||
|
let build = "amd64";
|
||||||
|
let specs = next_install_pass(&clauses, &[0, 1, 2], &mut cursors, &candidates, build, host);
|
||||||
|
assert_eq!(
|
||||||
|
specs,
|
||||||
|
vec![
|
||||||
|
"t:arm64".to_string(),
|
||||||
|
"u".to_string(),
|
||||||
|
"v:amd64".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(cursors, vec![2, 1, 1]);
|
||||||
|
|
||||||
|
// An exhausted clause contributes nothing; the loop aborts with
|
||||||
|
// the remaining clauses when nothing installs any more.
|
||||||
|
let specs = next_install_pass(&clauses, &[1], &mut cursors, &candidates, build, host);
|
||||||
|
assert!(specs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detector_matches_local_options_options_and_dashed_spellings() {
|
fn detector_matches_local_options_options_and_dashed_spellings() {
|
||||||
// The pkh scaffold spelling: bare token in local-options.
|
// The pkh scaffold spelling: bare token in local-options.
|
||||||
@@ -845,6 +1356,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
apply_quilt_patches(tree.to_str().unwrap(), &HashMap::new(), ctx, &None, &None).unwrap();
|
apply_quilt_patches(
|
||||||
|
tree.to_str().unwrap(),
|
||||||
|
&HashMap::new(),
|
||||||
|
ctx,
|
||||||
|
&crate::report::Quiet,
|
||||||
|
&None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+545
-117
@@ -5,7 +5,11 @@ pub(crate) mod ephemeral;
|
|||||||
mod local;
|
mod local;
|
||||||
|
|
||||||
use crate::context::{self, Context};
|
use crate::context::{self, Context};
|
||||||
use crate::ui::deb::{DebUi, Phase};
|
use crate::logfmt::{
|
||||||
|
AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier,
|
||||||
|
MmdebstrapClassifier, QuiltClassifier,
|
||||||
|
};
|
||||||
|
use crate::report::{BuildTarget, BuildView};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -17,67 +21,163 @@ pub enum BuildMode {
|
|||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phases of a binary build, announced to the [`BuildView`]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Downloading the chroot tarball (mmdebstrap)
|
||||||
|
PreparingChroot,
|
||||||
|
/// Extracting the chroot tarball
|
||||||
|
ExtractingChroot,
|
||||||
|
/// Device nodes, /proc bind mount, etc.
|
||||||
|
FinalizingChroot,
|
||||||
|
/// apt-get update
|
||||||
|
UpdatingPackageLists,
|
||||||
|
/// Installing build-essential & co
|
||||||
|
InstallingEssentials,
|
||||||
|
/// quilt push -a
|
||||||
|
ApplyingPatches,
|
||||||
|
/// --inject packages
|
||||||
|
InjectingPackages,
|
||||||
|
/// apt-get build-dep
|
||||||
|
InstallingBuildDeps,
|
||||||
|
/// debian/rules build
|
||||||
|
Building,
|
||||||
|
/// fakeroot debian/rules binary
|
||||||
|
ProducingBinaries,
|
||||||
|
/// Retrieving produced .deb files
|
||||||
|
RetrievingArtifacts,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
/// Human-readable label displayed in the status bar
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Phase::PreparingChroot => "Preparing chroot",
|
||||||
|
Phase::ExtractingChroot => "Extracting chroot",
|
||||||
|
Phase::FinalizingChroot => "Finalizing chroot",
|
||||||
|
Phase::UpdatingPackageLists => "Updating package lists",
|
||||||
|
Phase::InstallingEssentials => "Installing essential packages",
|
||||||
|
Phase::ApplyingPatches => "Applying patches",
|
||||||
|
Phase::InjectingPackages => "Injecting packages",
|
||||||
|
Phase::InstallingBuildDeps => "Installing build dependencies",
|
||||||
|
Phase::Building => "Building package",
|
||||||
|
Phase::ProducingBinaries => "Producing binary packages",
|
||||||
|
Phase::RetrievingArtifacts => "Retrieving artifacts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default line classifier rewriting a phase's subprocess output
|
||||||
|
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
|
||||||
|
match phase {
|
||||||
|
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
|
||||||
|
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
|
||||||
|
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
|
||||||
|
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
|
||||||
|
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
|
||||||
|
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
|
||||||
|
Phase::InstallingBuildDeps => {
|
||||||
|
Box::new(AptInstallClassifier::new("Installing build dependencies"))
|
||||||
|
}
|
||||||
|
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
|
||||||
|
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter `phase` on the view with its default line classifier
|
||||||
|
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) {
|
||||||
|
view.phase(phase.label(), default_classifier(phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters of one [`build_binary_package`] call.
|
||||||
|
pub struct DebBuildOptions<'a> {
|
||||||
|
/// Target architecture; defaults to the host architecture.
|
||||||
|
pub arch: Option<String>,
|
||||||
|
/// Target distribution series; defaults to the changelog series
|
||||||
|
/// (UNRELEASED resolves to the vendor's development series).
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// Distribution pocket to resolve build-dependencies from.
|
||||||
|
pub pocket: Option<String>,
|
||||||
|
/// Source tree to build; defaults to the process working directory.
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
/// Cross-compile for the target architecture instead of using
|
||||||
|
/// qemu-binfmt.
|
||||||
|
pub cross: bool,
|
||||||
|
/// Build mode; defaults to [`BuildMode::Local`].
|
||||||
|
pub mode: Option<BuildMode>,
|
||||||
|
/// PPAs to add for build-dependencies (`user/ppa_name`).
|
||||||
|
pub ppa: Vec<String>,
|
||||||
|
/// Packages to inject into the build environment before build-dep
|
||||||
|
/// (.deb paths, archive names or PPA packages).
|
||||||
|
pub inject: Vec<String>,
|
||||||
|
/// Parallel build jobs; defaults to the core count available in the
|
||||||
|
/// build context.
|
||||||
|
pub jobs: Option<usize>,
|
||||||
|
/// Explicit build context; defaults to the current context.
|
||||||
|
pub ctx: Option<Arc<Context>>,
|
||||||
|
/// Where build events (phases, progress, outcome) are reported.
|
||||||
|
pub view: &'a dyn BuildView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DebBuildOptions<'_> {
|
||||||
|
fn default() -> Self {
|
||||||
|
static QUIET: crate::report::Quiet = crate::report::Quiet;
|
||||||
|
DebBuildOptions {
|
||||||
|
arch: None,
|
||||||
|
series: None,
|
||||||
|
pocket: None,
|
||||||
|
cwd: None,
|
||||||
|
cross: false,
|
||||||
|
mode: None,
|
||||||
|
ppa: Vec::new(),
|
||||||
|
inject: Vec::new(),
|
||||||
|
jobs: None,
|
||||||
|
ctx: None,
|
||||||
|
view: &QUIET,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build package in 'cwd' to a .deb
|
/// Build package in 'cwd' to a .deb
|
||||||
///
|
///
|
||||||
/// Returns the list of produced artifacts (.deb files plus the upload
|
/// Returns the list of produced artifacts (.deb files plus the upload
|
||||||
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
|
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
|
||||||
/// `debian/files` and the native metadata generation rather than by
|
/// `debian/files` and the native metadata generation rather than by
|
||||||
/// globbing the build root (which would surface stale files). When `ui` is
|
/// globbing the build root (which would surface stale files). Subprocess
|
||||||
/// set, a live view (status bar + rolling log pane) is displayed and all
|
/// output is captured through the view's sink (live view + tee log for the
|
||||||
/// subprocess output is captured through it; on failure the widget is
|
/// terminal adapter); on failure the view is cleared and prints a summary
|
||||||
/// cleared and a summary of captured errors is printed.
|
/// of captured errors.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn build_binary_package(
|
pub async fn build_binary_package(
|
||||||
arch: Option<&str>,
|
opts: DebBuildOptions<'_>,
|
||||||
series: Option<&str>,
|
|
||||||
pocket: Option<&str>,
|
|
||||||
cwd: Option<&Path>,
|
|
||||||
cross: bool,
|
|
||||||
mode: Option<BuildMode>,
|
|
||||||
ppa: Option<&[&str]>,
|
|
||||||
inject_packages: Option<&[&str]>,
|
|
||||||
ctx: Option<Arc<Context>>,
|
|
||||||
ui: Option<Arc<DebUi>>,
|
|
||||||
jobs: Option<usize>,
|
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let result = build_binary_package_impl(
|
let view = opts.view;
|
||||||
arch,
|
let result = build_binary_package_impl(opts).await;
|
||||||
series,
|
|
||||||
pocket,
|
|
||||||
cwd,
|
|
||||||
cross,
|
|
||||||
mode,
|
|
||||||
ppa,
|
|
||||||
inject_packages,
|
|
||||||
ctx,
|
|
||||||
&ui,
|
|
||||||
jobs,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let (Some(u), Err(_)) = (&ui, &result) {
|
if result.is_err() {
|
||||||
u.finish_failure();
|
view.finish_failure();
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of [`build_binary_package`], without failure handling
|
/// Implementation of [`build_binary_package`], without failure handling
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn build_binary_package_impl(
|
async fn build_binary_package_impl(
|
||||||
arch: Option<&str>,
|
opts: DebBuildOptions<'_>,
|
||||||
series: Option<&str>,
|
|
||||||
pocket: Option<&str>,
|
|
||||||
cwd: Option<&Path>,
|
|
||||||
cross: bool,
|
|
||||||
mode: Option<BuildMode>,
|
|
||||||
ppa: Option<&[&str]>,
|
|
||||||
inject_packages: Option<&[&str]>,
|
|
||||||
ctx: Option<Arc<Context>>,
|
|
||||||
ui: &Option<Arc<DebUi>>,
|
|
||||||
jobs: Option<usize>,
|
|
||||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||||
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
let DebBuildOptions {
|
||||||
|
ref arch,
|
||||||
|
ref series,
|
||||||
|
ref pocket,
|
||||||
|
ref cwd,
|
||||||
|
cross,
|
||||||
|
ref mode,
|
||||||
|
ref ppa,
|
||||||
|
ref inject,
|
||||||
|
ref jobs,
|
||||||
|
ref ctx,
|
||||||
|
view,
|
||||||
|
} = opts;
|
||||||
|
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
|
||||||
|
|
||||||
// Parse changelog to get package name, version and series
|
// Parse changelog to get package name, version and series
|
||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
@@ -101,44 +201,45 @@ async fn build_binary_package_impl(
|
|||||||
&package_series
|
&package_series
|
||||||
};
|
};
|
||||||
let current_arch = crate::get_current_arch();
|
let current_arch = crate::get_current_arch();
|
||||||
let arch = arch.unwrap_or(¤t_arch);
|
let arch = arch.as_deref().unwrap_or(¤t_arch);
|
||||||
|
|
||||||
// Make sure we select a specific mode, either using user-requested
|
// Make sure we select a specific mode, either using user-requested
|
||||||
// or by using default for user-supplied parameters
|
// or by using default for user-supplied parameters
|
||||||
let mode = if let Some(m) = mode {
|
let default_mode = BuildMode::Local;
|
||||||
m
|
let mode = mode.as_ref().unwrap_or(&default_mode);
|
||||||
} else {
|
|
||||||
// By default, we use local build
|
|
||||||
BuildMode::Local
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an ephemeral unshare context for all Local builds
|
// Create an ephemeral unshare context for all Local builds
|
||||||
// Use qemu_binfmt when target architecture differs from host and cross is not requested
|
// Use qemu_binfmt when target architecture differs from host and cross is not requested
|
||||||
let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross {
|
let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross {
|
||||||
Some(arch)
|
Some(arch)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use provided context or get current
|
// Use provided context or get current
|
||||||
let base_ctx = ctx.unwrap_or_else(context::current);
|
let base_ctx = ctx.clone().unwrap_or_else(context::current);
|
||||||
|
|
||||||
// Identify the target in the live UI once the changelog is parsed, so
|
// Identify the target in the live view once the changelog is parsed, so
|
||||||
// even the chroot download output is attributed and tee'd
|
// even the chroot download output is attributed and tee'd
|
||||||
if let Some(u) = ui {
|
view.target(BuildTarget {
|
||||||
u.set_target(&package, &version, series, arch);
|
package: &package,
|
||||||
}
|
version: &version,
|
||||||
|
target: &format!("{series}/{arch}"),
|
||||||
|
display: format!("Building {package} ({version}) for {series}/{arch}"),
|
||||||
|
source_only: false,
|
||||||
|
tee_log: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Create an ephemeral unshare context for all Local builds. It is kept in
|
// Create an ephemeral unshare context for all Local builds. It is kept in
|
||||||
// this scope so it outlives the guarded section below and is only dropped
|
// this scope so it outlives the guarded section below and is only dropped
|
||||||
// once the live view has been cleared.
|
// once the live view has been cleared.
|
||||||
let mut guard = if mode == BuildMode::Local {
|
let mut guard = if *mode == BuildMode::Local {
|
||||||
Some(
|
Some(
|
||||||
ephemeral::EphemeralContextGuard::new_with_context(
|
ephemeral::EphemeralContextGuard::new_with_context(
|
||||||
series,
|
series,
|
||||||
chroot_arch,
|
chroot_arch,
|
||||||
base_ctx.clone(),
|
base_ctx.clone(),
|
||||||
ui.clone(),
|
view,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
@@ -168,6 +269,19 @@ async fn build_binary_package_impl(
|
|||||||
.ok_or("Cannot find parent directory name")?;
|
.ok_or("Cannot find parent directory name")?;
|
||||||
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
|
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
|
||||||
|
|
||||||
|
// Resolve the package directory inside the staging area. The tree
|
||||||
|
// the caller pointed at is authoritative (its changelog defined the
|
||||||
|
// package/version/series above), so its staged copy wins; the
|
||||||
|
// name-pattern search only runs as a fallback.
|
||||||
|
let package_dir = resolve_package_directory(
|
||||||
|
Path::new(&build_root),
|
||||||
|
cwd,
|
||||||
|
&package,
|
||||||
|
&version,
|
||||||
|
series,
|
||||||
|
&build_ctx,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Run the build using target build mode. It returns the exact set of
|
// Run the build using target build mode. It returns the exact set of
|
||||||
// artifacts produced by this build (binary packages registered in
|
// artifacts produced by this build (binary packages registered in
|
||||||
// debian/files plus the generated .buildinfo/.changes), as paths
|
// debian/files plus the generated .buildinfo/.changes), as paths
|
||||||
@@ -179,14 +293,15 @@ async fn build_binary_package_impl(
|
|||||||
&version,
|
&version,
|
||||||
arch,
|
arch,
|
||||||
series,
|
series,
|
||||||
pocket,
|
pocket.as_deref(),
|
||||||
&build_root,
|
&build_root,
|
||||||
|
&package_dir,
|
||||||
cross,
|
cross,
|
||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject,
|
||||||
build_ctx.clone(),
|
build_ctx.clone(),
|
||||||
ui.clone(),
|
view,
|
||||||
jobs,
|
*jobs,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
@@ -194,9 +309,7 @@ async fn build_binary_package_impl(
|
|||||||
|
|
||||||
// Retrieve the produced artifacts (binary packages plus the upload
|
// Retrieve the produced artifacts (binary packages plus the upload
|
||||||
// metadata) to the parent directory.
|
// metadata) to the parent directory.
|
||||||
if let Some(u) = ui {
|
enter_phase(view, Phase::RetrievingArtifacts);
|
||||||
u.phase(Phase::RetrievingArtifacts);
|
|
||||||
}
|
|
||||||
let total_debs = remote_files.len();
|
let total_debs = remote_files.len();
|
||||||
|
|
||||||
let mut artifacts = Vec::with_capacity(total_debs);
|
let mut artifacts = Vec::with_capacity(total_debs);
|
||||||
@@ -206,14 +319,10 @@ async fn build_binary_package_impl(
|
|||||||
build_ctx.retrieve_path(remote_file, &local_dest)?;
|
build_ctx.retrieve_path(remote_file, &local_dest)?;
|
||||||
artifacts.push(local_dest);
|
artifacts.push(local_dest);
|
||||||
|
|
||||||
if let Some(u) = ui {
|
view.progress("Retrieving artifacts", idx + 1, total_debs);
|
||||||
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(u) = ui {
|
view.finish_success(&artifacts);
|
||||||
u.finish_success(&artifacts, u.elapsed());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(artifacts)
|
Ok(artifacts)
|
||||||
}
|
}
|
||||||
@@ -222,9 +331,7 @@ async fn build_binary_package_impl(
|
|||||||
// Clear the live view before returning: the ephemeral guard is dropped at
|
// Clear the live view before returning: the ephemeral guard is dropped at
|
||||||
// the end of this function and its cleanup commands (umount, rm -rf of
|
// the end of this function and its cleanup commands (umount, rm -rf of
|
||||||
// the chroot) inherit the terminal, so they must not fight the widget.
|
// the chroot) inherit the terminal, so they must not fight the widget.
|
||||||
if let Some(u) = ui {
|
view.suspend();
|
||||||
u.suspend();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark build as successful to trigger chroot cleanup
|
// Mark build as successful to trigger chroot cleanup
|
||||||
if result.is_ok()
|
if result.is_ok()
|
||||||
@@ -236,6 +343,38 @@ async fn build_binary_package_impl(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the package directory for a build inside the staged build root.
|
||||||
|
///
|
||||||
|
/// The tree the caller pointed at is authoritative: `cwd`'s changelog
|
||||||
|
/// already defined the package, version and series for this build, so its
|
||||||
|
/// staged copy is used outright when it carries a `debian/` tree. The
|
||||||
|
/// name-pattern search ([`find_package_directory`], including the quirks
|
||||||
|
/// overrides) only runs when that copy cannot be resolved — a default `.`
|
||||||
|
/// cwd has no basename, and the pointed-at tree may live outside the staged
|
||||||
|
/// parent. Embedded callers are the motivation: their working directory
|
||||||
|
/// names (`tree`, `checkout`, ...) match none of the search patterns.
|
||||||
|
pub(crate) fn resolve_package_directory(
|
||||||
|
build_root: &Path,
|
||||||
|
cwd: &Path,
|
||||||
|
package: &str,
|
||||||
|
version: &str,
|
||||||
|
series: &str,
|
||||||
|
ctx: &context::Context,
|
||||||
|
) -> Result<PathBuf, Box<dyn Error>> {
|
||||||
|
if let Some(tree_name) = cwd.file_name() {
|
||||||
|
let staged_tree = build_root.join(tree_name);
|
||||||
|
if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? {
|
||||||
|
log::debug!(
|
||||||
|
"Using the staged copy of {} at {}",
|
||||||
|
cwd.display(),
|
||||||
|
staged_tree.display()
|
||||||
|
);
|
||||||
|
return Ok(staged_tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
find_package_directory(build_root, package, version, series, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the current package directory by trying both patterns:
|
/// Find the current package directory by trying both patterns:
|
||||||
/// - package/package
|
/// - package/package
|
||||||
/// - package/package-origversion
|
/// - package/package-origversion
|
||||||
@@ -244,10 +383,11 @@ pub(crate) fn find_package_directory(
|
|||||||
parent_dir: &Path,
|
parent_dir: &Path,
|
||||||
package: &str,
|
package: &str,
|
||||||
version: &str,
|
version: &str,
|
||||||
|
series: &str,
|
||||||
ctx: &context::Context,
|
ctx: &context::Context,
|
||||||
) -> Result<PathBuf, Box<dyn Error>> {
|
) -> Result<PathBuf, Box<dyn Error>> {
|
||||||
// Check quirks first for custom package directories
|
// Check quirks first for custom package directories
|
||||||
let custom_dirs = crate::quirks::get_package_directories(package);
|
let custom_dirs = crate::quirks::get_package_directories(package, series);
|
||||||
for custom_dir in custom_dirs {
|
for custom_dir in custom_dirs {
|
||||||
let package_dir = parent_dir.join(&custom_dir);
|
let package_dir = parent_dir.join(&custom_dir);
|
||||||
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
|
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
|
||||||
@@ -319,11 +459,13 @@ pub(crate) fn find_package_directory(
|
|||||||
let entries = ctx.list_files(package_parent)?;
|
let entries = ctx.list_files(package_parent)?;
|
||||||
let mut found_dirs = Vec::new();
|
let mut found_dirs = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
if entry.is_dir() {
|
// list_files yields context-relative paths (e.g. rooted inside
|
||||||
if let Some(file_name) = entry.file_name() {
|
// the chroot for an unshare context): classify through the
|
||||||
found_dirs.push(file_name.to_string_lossy().into_owned());
|
// context, a host-side stat would miss every entry.
|
||||||
}
|
let is_dir = ctx.is_dir(&entry)?;
|
||||||
log::debug!(" - {}", entry.display());
|
log::debug!(" - {}", entry.display());
|
||||||
|
if is_dir && let Some(file_name) = entry.file_name() {
|
||||||
|
found_dirs.push(file_name.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,12 +508,15 @@ fn find_dsc_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether an apt source URI points to a distribution archive
|
/// Check whether an apt source URI points to a distribution archive
|
||||||
/// (as opposed to a PPA or another third-party repository)
|
/// (as opposed to a PPA or another third-party repository): a thin,
|
||||||
|
/// dist-agnostic wrapper over [`crate::distro_info::is_official_source`],
|
||||||
|
/// unioned across every known distro. Where the distribution is known,
|
||||||
|
/// the dist-scoped check is preferred (it cannot misfire on another
|
||||||
|
/// distro's mirror); this fallback stays for the sites that cannot know.
|
||||||
pub(crate) fn is_archive_source(uri: &str) -> bool {
|
pub(crate) fn is_archive_source(uri: &str) -> bool {
|
||||||
uri.contains("archive.ubuntu.com")
|
crate::distro_info::supported_dists()
|
||||||
|| uri.contains("security.ubuntu.com")
|
.iter()
|
||||||
|| uri.contains("ports.ubuntu.com")
|
.any(|dist| crate::distro_info::is_official_source(dist, uri))
|
||||||
|| uri.contains("deb.debian.org")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -379,6 +524,126 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// The archive check is dist-agnostic (any distro's official mirror
|
||||||
|
/// counts) and host-based: the country mirrors the old substring
|
||||||
|
/// checks matched (`fr.archive.ubuntu.com`) still count, look-alike
|
||||||
|
/// hosts and PPAs do not.
|
||||||
|
#[test]
|
||||||
|
fn archive_sources_are_official_mirrors_of_any_distro() {
|
||||||
|
for uri in [
|
||||||
|
"https://archive.ubuntu.com/ubuntu",
|
||||||
|
// Country mirrors front the Ubuntu archive.
|
||||||
|
"http://fr.archive.ubuntu.com/ubuntu",
|
||||||
|
"http://security.ubuntu.com/ubuntu",
|
||||||
|
"http://ports.ubuntu.com/ubuntu-ports",
|
||||||
|
"https://deb.debian.org/debian",
|
||||||
|
] {
|
||||||
|
assert!(is_archive_source(uri), "{uri}");
|
||||||
|
}
|
||||||
|
for uri in [
|
||||||
|
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
|
||||||
|
"http://notarchive.ubuntu.com/ubuntu",
|
||||||
|
"https://example.com/debian",
|
||||||
|
] {
|
||||||
|
assert!(!is_archive_source(uri), "{uri}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unshare context mapped over `chroot_root`, parented on a local
|
||||||
|
/// context like the ephemeral build contexts are: exists/list_files/
|
||||||
|
/// is_dir answer through the path mapping, no namespace privileges
|
||||||
|
/// needed.
|
||||||
|
fn unshare_test_context(chroot_root: &Path) -> Context {
|
||||||
|
let base = Context::new(crate::context::ContextConfig::Local).unwrap();
|
||||||
|
Context::with_parent(
|
||||||
|
crate::context::ContextConfig::Unshare {
|
||||||
|
path: chroot_root.to_string_lossy().to_string(),
|
||||||
|
parent: None,
|
||||||
|
},
|
||||||
|
Arc::new(base),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The staging-area listing must classify entries through the context:
|
||||||
|
/// an unshare context returns build-root-relative paths that a host-side
|
||||||
|
/// stat never sees (they live under the chroot root on the host), which
|
||||||
|
/// used to silently empty the 'Found directories' list of the search
|
||||||
|
/// failure message — and with it every hint about the actual layout.
|
||||||
|
#[test]
|
||||||
|
fn find_package_directory_lists_staged_directories_through_the_context() {
|
||||||
|
let chroot = tempfile::tempdir().unwrap();
|
||||||
|
// Staged parent holding a single tree whose name matches none of
|
||||||
|
// the search patterns (the embedded-caller layout: <job>/tree)
|
||||||
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||||
|
std::fs::create_dir_all(staged_parent.join("tree/debian")).unwrap();
|
||||||
|
|
||||||
|
let ctx = unshare_test_context(chroot.path());
|
||||||
|
let err = find_package_directory(
|
||||||
|
Path::new("/tmp/pkh-build-1/j-42"),
|
||||||
|
"bc",
|
||||||
|
"1.07.1-1ubuntu1",
|
||||||
|
"questing",
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect_err("no candidate matches a tree named 'tree'");
|
||||||
|
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("Found directories: tree"),
|
||||||
|
"error should list the staged directories through the context: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit cwd must resolve to its staged copy even when its name
|
||||||
|
/// matches none of the search patterns: the pointed-at tree is what the
|
||||||
|
/// parsed changelog came from.
|
||||||
|
#[test]
|
||||||
|
fn resolve_package_directory_prefers_the_pointed_tree() {
|
||||||
|
let chroot = tempfile::tempdir().unwrap();
|
||||||
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||||
|
std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap();
|
||||||
|
|
||||||
|
let ctx = unshare_test_context(chroot.path());
|
||||||
|
let resolved = resolve_package_directory(
|
||||||
|
Path::new("/tmp/pkh-build-1/j-42"),
|
||||||
|
Path::new("/work/jobs/j-42/tree"),
|
||||||
|
"bc",
|
||||||
|
"1.07.1-1ubuntu1",
|
||||||
|
"questing",
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect("the staged copy of the pointed-at tree must resolve");
|
||||||
|
|
||||||
|
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the pointed-at tree is not in the staging area under its own
|
||||||
|
/// name, resolution falls back to the name-pattern search.
|
||||||
|
#[test]
|
||||||
|
fn resolve_package_directory_falls_back_to_the_name_search() {
|
||||||
|
let chroot = tempfile::tempdir().unwrap();
|
||||||
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
||||||
|
// Staged copy of a pulled tree: <pkg>/<pkg>-<origversion>
|
||||||
|
std::fs::create_dir_all(staged_parent.join("bc/bc-1.07.1/debian")).unwrap();
|
||||||
|
|
||||||
|
let ctx = unshare_test_context(chroot.path());
|
||||||
|
let resolved = resolve_package_directory(
|
||||||
|
Path::new("/tmp/pkh-build-1/j-42"),
|
||||||
|
// A tree never staged under that name
|
||||||
|
Path::new("/work/other/checkout"),
|
||||||
|
"bc",
|
||||||
|
"1.07.1-1ubuntu1",
|
||||||
|
"questing",
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect("the pulled-tree layout must resolve via the name search");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved,
|
||||||
|
PathBuf::from("/tmp/pkh-build-1/j-42/bc/bc-1.07.1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_build_end_to_end(
|
async fn test_build_end_to_end(
|
||||||
package: &str,
|
package: &str,
|
||||||
series: &str,
|
series: &str,
|
||||||
@@ -412,25 +677,25 @@ mod tests {
|
|||||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
|
||||||
// Change directory to the package directory
|
// Change directory to the package directory
|
||||||
let cwd =
|
let cwd = crate::deb::find_package_directory(
|
||||||
crate::deb::find_package_directory(cwd, package, &package_info.stanza.version, &ctx)
|
cwd,
|
||||||
.expect("Cannot find package directory");
|
package,
|
||||||
|
&package_info.stanza.version,
|
||||||
|
series,
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect("Cannot find package directory");
|
||||||
log::debug!("Package directory: {}", cwd.display());
|
log::debug!("Package directory: {}", cwd.display());
|
||||||
|
|
||||||
log::info!("Starting binary package build...");
|
log::info!("Starting binary package build...");
|
||||||
crate::deb::build_binary_package(
|
crate::deb::build_binary_package(DebBuildOptions {
|
||||||
arch,
|
arch: arch.map(str::to_string),
|
||||||
Some(series),
|
series: Some(series.to_string()),
|
||||||
None,
|
|
||||||
Some(&cwd),
|
|
||||||
cross,
|
cross,
|
||||||
None,
|
cwd: Some(cwd.to_path_buf()),
|
||||||
None,
|
ctx: Some(ctx),
|
||||||
None,
|
..Default::default()
|
||||||
Some(ctx),
|
})
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("Cannot build binary package (deb)");
|
.expect("Cannot build binary package (deb)");
|
||||||
log::info!("Successfully built binary package");
|
log::info!("Successfully built binary package");
|
||||||
@@ -487,12 +752,34 @@ mod tests {
|
|||||||
/// NOTE: Ideally, we want to run this in CI, but it takes more than 1h
|
/// NOTE: Ideally, we want to run this in CI, but it takes more than 1h
|
||||||
/// to fully build the linux-riscv package on an amd64 builder, which is too
|
/// to fully build the linux-riscv package on an amd64 builder, which is too
|
||||||
/// much time
|
/// much time
|
||||||
|
/// The series is the current LTS (26.04) rather than an interim one:
|
||||||
|
/// interim series vanish from the mirrors a few months after their EOL
|
||||||
|
/// (questing is already unreachable), an LTS stays pullable for years.
|
||||||
#[ignore]
|
#[ignore]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[test_log::test]
|
#[test_log::test]
|
||||||
#[cfg(target_arch = "x86_64")]
|
#[cfg(target_arch = "x86_64")]
|
||||||
async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() {
|
async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() {
|
||||||
test_build_end_to_end("linux-riscv", "questing", None, Some("riscv64"), true).await;
|
test_build_end_to_end("linux-riscv", "resolute", None, Some("riscv64"), true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KNOWN-BROKEN cross build of the noble-era kernel, kept as an
|
||||||
|
/// ignored fixture to work from. Noble controls declare their build
|
||||||
|
/// tools unqualified (the `:native` idiom landed later), so exact dpkg
|
||||||
|
/// semantics demand host-architecture instances of them
|
||||||
|
/// (python3:riscv64, gcc-13:riscv64, clang-17:riscv64, ...) and the
|
||||||
|
/// resulting two-architecture install set is unsolvable: t64
|
||||||
|
/// libraries (libclang1-17t64) conflict with their own foreign-arch
|
||||||
|
/// variant, and the riscv64 toolchain instances drag depends chains
|
||||||
|
/// (gcc:riscv64) that do not resolve from the chroot sources. A real
|
||||||
|
/// run fails at the apt transaction with 'Unable to correct problems'.
|
||||||
|
/// Resolute-era controls declare :native properly; see the test above.
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
async fn test_deb_linux_riscv_noble_cross_end_to_end() {
|
||||||
|
test_build_end_to_end("linux-riscv", "noble", None, Some("riscv64"), true).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is a specific test case for the latest gcc package on Debian
|
/// This is a specific test case for the latest gcc package on Debian
|
||||||
@@ -592,19 +879,14 @@ mod tests {
|
|||||||
|
|
||||||
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
|
||||||
crate::deb::build_binary_package(
|
crate::deb::build_binary_package(DebBuildOptions {
|
||||||
Some("arm64"),
|
arch: Some("arm64".to_string()),
|
||||||
Some("noble"),
|
series: Some("noble".to_string()),
|
||||||
None,
|
cwd: Some(pkg_dir),
|
||||||
Some(&pkg_dir),
|
cross: true,
|
||||||
true,
|
ctx: Some(ctx),
|
||||||
None,
|
..Default::default()
|
||||||
None,
|
})
|
||||||
None,
|
|
||||||
Some(ctx),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect("Cannot cross-build package declaring Build-Depends-Indep");
|
.expect("Cannot cross-build package declaring Build-Depends-Indep");
|
||||||
|
|
||||||
@@ -628,4 +910,150 @@ mod tests {
|
|||||||
"arch-independant .deb not produced, got: {deb_files:?}"
|
"arch-independant .deb not produced, got: {deb_files:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cross build whose build-dependencies cannot be satisfied under
|
||||||
|
/// dpkg's cross semantics must abort at the install step, naming the
|
||||||
|
/// unsatisfied dependency: the expected-failure counterpart of the
|
||||||
|
/// cross builds above.
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
#[cfg(target_arch = "x86_64")]
|
||||||
|
async fn test_deb_cross_unsatisfiable_build_dep_fails_end_to_end() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let pkg_dir = temp_dir.path().join("pkh-crosstest");
|
||||||
|
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/changelog"),
|
||||||
|
"pkh-crosstest (1.0) noble; urgency=medium\n\n \
|
||||||
|
* Synthetic package declaring an unsatisfiable build-dependency.\n\n \
|
||||||
|
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/control"),
|
||||||
|
"Source: pkh-crosstest\n\
|
||||||
|
Section: devel\n\
|
||||||
|
Priority: optional\n\
|
||||||
|
Maintainer: pkh tests <pkh@example.com>\n\
|
||||||
|
Standards-Version: 4.7.4\n\
|
||||||
|
Build-Depends: pkh-no-such-package-xyz\n\
|
||||||
|
Architecture: any\n\
|
||||||
|
\n\
|
||||||
|
Package: pkh-crosstest\n\
|
||||||
|
Architecture: any\n\
|
||||||
|
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||||
|
Description: Cross-build negative regression package\n \
|
||||||
|
Declares a build-dependency absent from the archive.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/rules"),
|
||||||
|
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let rules = pkg_dir.join("debian/rules");
|
||||||
|
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&rules, perms).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||||
|
|
||||||
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
|
||||||
|
let err = crate::deb::build_binary_package(DebBuildOptions {
|
||||||
|
arch: Some("arm64".to_string()),
|
||||||
|
series: Some("noble".to_string()),
|
||||||
|
cwd: Some(pkg_dir),
|
||||||
|
cross: true,
|
||||||
|
ctx: Some(ctx),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("an unsatisfiable build-dependency must fail the cross build");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("pkh-no-such-package-xyz"),
|
||||||
|
"error should name the unsatisfied dependency: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An embedded-caller layout — the tree checked out at <job>/tree, a
|
||||||
|
/// name matching none of the search patterns — must build: the staged
|
||||||
|
/// copy of the tree the caller pointed at is resolved directly instead
|
||||||
|
/// of being re-derived from package/version names (which used to fail
|
||||||
|
/// with 'Could not find package directory').
|
||||||
|
#[tokio::test]
|
||||||
|
#[test_log::test]
|
||||||
|
async fn test_deb_builds_a_tree_named_directory_end_to_end() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let pkg_dir = temp_dir.path().join("j-42/tree");
|
||||||
|
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/changelog"),
|
||||||
|
"pkh-treetest (1.0) noble; urgency=medium\n\n \
|
||||||
|
* Synthetic package built from a directory named 'tree'.\n\n \
|
||||||
|
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/control"),
|
||||||
|
"Source: pkh-treetest\n\
|
||||||
|
Section: devel\n\
|
||||||
|
Priority: optional\n\
|
||||||
|
Maintainer: pkh tests <pkh@example.com>\n\
|
||||||
|
Standards-Version: 4.7.4\n\
|
||||||
|
Build-Depends: debhelper-compat (= 13)\n\
|
||||||
|
Architecture: any\n\
|
||||||
|
\n\
|
||||||
|
Package: pkh-treetest\n\
|
||||||
|
Architecture: any\n\
|
||||||
|
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
|
||||||
|
Description: Package-directory resolution regression package\n \
|
||||||
|
Its tree lives in a directory whose name matches none of the\n \
|
||||||
|
package-directory search patterns.\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
pkg_dir.join("debian/rules"),
|
||||||
|
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let rules = pkg_dir.join("debian/rules");
|
||||||
|
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&rules, perms).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
|
||||||
|
|
||||||
|
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
|
||||||
|
|
||||||
|
crate::deb::build_binary_package(DebBuildOptions {
|
||||||
|
series: Some("noble".to_string()),
|
||||||
|
cwd: Some(pkg_dir),
|
||||||
|
ctx: Some(ctx),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("a tree named 'tree' must build");
|
||||||
|
|
||||||
|
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path().join("j-42"))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
deb_files
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")),
|
||||||
|
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-41
@@ -36,8 +36,12 @@ pub struct ChangelogEntry {
|
|||||||
pub closes: Option<String>,
|
pub closes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the most recent entry of a Debian changelog file.
|
/// Parse up to `limit` entries of a Debian changelog file, newest first
|
||||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
/// (`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| {
|
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"failed to read changelog '{}': {}. Make sure you are running \
|
"failed to read changelog '{}': {}. Make sure you are running \
|
||||||
@@ -46,17 +50,60 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
parse_changelog_entry_from_str(&content)
|
parse_changelog_entries_from_str(&content, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the most recent changelog entry from its textual content. `origin`
|
/// Parse the most recent entry of a Debian changelog file.
|
||||||
/// is used in error messages only.
|
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(
|
pub fn parse_changelog_entry_from_str(
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
) -> 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 origin = "changelog";
|
||||||
let mut lines = content.lines().peekable();
|
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]`
|
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||||
let header = loop {
|
let header = loop {
|
||||||
match lines.next() {
|
match lines.next() {
|
||||||
@@ -104,19 +151,21 @@ pub fn parse_changelog_entry_from_str(
|
|||||||
// --- Body until trailer line ` -- Name <email> Date`
|
// --- Body until trailer line ` -- Name <email> Date`
|
||||||
let mut body_lines: Vec<String> = Vec::new();
|
let mut body_lines: Vec<String> = Vec::new();
|
||||||
let mut trailer: Option<String> = None;
|
let mut trailer: Option<String> = None;
|
||||||
for line in lines {
|
while let Some(line) = lines.peek().copied() {
|
||||||
let line = line.trim_end();
|
let line = line.trim_end();
|
||||||
if line.starts_with(" -- ") {
|
if line.starts_with(" -- ") {
|
||||||
trailer = Some(line.to_string());
|
trailer = lines.next().map(|l| l.trim_end().to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Stop at an emacs local-variables block or a new entry header.
|
// Stop at an emacs local-variables block or a new entry header
|
||||||
|
// (both peeked, not consumed).
|
||||||
if line.starts_with("Local variables:") {
|
if line.starts_with("Local variables:") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
lines.next();
|
||||||
// Blank lines become "." like dpkg does for the Changes field.
|
// Blank lines become "." like dpkg does for the Changes field.
|
||||||
if line.trim().is_empty() {
|
if line.trim().is_empty() {
|
||||||
body_lines.push(".".to_string());
|
body_lines.push(".".to_string());
|
||||||
@@ -211,39 +260,6 @@ fn find_closes(body_lines: &[String]) -> Option<String> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the version of the *previous* changelog entry (the second header
|
|
||||||
/// in the file), or `None` when only one entry exists.
|
|
||||||
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
|
||||||
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
|
||||||
parse_previous_version_from_str(&content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the version of the *previous* changelog entry from the textual
|
|
||||||
/// content of a changelog file.
|
|
||||||
pub fn parse_previous_version_from_str(
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
||||||
let mut seen_first = false;
|
|
||||||
for line in content.lines() {
|
|
||||||
let line = line.trim_end();
|
|
||||||
if looks_like_header(line) {
|
|
||||||
if !seen_first {
|
|
||||||
seen_first = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let open = line
|
|
||||||
.find('(')
|
|
||||||
.ok_or_else(|| format!("invalid changelog header: {line}"))?;
|
|
||||||
let close = line[open..]
|
|
||||||
.find(')')
|
|
||||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{line}'"))?;
|
|
||||||
return Ok(Some(line[open + 1..open + close].to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Heuristic check for a changelog entry header line
|
/// Heuristic check for a changelog entry header line
|
||||||
/// (`name (version) dist; urgency=...`).
|
/// (`name (version) dist; urgency=...`).
|
||||||
fn looks_like_header(line: &str) -> bool {
|
fn looks_like_header(line: &str) -> bool {
|
||||||
@@ -309,4 +325,114 @@ pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
|||||||
assert!(entry.binary_only);
|
assert!(entry.binary_only);
|
||||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1349,6 +1349,233 @@ Provides: virt2 (>= 1.0), plain
|
|||||||
assert_eq!(facts.evaluate_relation(&o("plain")), Some(false));
|
assert_eq!(facts.evaluate_relation(&o("plain")), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cross-compilation semantics of the package lookup (the `Facts`
|
||||||
|
/// host/build split), mirroring `Dpkg::Deps::KnownFacts::_find_package`:
|
||||||
|
/// an unqualified dependency is satisfied by the HOST architecture
|
||||||
|
/// instance, by any instance of a `Multi-Arch: foreign` package, or by
|
||||||
|
/// an `Architecture: all` instance — never by a foreign-arch instance
|
||||||
|
/// of a `Multi-Arch: no`/`same` package. The build-architecture
|
||||||
|
/// instances only come into play through `:native`.
|
||||||
|
///
|
||||||
|
/// Verdicts marked «live» were probed against `dpkg-checkbuilddeps -a
|
||||||
|
/// <host>` on a real amd64 system carrying the build-arch instances.
|
||||||
|
#[test]
|
||||||
|
fn cross_lookup_matrix() {
|
||||||
|
const O: fn(&str) -> PkgRelation = |s| parse_simple(s, true).unwrap();
|
||||||
|
// host = arm64 (target), build = amd64 (machine): the instances
|
||||||
|
// below simulate what a cross-building amd64 machine has installed.
|
||||||
|
let facts = |ma_build: &str, ma_host: &str| {
|
||||||
|
let mut f = Facts::new("arm64", "amd64");
|
||||||
|
if !ma_build.is_empty() {
|
||||||
|
f.add_installed("t", "1.0", "amd64", ma_build);
|
||||||
|
}
|
||||||
|
if !ma_host.is_empty() {
|
||||||
|
f.add_installed("t", "1.0", "arm64", ma_host);
|
||||||
|
}
|
||||||
|
f
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unqualified: only the host-arch instance satisfies...
|
||||||
|
assert_eq!(
|
||||||
|
facts("no", "").evaluate_relation(&O("t")),
|
||||||
|
Some(false),
|
||||||
|
"M-A:no build-arch instance must not satisfy an unqualified dep"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
facts("same", "").evaluate_relation(&O("t")),
|
||||||
|
Some(false),
|
||||||
|
"M-A:same build-arch instance must not satisfy an unqualified dep"
|
||||||
|
);
|
||||||
|
assert_eq!(facts("", "no").evaluate_relation(&O("t")), Some(true));
|
||||||
|
assert_eq!(facts("", "same").evaluate_relation(&O("t")), Some(true));
|
||||||
|
// ...unless the package is Multi-Arch: foreign («live»: bison,
|
||||||
|
// flex: the natively-installed variant satisfies the cross check).
|
||||||
|
assert_eq!(facts("foreign", "").evaluate_relation(&O("t")), Some(true));
|
||||||
|
assert_eq!(facts("", "foreign").evaluate_relation(&O("t")), Some(true));
|
||||||
|
|
||||||
|
// `Architecture: all` instances satisfy unqualified dependencies
|
||||||
|
// whatever the Multi-Arch attribute.
|
||||||
|
let mut all = Facts::new("arm64", "amd64");
|
||||||
|
all.add_installed("t", "1.0", "all", "foreign");
|
||||||
|
assert_eq!(all.evaluate_relation(&O("t")), Some(true));
|
||||||
|
let mut all2 = Facts::new("arm64", "amd64");
|
||||||
|
all2.add_installed("t", "1.0", "all", "no");
|
||||||
|
assert_eq!(all2.evaluate_relation(&O("t")), Some(true));
|
||||||
|
|
||||||
|
// Versioned relations check the first matching instance only:
|
||||||
|
// insertion order decides which instance a dependency binds to,
|
||||||
|
// and an unsatisfying version does not fall through to later
|
||||||
|
// instances.
|
||||||
|
let mut mixed = Facts::new("arm64", "amd64");
|
||||||
|
mixed.add_installed("t", "0.5", "arm64", "no");
|
||||||
|
mixed.add_installed("t", "3.0", "amd64", "no");
|
||||||
|
assert_eq!(mixed.evaluate_relation(&O("t (>= 1)")), Some(false));
|
||||||
|
assert_eq!(mixed.evaluate_relation(&O("t (<< 1)")), Some(true));
|
||||||
|
|
||||||
|
// `:native`: the build-architecture instance satisfies («live»:
|
||||||
|
// gcc:native on an amd64 machine, whatever the target); an
|
||||||
|
// Architecture: all instance does too — but a Multi-Arch: foreign
|
||||||
|
// instance aborts the whole lookup, even on the build architecture
|
||||||
|
// («live»: flex:native with natively-installed M-A:foreign flex is
|
||||||
|
// unmet).
|
||||||
|
assert_eq!(
|
||||||
|
facts("no", "").evaluate_relation(&O("t:native")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
facts("same", "").evaluate_relation(&O("t:native")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
facts("foreign", "").evaluate_relation(&O("t:native")),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
// An Architecture: all instance satisfies :native — unless it is
|
||||||
|
// Multi-Arch: foreign, which aborts the lookup like any foreign
|
||||||
|
// instance.
|
||||||
|
assert_eq!(all2.evaluate_relation(&O("t:native")), Some(true));
|
||||||
|
assert_eq!(all.evaluate_relation(&O("t:native")), Some(false));
|
||||||
|
assert_eq!(
|
||||||
|
facts("", "no").evaluate_relation(&O("t:native")),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `:any`: only a Multi-Arch: allowed instance satisfies, on any
|
||||||
|
// architecture («live»: libssl-dev:any with M-A:same libssl-dev is
|
||||||
|
// unmet).
|
||||||
|
assert_eq!(
|
||||||
|
facts("allowed", "").evaluate_relation(&O("t:any")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
facts("", "allowed").evaluate_relation(&O("t:any")),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
facts("same", "").evaluate_relation(&O("t:any")),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit architecture qualifier: only that exact instance.
|
||||||
|
assert_eq!(facts("no", "").evaluate_relation(&O("t:amd64")), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
facts("", "no").evaluate_relation(&O("t:amd64")),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same cross matrix, validated against the real
|
||||||
|
/// `dpkg-checkbuilddeps`: for each fixture the exit status and the
|
||||||
|
/// reported unmet list must match, with host != build (the machine is
|
||||||
|
/// the native architecture; the host architecture is a foreign one).
|
||||||
|
#[test]
|
||||||
|
fn diff_checkbuilddeps_cross_matrix() {
|
||||||
|
let build_arch = arch::native().unwrap_or_else(|_| "amd64".into());
|
||||||
|
// Any foreign arch the dpkg tables know; the instances only exist
|
||||||
|
// in the synthetic status file.
|
||||||
|
let host_arch = if build_arch == "arm64" {
|
||||||
|
"riscv64".to_string()
|
||||||
|
} else {
|
||||||
|
"arm64".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mk_status = |entries: &[(&str, &str)]| {
|
||||||
|
let mut s = String::new();
|
||||||
|
for (pkg_arch, ma) in entries {
|
||||||
|
let ma = if ma.is_empty() { "no" } else { ma };
|
||||||
|
s.push_str(&format!(
|
||||||
|
"Package: t\nStatus: install ok installed\nVersion: 1.0\nArchitecture: {pkg_arch}\nMulti-Arch: {ma}\n\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
|
||||||
|
for (name, entries, dep) in [
|
||||||
|
// Unqualified: build-arch instances never satisfy, host-arch
|
||||||
|
// and Multi-Arch: foreign do.
|
||||||
|
("ma-no-build", &[("amd64", "no")] as &[(&str, &str)], "t"),
|
||||||
|
("ma-same-build", &[("amd64", "same")], "t"),
|
||||||
|
("ma-no-host", &[("arm64", "no")], "t"),
|
||||||
|
("ma-same-host", &[("arm64", "same")], "t"),
|
||||||
|
("ma-foreign-build", &[("amd64", "foreign")], "t"),
|
||||||
|
("all-build", &[("all", "foreign")], "t"),
|
||||||
|
// :native and :any qualifiers.
|
||||||
|
("native-build", &[("amd64", "no")], "t:native"),
|
||||||
|
("native-foreign-build", &[("amd64", "foreign")], "t:native"),
|
||||||
|
("any-allowed-build", &[("amd64", "allowed")], "t:any"),
|
||||||
|
("any-same-build", &[("amd64", "same")], "t:any"),
|
||||||
|
("explicit-build", &[("amd64", "no")], "t:amd64"),
|
||||||
|
("explicit-host", &[("arm64", "no")], "t:amd64"),
|
||||||
|
] {
|
||||||
|
// Substitute the foreign architecture for fixtures that name
|
||||||
|
// the host arch explicitly.
|
||||||
|
let dep = dep.replace("arm64", &host_arch);
|
||||||
|
let entries: Vec<(String, &str)> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(a, m)| (a.replace("amd64", &build_arch), *m))
|
||||||
|
.collect();
|
||||||
|
let entries: Vec<(&str, &str)> =
|
||||||
|
entries.iter().map(|(a, m)| (a.as_str(), *m)).collect();
|
||||||
|
let status = mk_status(&entries);
|
||||||
|
let verdict = |bd: &str, host: &str| {
|
||||||
|
diff_cross_case(bd, &status, &build_arch, host, |ours, real| {
|
||||||
|
assert_eq!(ours, real, "verdict mismatch for {name}")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
verdict(&dep, &host_arch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One differential cross case: run the real `dpkg-checkbuilddeps`
|
||||||
|
/// with `-a <host>` against a synthetic admindir, and the native
|
||||||
|
/// checker with the equivalent options on the same control, then hand
|
||||||
|
/// both verdicts to `compare`.
|
||||||
|
fn diff_cross_case(
|
||||||
|
bd: &str,
|
||||||
|
status: &str,
|
||||||
|
build_arch: &str,
|
||||||
|
host_arch: &str,
|
||||||
|
compare: impl Fn(bool, bool),
|
||||||
|
) {
|
||||||
|
let control_text = format!(
|
||||||
|
"Source: t\nMaintainer: a <a@b.c>\nBuild-Depends: {bd}\n\nPackage: t\nArchitecture: any\nDescription: x\n y\n"
|
||||||
|
);
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("control"), &control_text).unwrap();
|
||||||
|
let admindir = dir.path().join("admin");
|
||||||
|
std::fs::create_dir_all(&admindir).unwrap();
|
||||||
|
std::fs::write(admindir.join("status"), status).unwrap();
|
||||||
|
|
||||||
|
let output = std::process::Command::new("dpkg-checkbuilddeps")
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.arg("--admindir")
|
||||||
|
.arg(&admindir)
|
||||||
|
.arg("-a")
|
||||||
|
.arg(host_arch)
|
||||||
|
.arg("-I")
|
||||||
|
.arg("control")
|
||||||
|
.output()
|
||||||
|
.expect("run dpkg-checkbuilddeps (is dpkg-dev installed?)");
|
||||||
|
let real_ok = output.status.success();
|
||||||
|
|
||||||
|
let opts = CheckOpts {
|
||||||
|
host_arch: host_arch.to_string(),
|
||||||
|
build_arch: build_arch.to_string(),
|
||||||
|
build_profiles: Vec::new(),
|
||||||
|
ignore_arch: false,
|
||||||
|
ignore_indep: false,
|
||||||
|
ignore_builtin: true,
|
||||||
|
admindir: admindir.to_path_buf(),
|
||||||
|
};
|
||||||
|
let control = ControlInfo::parse_content(&control_text).unwrap();
|
||||||
|
let ours_ok = check_build_depends(&control, &opts)
|
||||||
|
.expect("native check failure")
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
compare(ours_ok, real_ok);
|
||||||
|
}
|
||||||
|
|
||||||
/// The same undecidable verdicts through the direct facts API: an
|
/// The same undecidable verdicts through the direct facts API: an
|
||||||
/// unreadable provided version and an invalid (non-`=`) provide each
|
/// unreadable provided version and an invalid (non-`=`) provide each
|
||||||
/// leave a versioned relation undecided, while a readable provider
|
/// leave a versioned relation undecided, while a readable provider
|
||||||
|
|||||||
+2
-2
@@ -20,8 +20,8 @@ pub mod files;
|
|||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use changelog::{
|
pub use changelog::{
|
||||||
ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str,
|
ChangelogEntry, parse_changelog_entries, parse_changelog_entries_from_str,
|
||||||
parse_previous_version_from_str,
|
parse_changelog_entry, parse_changelog_entry_from_str,
|
||||||
};
|
};
|
||||||
pub use checksums::{ChecksumKind, Entry as ChecksumEntry, FileChecksums};
|
pub use checksums::{ChecksumKind, Entry as ChecksumEntry, FileChecksums};
|
||||||
pub use control::{
|
pub use control::{
|
||||||
|
|||||||
+443
-34
@@ -1,6 +1,8 @@
|
|||||||
|
use crate::data::embed_data;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -28,29 +30,81 @@ struct SeriesInfo {
|
|||||||
network: String,
|
network: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Architectures an archive mirror serves: an explicit list, or the `all`
|
||||||
|
/// sentinel meaning one mirror serves every architecture (Debian's mirror
|
||||||
|
/// setup — an exhaustive list would rot each time an arch is added)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum MirrorArchs {
|
||||||
|
/// The `all` sentinel
|
||||||
|
All(String),
|
||||||
|
/// An explicit list of dpkg architecture names
|
||||||
|
List(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MirrorArchs {
|
||||||
|
/// Whether the mirror serves `arch`. A scalar other than the `all`
|
||||||
|
/// sentinel serves nothing (a test locks that reading of the data).
|
||||||
|
fn serves(&self, arch: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
MirrorArchs::All(sentinel) => sentinel == "all",
|
||||||
|
MirrorArchs::List(archs) => archs.iter().any(|a| a == arch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One archive mirror of a distribution: a URL serving a set of
|
||||||
|
/// architectures, plus the sibling host serving its `-security` pocket
|
||||||
|
/// for the same architectures (ports mirrors serve their own security)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Mirror {
|
||||||
|
/// Base URL of the mirror (the primary mirror's URL doubles as the
|
||||||
|
/// dist's base URL, see [`get_base_url`])
|
||||||
|
pub url: String,
|
||||||
|
/// Sibling host serving the `-security` pocket for these
|
||||||
|
/// architectures; `None` when the mirror serves its own security
|
||||||
|
#[serde(default)]
|
||||||
|
security_url: Option<String>,
|
||||||
|
/// Architectures the mirror serves (see [`MirrorArchs`])
|
||||||
|
archs: MirrorArchs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mirror {
|
||||||
|
/// Whether the mirror serves `arch`
|
||||||
|
pub fn serves(&self, arch: &str) -> bool {
|
||||||
|
self.archs.serves(arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct DistData {
|
struct DistData {
|
||||||
base_url: String,
|
mirrors: HashMap<String, Mirror>,
|
||||||
archive_keyring: String,
|
archive_keyring: String,
|
||||||
pockets: Vec<String>,
|
pockets: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
sections: Vec<String>,
|
sections: Vec<String>,
|
||||||
|
components: Vec<String>,
|
||||||
|
cross_pockets: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
build_profiles: Vec<String>,
|
||||||
|
/// Changelog suite names aliasing a distro-info series codename
|
||||||
|
/// ('unstable' for Debian's 'sid'): the two names identify the same
|
||||||
|
/// series ([`series_suite_alias`], [`resolve_suite_alias`])
|
||||||
|
#[serde(default)]
|
||||||
|
suite_aliases: HashMap<String, String>,
|
||||||
series: SeriesInfo,
|
series: SeriesInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct Data {
|
struct Data {
|
||||||
dist: std::collections::HashMap<String, DistData>,
|
dist: HashMap<String, DistData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_YAML: &str = include_str!("../distro_info.yml");
|
embed_data! {
|
||||||
lazy_static! {
|
static ref DATA: Data = "../data/distro_info.yml"
|
||||||
// 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.
|
|
||||||
static ref DATA: Data = serde_yaml::from_str(DATA_YAML)
|
|
||||||
.expect("built-in distro_info.yml data is statically valid and must parse");
|
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
// Shared HTTP client used for all outgoing plain requests: timeouts keep
|
// Shared HTTP client used for all outgoing plain requests: timeouts keep
|
||||||
// a hanging remote (connect or transfer) from stalling pkh indefinitely.
|
// a hanging remote (connect or transfer) from stalling pkh indefinitely.
|
||||||
// The short pool idle timeout and TCP keepalive avoid reusing keep-alive
|
// The short pool idle timeout and TCP keepalive avoid reusing keep-alive
|
||||||
@@ -214,9 +268,30 @@ fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Err
|
|||||||
Ok(series_info_list)
|
Ok(series_info_list)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the distributions known to pkh (e.g. "debian", "ubuntu")
|
/// List the distributions known to pkh (e.g. "debian", "ubuntu"), sorted so
|
||||||
|
/// that menus and error messages derived from it are deterministic
|
||||||
pub fn supported_dists() -> Vec<String> {
|
pub fn supported_dists() -> Vec<String> {
|
||||||
DATA.dist.keys().cloned().collect()
|
let mut dists: Vec<String> = DATA.dist.keys().cloned().collect();
|
||||||
|
dists.sort();
|
||||||
|
dists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of a dist's primary mirror entry: its URL doubles as the dist's
|
||||||
|
/// base URL ([`get_base_url`]) and is the first candidate of
|
||||||
|
/// [`mirror_for_arch`]
|
||||||
|
const PRIMARY_MIRROR: &str = "primary";
|
||||||
|
|
||||||
|
/// The data of a known distribution: the shared "unknown distribution"
|
||||||
|
/// error of the per-dist accessors
|
||||||
|
fn dist_data(dist: &str) -> Result<&'static DistData, Box<dyn Error>> {
|
||||||
|
DATA.dist.get(dist).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Unknown distribution '{}'. Supported distributions are: {}.",
|
||||||
|
dist,
|
||||||
|
supported_dists().join(", ")
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Special changelog distribution marking an entry that has not been
|
/// Special changelog distribution marking an entry that has not been
|
||||||
@@ -240,7 +315,7 @@ pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Bo
|
|||||||
})?;
|
})?;
|
||||||
let series_info = &dist_data.series;
|
let series_info = &dist_data.series;
|
||||||
let content = if Path::new(series_info.local.as_str()).exists() {
|
let content = if Path::new(series_info.local.as_str()).exists() {
|
||||||
std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv")).map_err(|e| {
|
std::fs::read_to_string(series_info.local.as_str()).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to read distribution series data for '{dist}' \
|
"Failed to read distribution series data for '{dist}' \
|
||||||
from '{}': {}. The 'distro-info' package provides these CSV files.",
|
from '{}': {}. The 'distro-info' package provides these CSV files.",
|
||||||
@@ -343,6 +418,30 @@ pub async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>
|
|||||||
Err(format!("Unknown series: {}", series).into())
|
Err(format!("Unknown series: {}", series).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The changelog suite name that aliases the series codename of `dist`
|
||||||
|
/// (Debian's 'unstable' for 'sid'): the two names identify the same
|
||||||
|
/// series. `None` when the series carries no suite alias.
|
||||||
|
pub fn series_suite_alias(dist: &str, series: &str) -> Option<String> {
|
||||||
|
dist_data(dist)
|
||||||
|
.ok()?
|
||||||
|
.suite_aliases
|
||||||
|
.iter()
|
||||||
|
.find(|(_suite, codename)| codename.as_str() == series)
|
||||||
|
.map(|(suite, _)| suite.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identify a changelog suite name with the distro-info series codename
|
||||||
|
/// it aliases (Debian's 'unstable' is 'sid'), and the dist that codename
|
||||||
|
/// belongs to. `None` when `suite` is not a known alias of any dist.
|
||||||
|
pub fn resolve_suite_alias(suite: &str) -> Option<(String, String)> {
|
||||||
|
for (dist, data) in DATA.dist.iter() {
|
||||||
|
if let Some(codename) = data.suite_aliases.get(suite) {
|
||||||
|
return Some((dist.clone(), codename.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the package pockets available for a given distribution, in search order
|
/// Get the package pockets available for a given distribution, in search order
|
||||||
///
|
///
|
||||||
/// The main archive ('') comes first so that a search without an explicit
|
/// The main archive ('') comes first so that a search without an explicit
|
||||||
@@ -351,14 +450,7 @@ pub async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>
|
|||||||
///
|
///
|
||||||
/// Example: get_dist_pockets(ubuntu) => ["", "updates", "security", "proposed"]
|
/// Example: get_dist_pockets(ubuntu) => ["", "updates", "security", "proposed"]
|
||||||
pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
let mut pockets = dist_data(dist)?.pockets.clone();
|
||||||
format!(
|
|
||||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
|
||||||
dist,
|
|
||||||
supported_dists().join(", ")
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let mut pockets = dist_data.pockets.clone();
|
|
||||||
|
|
||||||
// Explicitely add 'main' pocket, which is just the empty string, first
|
// Explicitely add 'main' pocket, which is just the empty string, first
|
||||||
pockets.insert(0, "".to_string());
|
pockets.insert(0, "".to_string());
|
||||||
@@ -366,18 +458,38 @@ pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
|||||||
Ok(pockets)
|
Ok(pockets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the archive components of a distribution (ubuntu's main,
|
||||||
|
/// restricted, universe, multiverse; Debian's main, contrib, non-free,
|
||||||
|
/// non-free-firmware): the default set a build environment enables on its
|
||||||
|
/// official sources. Live archive operations keep resolving components
|
||||||
|
/// from Release files ([`get_components`]); this is the offline default.
|
||||||
|
pub fn get_dist_components(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
|
Ok(dist_data(dist)?.components.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the pockets a cross-build environment enables for a series (the
|
||||||
|
/// `<series>-<pocket>` suite list is built from these): updates,
|
||||||
|
/// backports and security. Deliberately separate from
|
||||||
|
/// [`get_dist_pockets`], which is the *search order* of pull — folding
|
||||||
|
/// backports into it would change pull behavior.
|
||||||
|
pub fn get_cross_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
|
Ok(dist_data(dist)?.cross_pockets.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default build profiles of a distribution's vendor (Ubuntu
|
||||||
|
/// activates `derivative.ubuntu noudeb`, Debian none), mirroring what
|
||||||
|
/// `Dpkg::BuildProfiles` resolves when `DEB_BUILD_PROFILES` is unset.
|
||||||
|
/// Vendors are matched case-insensitively by the caller (dpkg's `Vendor:`
|
||||||
|
/// field keeps its original casing).
|
||||||
|
pub fn get_build_profiles(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
|
Ok(dist_data(dist)?.build_profiles.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the valid `Section` values of a distribution's packages, as accepted
|
/// Get the valid `Section` values of a distribution's packages, as accepted
|
||||||
/// by its archives (a `section/subsection` in debian/control validates on
|
/// by its archives (a `section/subsection` in debian/control validates on
|
||||||
/// the part before the '/')
|
/// the part before the '/')
|
||||||
pub fn get_sections(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
pub fn get_sections(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
Ok(dist_data(dist)?.sections.clone())
|
||||||
format!(
|
|
||||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
|
||||||
dist,
|
|
||||||
supported_dists().join(", ")
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(dist_data.sections.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the sources URL for a distribution, series, pocket, and component
|
/// Get the sources URL for a distribution, series, pocket, and component
|
||||||
@@ -390,23 +502,105 @@ pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &s
|
|||||||
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the archive base URL for a distribution
|
/// Get the archive base URL for a distribution: the URL of its primary
|
||||||
|
/// mirror (the former `base_url` key folded into `mirrors.primary.url`
|
||||||
|
/// when the mirrors were modeled — the signature is kept so the pull
|
||||||
|
/// paths do not churn)
|
||||||
///
|
///
|
||||||
/// Example: ubuntu => https://archive.ubuntu.com/ubuntu
|
/// Example: ubuntu => https://archive.ubuntu.com/ubuntu
|
||||||
pub fn get_base_url(dist: &str) -> Result<String, Box<dyn Error>> {
|
pub fn get_base_url(dist: &str) -> Result<String, Box<dyn Error>> {
|
||||||
DATA.dist
|
let mirror = dist_data(dist)?
|
||||||
.get(dist)
|
.mirrors
|
||||||
.map(|d| d.base_url.clone())
|
.get(PRIMARY_MIRROR)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
"Distribution '{dist}' has no '{PRIMARY_MIRROR}' mirror in the built-in \
|
||||||
dist,
|
configuration. This is a bug; supported distributions are: {}.",
|
||||||
|
supported_dists().join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(mirror.url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mirror of `dist` serving `arch`: the primary mirror first, then
|
||||||
|
/// the other mirrors by name, so the answer is deterministic. Debian's
|
||||||
|
/// `all` sentinel makes its primary mirror serve every architecture.
|
||||||
|
/// Errors when the dist is unknown or no mirror serves the architecture
|
||||||
|
/// (an architecture the built-in data does not know about).
|
||||||
|
///
|
||||||
|
/// Example: mirror_for_arch(ubuntu, riscv64) => the ports mirror
|
||||||
|
pub fn mirror_for_arch(dist: &str, arch: &str) -> Result<&'static Mirror, Box<dyn Error>> {
|
||||||
|
let data = dist_data(dist)?;
|
||||||
|
if let Some(primary) = data.mirrors.get(PRIMARY_MIRROR)
|
||||||
|
&& primary.serves(arch)
|
||||||
|
{
|
||||||
|
return Ok(primary);
|
||||||
|
}
|
||||||
|
let mut others: Vec<&String> = data
|
||||||
|
.mirrors
|
||||||
|
.keys()
|
||||||
|
.filter(|name| name.as_str() != PRIMARY_MIRROR)
|
||||||
|
.collect();
|
||||||
|
others.sort();
|
||||||
|
others
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|name| data.mirrors.get(name))
|
||||||
|
.find(|mirror| mirror.serves(arch))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"No mirror of '{dist}' serves the '{arch}' architecture. Supported \
|
||||||
|
distributions are: {}.",
|
||||||
supported_dists().join(", ")
|
supported_dists().join(", ")
|
||||||
)
|
)
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host part of an apt-source URL: everything after the `://` scheme up
|
||||||
|
/// to the first `/` (a `:port` suffix stripped). URLs without a scheme
|
||||||
|
/// yield their leading segment.
|
||||||
|
fn url_host(url: &str) -> &str {
|
||||||
|
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||||
|
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||||
|
authority
|
||||||
|
.split_once(':')
|
||||||
|
.map_or(authority, |(host, _)| host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the host of `uri` is the host of `mirror_url` or a subdomain
|
||||||
|
/// of it: the old substring checks (`uri.contains("archive.ubuntu.com")`)
|
||||||
|
/// intentionally matched the country mirrors fronting each archive host
|
||||||
|
/// (`fr.archive.ubuntu.com`), and an exact host comparison would have
|
||||||
|
/// dropped them. The leading dot of the suffix keeps look-alike hosts
|
||||||
|
/// (`notarchive.ubuntu.com`) out.
|
||||||
|
fn uri_matches_url_host(uri: &str, mirror_url: &str) -> bool {
|
||||||
|
let host = url_host(mirror_url);
|
||||||
|
let uri_host = url_host(uri);
|
||||||
|
uri_host == host || uri_host.ends_with(&format!(".{host}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `uri` points at `mirror` or its security sibling: the URI's
|
||||||
|
/// host is the mirror's (or the sibling's) host or a subdomain of it (see
|
||||||
|
/// [`uri_matches_url_host`])
|
||||||
|
pub fn is_mirror_source(mirror: &Mirror, uri: &str) -> bool {
|
||||||
|
uri_matches_url_host(uri, &mirror.url)
|
||||||
|
|| mirror
|
||||||
|
.security_url
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|security| uri_matches_url_host(uri, security))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `uri` points at an official source of `dist` — one of its
|
||||||
|
/// archive mirrors or their security siblings — as opposed to a PPA or
|
||||||
|
/// another third-party repository. Unknown distributions match nothing.
|
||||||
|
pub fn is_official_source(dist: &str, uri: &str) -> bool {
|
||||||
|
DATA.dist.get(dist).is_some_and(|data| {
|
||||||
|
data.mirrors
|
||||||
|
.values()
|
||||||
|
.any(|mirror| is_mirror_source(mirror, uri))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Obtain the URLs for the archive keyrings of a distribution series
|
/// Obtain the URLs for the archive keyrings of a distribution series
|
||||||
///
|
///
|
||||||
/// For 'sid' and 'experimental', returns keyrings from the 3 latest releases
|
/// For 'sid' and 'experimental', returns keyrings from the 3 latest releases
|
||||||
@@ -547,6 +741,30 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The release number of a distribution series, paired with the dist it
|
||||||
|
/// belongs to: the version column of the series data, stripped to its
|
||||||
|
/// leading token ("12" for Debian bookworm, "26.04" out of Ubuntu
|
||||||
|
/// resolute's "26.04 LTS"). `None` when the series carries no version at
|
||||||
|
/// all (Debian's rolling sid/experimental have an empty column;
|
||||||
|
/// pseudo-versions like "unstable" pass through, callers validate per
|
||||||
|
/// vendor). Errors when no known distribution carries the series.
|
||||||
|
pub async fn get_series_release_number(
|
||||||
|
series: &str,
|
||||||
|
) -> Result<Option<(String, String)>, Box<dyn Error>> {
|
||||||
|
let dist = get_dist_from_series(series).await?;
|
||||||
|
for info in get_ordered_series(&dist).await? {
|
||||||
|
if info.series == series {
|
||||||
|
let number = info
|
||||||
|
.version
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|version| version.split_whitespace().next())
|
||||||
|
.map(str::to_string);
|
||||||
|
return Ok(number.map(|number| (dist, number)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -564,6 +782,143 @@ mod tests {
|
|||||||
assert!(get_sections("not-a-distro").is_err());
|
assert!(get_sections("not-a-distro").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The primary mirror's URL is the former `base_url`, byte for byte:
|
||||||
|
/// the pull paths build their archive URLs from it.
|
||||||
|
#[test]
|
||||||
|
fn test_primary_mirror_url_is_the_base_url() {
|
||||||
|
assert_eq!(
|
||||||
|
get_base_url("ubuntu").unwrap(),
|
||||||
|
"https://archive.ubuntu.com/ubuntu"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_base_url("debian").unwrap(),
|
||||||
|
"https://deb.debian.org/debian"
|
||||||
|
);
|
||||||
|
assert!(get_base_url("not-a-distro").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror-per-architecture resolution: the local architectures come
|
||||||
|
/// from Ubuntu's primary mirror, the others from ports; Debian's `all`
|
||||||
|
/// sentinel makes its one mirror serve everything, including
|
||||||
|
/// architectures the data never lists.
|
||||||
|
#[test]
|
||||||
|
fn test_mirror_for_arch() {
|
||||||
|
assert_eq!(
|
||||||
|
mirror_for_arch("ubuntu", "amd64").unwrap().url,
|
||||||
|
"https://archive.ubuntu.com/ubuntu"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mirror_for_arch("ubuntu", "riscv64").unwrap().url,
|
||||||
|
"http://ports.ubuntu.com/ubuntu-ports"
|
||||||
|
);
|
||||||
|
for arch in ["amd64", "riscv64", "brand-new"] {
|
||||||
|
assert_eq!(
|
||||||
|
mirror_for_arch("debian", arch).unwrap().url,
|
||||||
|
"https://deb.debian.org/debian",
|
||||||
|
"the `all` sentinel serves every architecture, including {arch}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// An architecture no Ubuntu mirror serves, and an unknown dist.
|
||||||
|
assert!(mirror_for_arch("ubuntu", "mips64el").is_err());
|
||||||
|
assert!(mirror_for_arch("not-a-distro", "amd64").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `archs` forms and their reading: the `all` sentinel serves
|
||||||
|
/// everything, an explicit list serves exactly its members, and any
|
||||||
|
/// other scalar serves nothing (a data bug a validation would have to
|
||||||
|
/// catch, hence the documented reading).
|
||||||
|
#[test]
|
||||||
|
fn test_mirror_archs_forms() {
|
||||||
|
let all: MirrorArchs = serde_yaml::from_str("all").unwrap();
|
||||||
|
assert!(all.serves("anything"));
|
||||||
|
let list: MirrorArchs = serde_yaml::from_str("[amd64, i386]").unwrap();
|
||||||
|
assert!(list.serves("amd64"));
|
||||||
|
assert!(!list.serves("arm64"));
|
||||||
|
let typo: MirrorArchs = serde_yaml::from_str("every").unwrap();
|
||||||
|
assert!(!typo.serves("amd64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Official-source matching is host-based but keeps matching the
|
||||||
|
/// country mirrors the old substring checks matched (`fr.archive.
|
||||||
|
/// ubuntu.com`): equality or a `.{host}` suffix, never a bare
|
||||||
|
/// substring — `notarchive.ubuntu.com` must not match.
|
||||||
|
#[test]
|
||||||
|
fn test_is_official_source_matches_country_mirrors_only() {
|
||||||
|
for uri in [
|
||||||
|
"https://archive.ubuntu.com/ubuntu",
|
||||||
|
"http://security.ubuntu.com/ubuntu",
|
||||||
|
"http://ports.ubuntu.com/ubuntu-ports",
|
||||||
|
// Country mirrors front the same archives.
|
||||||
|
"http://fr.archive.ubuntu.com/ubuntu",
|
||||||
|
"https://de.security.ubuntu.com/ubuntu",
|
||||||
|
] {
|
||||||
|
assert!(is_official_source("ubuntu", uri), "{uri}");
|
||||||
|
}
|
||||||
|
for uri in [
|
||||||
|
"https://deb.debian.org/debian",
|
||||||
|
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
|
||||||
|
"http://notarchive.ubuntu.com/ubuntu",
|
||||||
|
"http://archive.ubuntu.com.evil.example/ubuntu",
|
||||||
|
] {
|
||||||
|
assert!(!is_official_source("ubuntu", uri), "{uri}");
|
||||||
|
}
|
||||||
|
// Debian: its own mirror matches, Ubuntu's mirrors do not, and an
|
||||||
|
// unknown dist matches nothing.
|
||||||
|
assert!(is_official_source(
|
||||||
|
"debian",
|
||||||
|
"https://deb.debian.org/debian"
|
||||||
|
));
|
||||||
|
assert!(!is_official_source(
|
||||||
|
"debian",
|
||||||
|
"http://security.ubuntu.com/ubuntu"
|
||||||
|
));
|
||||||
|
assert!(!is_official_source(
|
||||||
|
"not-a-distro",
|
||||||
|
"https://deb.debian.org/debian"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dist-level defaults the build paths read: components,
|
||||||
|
/// cross-build pockets (deliberately not the pull search order) and
|
||||||
|
/// vendor build profiles.
|
||||||
|
#[test]
|
||||||
|
fn test_dist_components_cross_pockets_and_build_profiles() {
|
||||||
|
assert_eq!(
|
||||||
|
get_dist_components("ubuntu").unwrap(),
|
||||||
|
vec!["main", "restricted", "universe", "multiverse"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
get_dist_components("debian")
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"non-free-firmware".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
for dist in ["debian", "ubuntu"] {
|
||||||
|
assert_eq!(
|
||||||
|
get_cross_pockets(dist).unwrap(),
|
||||||
|
vec!["updates", "backports", "security"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not the pull search order: no 'proposed', no empty main pocket.
|
||||||
|
let cross = get_cross_pockets("ubuntu").unwrap();
|
||||||
|
assert!(!cross.contains(&"proposed".to_string()));
|
||||||
|
assert!(!cross.contains(&"".to_string()));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_build_profiles("ubuntu").unwrap(),
|
||||||
|
vec!["derivative.ubuntu", "noudeb"]
|
||||||
|
);
|
||||||
|
assert!(get_build_profiles("debian").unwrap().is_empty());
|
||||||
|
|
||||||
|
for getter in [
|
||||||
|
get_dist_components as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||||
|
get_cross_pockets as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||||
|
get_build_profiles as fn(&str) -> Result<Vec<String>, Box<dyn Error>>,
|
||||||
|
] {
|
||||||
|
assert!(getter("not-a-distro").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_series_csv_malformed_rows() {
|
fn test_parse_series_csv_malformed_rows() {
|
||||||
// A short row (missing 'codename') is skipped, a row with an invalid
|
// A short row (missing 'codename') is skipped, a row with an invalid
|
||||||
@@ -709,6 +1064,41 @@ mod tests {
|
|||||||
assert!(series.contains(&"jammy".to_string()));
|
assert!(series.contains(&"jammy".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suite aliases identify a changelog suite name with the series
|
||||||
|
/// codename of the same series: Debian's 'unstable' is 'sid'
|
||||||
|
#[test]
|
||||||
|
fn test_suite_aliases() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_suite_alias("unstable"),
|
||||||
|
Some(("debian".to_string(), "sid".to_string()))
|
||||||
|
);
|
||||||
|
// A series codename or unknown suite is not an alias
|
||||||
|
assert_eq!(resolve_suite_alias("sid"), None);
|
||||||
|
assert_eq!(resolve_suite_alias("noble"), None);
|
||||||
|
assert_eq!(
|
||||||
|
series_suite_alias("debian", "sid"),
|
||||||
|
Some("unstable".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(series_suite_alias("debian", "trixie"), None);
|
||||||
|
assert_eq!(series_suite_alias("ubuntu", "noble"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every suite alias must map to a real series of its dist, or the
|
||||||
|
/// selector would offer a phantom entry
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_suite_aliases_target_real_series() {
|
||||||
|
for (dist, data) in DATA.dist.iter() {
|
||||||
|
for (suite, codename) in &data.suite_aliases {
|
||||||
|
let series = get_ordered_series_name(dist).await.unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
series.contains(codename),
|
||||||
|
"suite alias '{suite}' of {dist} maps to '{codename}', \
|
||||||
|
which is not a known series"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_dist_from_series() {
|
async fn test_get_dist_from_series() {
|
||||||
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
||||||
@@ -731,6 +1121,25 @@ mod tests {
|
|||||||
assert!(unknown_number.is_none());
|
assert!(unknown_number.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_series_release_number() {
|
||||||
|
let (dist, bookworm) = get_series_release_number("bookworm")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dist, "debian");
|
||||||
|
assert_eq!(bookworm, "12");
|
||||||
|
|
||||||
|
// Ubuntu LTS rows carry a " LTS" decoration: only the leading
|
||||||
|
// YY.MM token is the release number
|
||||||
|
let (dist, noble) = get_series_release_number("noble").await.unwrap().unwrap();
|
||||||
|
assert_eq!(dist, "ubuntu");
|
||||||
|
assert_eq!(noble, "24.04");
|
||||||
|
|
||||||
|
// No known dist carries the series
|
||||||
|
assert!(get_series_release_number("not-a-series").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_keyring_urls_sid() {
|
async fn test_get_keyring_urls_sid() {
|
||||||
// Test that 'sid' returns keyrings from the 3 latest released versions
|
// Test that 'sid' returns keyrings from the 3 latest released versions
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! Passive interrupt state shared between the CLI and the library.
|
||||||
|
//!
|
||||||
|
//! Everything active about Ctrl+C lives in the CLI (`main.rs`): it installs
|
||||||
|
//! the SIGINT handler, wakes a watchdog thread, prints the interrupt notice
|
||||||
|
//! and exits with the conventional status 130. This module only holds the
|
||||||
|
//! state the library's own types need:
|
||||||
|
//!
|
||||||
|
//! - the interrupted flag ([`mark_interrupted`] / [`interrupted`]), read by
|
||||||
|
//! flows so they stand down while the watchdog tears everything down;
|
||||||
|
//! - the cleanup hook registry ([`register_cleanup_hook`]) for resources
|
||||||
|
//! that must not outlive the process (e.g. the ephemeral build chroot,
|
||||||
|
//! see [`crate::deb::ephemeral`]), drained and run by the CLI watchdog
|
||||||
|
//! right before exiting ([`run_cleanup_hooks`]);
|
||||||
|
//! - the reporter slot ([`set_reporter`]): the live build view registers
|
||||||
|
//! how to clear the terminal (and where the full log lives); the CLI
|
||||||
|
//! runs it as the first step of the shutdown.
|
||||||
|
//!
|
||||||
|
//! Nothing here installs signal handlers, prints or exits: a library
|
||||||
|
//! consumer embedding these types keeps its own signal disposition.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
|
||||||
|
/// How the live view reports an interrupt: it clears the terminal and
|
||||||
|
/// returns the log-file hint to print below the notice, if any
|
||||||
|
pub type Reporter = Box<dyn FnOnce() -> Option<String> + Send>;
|
||||||
|
|
||||||
|
/// A boxed, send-safe cleanup hook body
|
||||||
|
type CleanupFn = Box<dyn Fn() + Send>;
|
||||||
|
|
||||||
|
/// The reporter run before the cleanup hooks; taken out when it runs
|
||||||
|
static REPORTER: Mutex<Option<Reporter>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Whether a Ctrl+C has been intercepted since the CLI installed the
|
||||||
|
/// handler
|
||||||
|
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Registry of cleanup hooks waiting to run at interrupt time
|
||||||
|
static CLEANUP_HOOKS: Mutex<Vec<CleanupHook>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Source of the registry ids used to deregister a specific hook
|
||||||
|
static NEXT_CLEANUP_HOOK_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// A pending cleanup hook together with its registry id
|
||||||
|
struct CleanupHook {
|
||||||
|
id: u64,
|
||||||
|
f: CleanupFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that a Ctrl+C has been intercepted; called by the CLI signal
|
||||||
|
/// handler
|
||||||
|
pub fn mark_interrupted() {
|
||||||
|
INTERRUPTED.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a Ctrl+C has been intercepted; flows use this to stay quiet and
|
||||||
|
/// to leave the cleanup to the CLI watchdog
|
||||||
|
pub fn interrupted() -> bool {
|
||||||
|
INTERRUPTED.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register how the live view reports an interrupt: the CLI watchdog runs
|
||||||
|
/// it as the first step of the shutdown, before the cleanup hooks. At most
|
||||||
|
/// one reporter runs per process: a later call replaces the one set before.
|
||||||
|
/// Without any reporter the watchdog only prints the plain notice.
|
||||||
|
pub fn set_reporter(report: Reporter) {
|
||||||
|
*REPORTER.lock().unwrap_or_else(PoisonError::into_inner) = Some(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the registered reporter out of the slot; `None` when no live view
|
||||||
|
/// registered one (`--verbose`, piped output)
|
||||||
|
pub fn take_reporter() -> Option<Reporter> {
|
||||||
|
REPORTER
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a hook to be run when the process is interrupted (after the
|
||||||
|
/// reporter), returning a guard whose drop deregisters the hook again.
|
||||||
|
///
|
||||||
|
/// Hooks must be self-contained — stored paths plus direct subprocesses —
|
||||||
|
/// and must never block indefinitely: they run in the watchdog while the
|
||||||
|
/// interrupted flow is still unwinding, and a second Ctrl+C during cleanup
|
||||||
|
/// is a no-op.
|
||||||
|
pub fn register_cleanup_hook(f: CleanupFn) -> CleanupHookGuard {
|
||||||
|
let id = NEXT_CLEANUP_HOOK_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
CLEANUP_HOOKS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.push(CleanupHook { id, f });
|
||||||
|
CleanupHookGuard(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII handle to a registered cleanup hook: dropping it (or an explicit
|
||||||
|
/// [`CleanupHookGuard::deregister`]) removes the hook from the registry so
|
||||||
|
/// the interrupt path can no longer run it
|
||||||
|
pub struct CleanupHookGuard(u64);
|
||||||
|
|
||||||
|
impl CleanupHookGuard {
|
||||||
|
/// Registry id of the hook (used to filter the registry in tests)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn id(&self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the hook from the registry; returns whether it was still
|
||||||
|
/// pending
|
||||||
|
pub fn deregister(&mut self) -> bool {
|
||||||
|
deregister_cleanup_hook(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CleanupHookGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
deregister_cleanup_hook(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a hook from the registry; returns whether it was still pending
|
||||||
|
fn deregister_cleanup_hook(id: u64) -> bool {
|
||||||
|
let mut hooks = CLEANUP_HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let len_before = hooks.len();
|
||||||
|
hooks.retain(|hook| hook.id != id);
|
||||||
|
hooks.len() != len_before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain and run every registered cleanup hook exactly once.
|
||||||
|
///
|
||||||
|
/// Called by the CLI watchdog right before the process exits. Draining uses
|
||||||
|
/// `try_lock` with a bounded retry instead of a blocking lock as a hard
|
||||||
|
/// upper bound on interrupt latency: the sequence must never hang waiting
|
||||||
|
/// for a lock, however unlikely a stalled holder is. Timing out therefore
|
||||||
|
/// skips cleanup (leaking) rather than hanging.
|
||||||
|
pub fn run_cleanup_hooks() {
|
||||||
|
run_drained_hooks(drain_cleanup_hooks());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take every pending hook out of the registry, waiting at most ~1s for the
|
||||||
|
/// registry lock (see [`run_cleanup_hooks`] for why this must not block
|
||||||
|
/// forever)
|
||||||
|
fn drain_cleanup_hooks() -> Vec<CleanupHook> {
|
||||||
|
const RETRIES: usize = 200;
|
||||||
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
|
||||||
|
|
||||||
|
for _ in 0..RETRIES {
|
||||||
|
if let Ok(mut hooks) = CLEANUP_HOOKS.try_lock() {
|
||||||
|
return std::mem::take(&mut *hooks);
|
||||||
|
}
|
||||||
|
std::thread::sleep(RETRY_DELAY);
|
||||||
|
}
|
||||||
|
log::error!("Timed out waiting for the cleanup hook registry; skipping interrupt cleanup");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run drained hooks one by one, isolating panics so that one failing hook
|
||||||
|
/// cannot skip the remaining ones
|
||||||
|
fn run_drained_hooks(hooks: Vec<CleanupHook>) {
|
||||||
|
for CleanupHook { id, f } in hooks {
|
||||||
|
// Hooks are arbitrary user code; assert unwind safety so they can be
|
||||||
|
// run inside a catching context
|
||||||
|
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||||
|
log::error!("Cleanup hook {id} panicked: {}", panic_message(&panic));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort message extraction from a panic payload
|
||||||
|
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
|
||||||
|
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||||
|
(*s).to_string()
|
||||||
|
} else if let Some(s) = panic.downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
"non-string panic payload".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
/// Serializes these tests: they drain the process-global registry, and
|
||||||
|
/// unrelated tests may hold registrations concurrently that must be
|
||||||
|
/// neither run nor lost. Poison-proof: a test failing while holding the
|
||||||
|
/// lock must not cascade into the others.
|
||||||
|
static TEST_LOCK: StdMutex<()> = StdMutex::new(());
|
||||||
|
|
||||||
|
fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
TEST_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the registry and take out only the hooks with the given ids,
|
||||||
|
/// putting everything else back so unrelated registrations stay pending
|
||||||
|
fn take_hooks(ids: &[u64]) -> Vec<CleanupHook> {
|
||||||
|
let drained = drain_cleanup_hooks();
|
||||||
|
let mut mine = Vec::new();
|
||||||
|
let mut others = Vec::new();
|
||||||
|
for hook in drained {
|
||||||
|
if ids.contains(&hook.id) {
|
||||||
|
mine.push(hook);
|
||||||
|
} else {
|
||||||
|
others.push(hook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CLEANUP_HOOKS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.extend(others);
|
||||||
|
mine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a hook that counts its invocations
|
||||||
|
fn counting_hook() -> (CleanupHookGuard, Arc<AtomicUsize>) {
|
||||||
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
|
let seen = counter.clone();
|
||||||
|
let guard = register_cleanup_hook(Box::new(move || {
|
||||||
|
seen.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}));
|
||||||
|
(guard, counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hooks run in registration order, and draining means each hook runs
|
||||||
|
/// exactly once even across repeated cleanup passes.
|
||||||
|
#[test]
|
||||||
|
fn hooks_run_once_in_registration_order() {
|
||||||
|
let _serial = test_lock();
|
||||||
|
|
||||||
|
let log = Arc::new(StdMutex::new(Vec::new()));
|
||||||
|
let mut guards = Vec::new();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for name in ["hook-a", "hook-b", "hook-c"] {
|
||||||
|
let log = log.clone();
|
||||||
|
// The returned guard must stay alive: dropping it deregisters
|
||||||
|
let guard = register_cleanup_hook(Box::new(move || {
|
||||||
|
log.lock().unwrap().push(name);
|
||||||
|
}));
|
||||||
|
ids.push(guard.id());
|
||||||
|
guards.push(guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only our own hooks are extracted; they run in registration order
|
||||||
|
let mine = take_hooks(&ids);
|
||||||
|
assert_eq!(mine.len(), ids.len());
|
||||||
|
run_drained_hooks(mine);
|
||||||
|
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||||
|
|
||||||
|
// Draining removed them: a second pass runs nothing again
|
||||||
|
assert!(take_hooks(&ids).is_empty());
|
||||||
|
assert_eq!(*log.lock().unwrap(), vec!["hook-a", "hook-b", "hook-c"]);
|
||||||
|
|
||||||
|
drop(guards);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A panicking hook is contained by the runner: it neither aborts the
|
||||||
|
/// process nor skips the hooks registered around it.
|
||||||
|
#[test]
|
||||||
|
fn panicking_hook_does_not_skip_the_others() {
|
||||||
|
let _serial = test_lock();
|
||||||
|
// The hook below panics on purpose: do not record it as a test
|
||||||
|
// failure in the end-of-run matrix
|
||||||
|
let _quiet = crate::test_support::suppress_failure_recording();
|
||||||
|
|
||||||
|
let (before, ran_before) = counting_hook();
|
||||||
|
let boom = register_cleanup_hook(Box::new(|| panic!("cleanup exploded")));
|
||||||
|
let (after, ran_after) = counting_hook();
|
||||||
|
|
||||||
|
let ids = [before.id(), boom.id(), after.id()];
|
||||||
|
run_drained_hooks(take_hooks(&ids));
|
||||||
|
|
||||||
|
assert_eq!(ran_before.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(ran_after.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit deregistration removes the hook: it is no longer drained and
|
||||||
|
/// never runs; a second deregistration reports it as already gone.
|
||||||
|
#[test]
|
||||||
|
fn deregistered_hook_never_runs() {
|
||||||
|
let _serial = test_lock();
|
||||||
|
|
||||||
|
let (mut guard, ran) = counting_hook();
|
||||||
|
|
||||||
|
assert!(guard.deregister());
|
||||||
|
assert!(!guard.deregister());
|
||||||
|
|
||||||
|
assert!(take_hooks(&[guard.id()]).is_empty());
|
||||||
|
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dropping the registration guard deregisters the hook implicitly.
|
||||||
|
#[test]
|
||||||
|
fn dropping_the_guard_deregisters_the_hook() {
|
||||||
|
let _serial = test_lock();
|
||||||
|
|
||||||
|
let id;
|
||||||
|
let ran;
|
||||||
|
{
|
||||||
|
let (guard, counter) = counting_hook();
|
||||||
|
id = guard.id();
|
||||||
|
ran = counter;
|
||||||
|
drop(guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(take_hooks(&[id]).is_empty());
|
||||||
|
assert_eq!(ran.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+367
-53
@@ -23,13 +23,95 @@ use std::path::Path;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::embed_data;
|
||||||
use crate::put::target::UploadTarget;
|
use crate::put::target::UploadTarget;
|
||||||
|
|
||||||
/// Git configuration key holding the Launchpad account name
|
/// Git configuration key holding the Launchpad account name
|
||||||
const LP_USER_KEY: &str = "lp.user";
|
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
|
/// Base URL of the Launchpad REST API
|
||||||
const API_BASE: &str = "https://api.launchpad.net/1.0";
|
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
|
/// The Launchpad username configured in git: the repository-local
|
||||||
/// configuration wins over the global one, like git's own precedence.
|
/// configuration wins over the global one, like git's own precedence.
|
||||||
@@ -74,26 +156,30 @@ fn split_ppa(ppa: &str) -> Result<(String, String), String> {
|
|||||||
|
|
||||||
/// URL of the Launchpad API resource of a Launchpad account
|
/// URL of the Launchpad API resource of a Launchpad account
|
||||||
fn person_url(user: &str) -> String {
|
fn person_url(user: &str) -> String {
|
||||||
format!("{API_BASE}/~{user}")
|
format!("{}/~{user}", api_base())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL of the Launchpad API resource of a PPA (`~user/+archive/ubuntu/name`
|
/// URL of the Launchpad API resource of a PPA (`~user/+archive/ubuntu/name`
|
||||||
/// covers the default `ppa` archive and named archives alike)
|
/// covers the default `ppa` archive and named archives alike); shared by the
|
||||||
fn archive_url(user: &str, ppa: &str) -> String {
|
/// put-side pre-flight checks and the apt keyring's fingerprint lookup
|
||||||
format!("{API_BASE}/~{user}/+archive/ubuntu/{ppa}")
|
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
|
/// Resolve a `user/ppa_name` PPA argument into its upload target (the
|
||||||
/// (`ppa.launchpad.net`, incoming `~user/ppa_name`), like dput-ng's
|
/// SFTP host and incoming template of `launchpad.yml`), like dput-ng's
|
||||||
/// `ppa:user/ppa` profile expansion.
|
/// `ppa:user/ppa` profile expansion.
|
||||||
pub fn ppa_target(ppa: &str) -> Result<UploadTarget, String> {
|
pub fn ppa_target(ppa: &str) -> Result<UploadTarget, String> {
|
||||||
let (user, name) = split_ppa(ppa)?;
|
let (user, name) = split_ppa(ppa)?;
|
||||||
|
|
||||||
Ok(UploadTarget {
|
Ok(UploadTarget {
|
||||||
fqdn: "ppa.launchpad.net".to_string(),
|
fqdn: LAUNCHPAD_DATA.ssh_host.clone(),
|
||||||
port: 22,
|
port: LAUNCHPAD_DATA.ssh_port,
|
||||||
login: None,
|
login: None,
|
||||||
incoming: format!("~{user}/{name}"),
|
incoming: LAUNCHPAD_DATA
|
||||||
|
.incoming_template
|
||||||
|
.replace("{owner}", &user)
|
||||||
|
.replace("{ppa}", &name),
|
||||||
label: format!("ppa:{ppa}"),
|
label: format!("ppa:{ppa}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -178,20 +264,21 @@ fn percent_encode(value: &str) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL of the `getPublishedSources` API call listing the currently
|
/// URL of the first page of the `getPublishedSources` API call listing the
|
||||||
/// `Published` source packages named `source_name` in the PPA `user/name`:
|
/// currently `Published` source packages named `source_name` in the PPA
|
||||||
/// `exact_match` avoids Launchpad's default case-insensitive substring
|
/// `user/name`: `exact_match` avoids Launchpad's default case-insensitive
|
||||||
/// matching, which would return unrelated sources (`data` matching
|
/// substring matching, which would return unrelated sources (`data` matching
|
||||||
/// `datatables`).
|
/// `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 {
|
fn published_sources_url(user: &str, ppa: &str, source_name: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published",
|
"{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published&ws.size={WS_PAGE_SIZE}",
|
||||||
archive_url(user, ppa),
|
archive_url(user, ppa),
|
||||||
percent_encode(source_name)
|
percent_encode(source_name)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry of a `getPublishedSources` answer: the subset of the source
|
/// One page of a `getPublishedSources` answer: the subset of the source
|
||||||
/// package publishing history the superseded-upload check needs (the live
|
/// package publishing history the superseded-upload check needs (the live
|
||||||
/// answer carries many more fields, ignored by serde)
|
/// answer carries many more fields, ignored by serde)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -200,42 +287,52 @@ struct PublishedSource {
|
|||||||
source_package_version: String,
|
source_package_version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `getPublishedSources` collection answer
|
/// One page of the `getPublishedSources` collection answer
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PublishedSources {
|
struct PublishedSources {
|
||||||
/// The currently published source packages matching the query
|
/// The currently published source packages matching the query, on this
|
||||||
|
/// page only
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
entries: Vec<PublishedSource>,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every version of `source_name` currently `Published` in the PPA
|
/// Parse one page of a `getPublishedSources` collection into the versions
|
||||||
/// `user/name` (same `user/ppa_name` format as `pkh put --ppa`), in API
|
/// it carries plus the link to the next page (`None` on the last one): the
|
||||||
/// order. Empty when the source was never published there — a 200 answer
|
/// pagination decision, factored out of the HTTP walk so it can be tested
|
||||||
/// with zero entries, the normal first-upload case.
|
/// without a server.
|
||||||
pub async fn published_versions(
|
fn parse_collection_page(body: &str) -> Result<(Vec<String>, Option<String>), serde_json::Error> {
|
||||||
ppa: &str,
|
let sources: PublishedSources = serde_json::from_str(body)?;
|
||||||
source_name: &str,
|
Ok((
|
||||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
sources
|
||||||
let (user, name) = split_ppa(ppa)?;
|
.entries
|
||||||
let client = crate::distro_info::http_client();
|
.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
|
let response = client
|
||||||
.get(published_sources_url(&user, &name, source_name))
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||||
match response.status() {
|
match response.status() {
|
||||||
reqwest::StatusCode::OK => {
|
reqwest::StatusCode::OK => response
|
||||||
let sources: PublishedSources = response
|
.text()
|
||||||
.json()
|
.await
|
||||||
.await
|
.map_err(|e| format!("cannot read the Launchpad API response for '{ppa}': {e}").into()),
|
||||||
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
|
|
||||||
Ok(sources
|
|
||||||
.entries
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| entry.source_package_version)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
reqwest::StatusCode::NOT_FOUND => {
|
reqwest::StatusCode::NOT_FOUND => {
|
||||||
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
||||||
}
|
}
|
||||||
@@ -243,6 +340,66 @@ pub async fn published_versions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// PPA uploads only target Ubuntu series: fail before uploading when the
|
||||||
/// changes' distribution is not a known series (typo) or a non-Ubuntu one —
|
/// changes' distribution is not a known series (typo) or a non-Ubuntu one —
|
||||||
/// both are only rejected during queue processing otherwise
|
/// both are only rejected during queue processing otherwise
|
||||||
@@ -277,6 +434,13 @@ mod tests {
|
|||||||
assert_eq!(target.login, None);
|
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]
|
#[test]
|
||||||
fn ppa_target_rejects_missing_separator() {
|
fn ppa_target_rejects_missing_separator() {
|
||||||
assert!(ppa_target("just-a-name").is_err());
|
assert!(ppa_target("just-a-name").is_err());
|
||||||
@@ -339,6 +503,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// The API answer carries many unrelated fields; deserialization must
|
||||||
/// pick the relevant ones and tolerate a null `enabled`
|
/// pick the relevant ones and tolerate a null `enabled`
|
||||||
#[test]
|
#[test]
|
||||||
@@ -372,16 +553,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The query matches the verified live `getPublishedSources` call, with
|
/// The query matches the verified live `getPublishedSources` call, with
|
||||||
/// the source name percent-encoded
|
/// the source name percent-encoded and an explicit page size (the API
|
||||||
|
/// default of 75 entries would hide part of long publishing histories)
|
||||||
#[test]
|
#[test]
|
||||||
fn published_sources_url_matches_launchpad_call() {
|
fn published_sources_url_matches_launchpad_call() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
published_sources_url("vhaudiquet", "noctalia", "noctalia"),
|
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"
|
"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!(
|
assert_eq!(
|
||||||
published_sources_url("vhaudiquet", "noctalia", "g++"),
|
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"
|
"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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,21 +593,153 @@ mod tests {
|
|||||||
]
|
]
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let sources: PublishedSources = serde_json::from_str(json).unwrap();
|
let (versions, next) = parse_collection_page(json).unwrap();
|
||||||
let versions: Vec<&str> = sources
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.source_package_version.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(versions, vec!["5.1.0-1ubuntu2", "2:1.0-1"]);
|
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
|
/// A 200 answer with zero entries is the normal "nothing published
|
||||||
/// there" case, and must deserialize to an empty list
|
/// there" case, and must deserialize to an empty list
|
||||||
#[test]
|
#[test]
|
||||||
fn published_sources_parses_empty_collection() {
|
fn published_sources_parses_empty_collection() {
|
||||||
let sources: PublishedSources =
|
let (versions, next) =
|
||||||
serde_json::from_str(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap();
|
parse_collection_page(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap();
|
||||||
assert!(sources.entries.is_empty());
|
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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -9,6 +9,9 @@ pub mod apt;
|
|||||||
pub mod build;
|
pub mod build;
|
||||||
/// Parse or edit a Debian changelog of a source package
|
/// Parse or edit a Debian changelog of a source package
|
||||||
pub mod changelog;
|
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)
|
/// Build a Debian package into a binary (.deb)
|
||||||
pub mod deb;
|
pub mod deb;
|
||||||
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
||||||
@@ -16,8 +19,13 @@ pub mod deb;
|
|||||||
pub mod debian;
|
pub mod debian;
|
||||||
/// Obtain general information about distribution, series, etc
|
/// Obtain general information about distribution, series, etc
|
||||||
pub mod distro_info;
|
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
|
/// Launchpad integration: PPA upload targets and account discovery
|
||||||
pub mod launchpad;
|
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`)
|
/// Scaffold a new Debian source package (`pkh new`)
|
||||||
pub mod new;
|
pub mod new;
|
||||||
/// Obtain information about one or multiple packages
|
/// Obtain information about one or multiple packages
|
||||||
@@ -31,6 +39,15 @@ pub mod put;
|
|||||||
/// Handle package-specific quirks and workarounds
|
/// Handle package-specific quirks and workarounds
|
||||||
pub mod quirks;
|
pub mod quirks;
|
||||||
|
|
||||||
|
/// Line classifiers rewriting raw subprocess output into display actions
|
||||||
|
/// and countable progress (pure logic, shared by build views)
|
||||||
|
pub mod logfmt;
|
||||||
|
|
||||||
|
/// Reporting ports: environment-agnostic build observation ([`BuildView`])
|
||||||
|
/// and question answering ([`Prompter`]), implemented by terminal views,
|
||||||
|
/// server bridges or the inert [`Quiet`]
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
/// Terminal UI helpers (progress bars, live build views, prompts)
|
/// Terminal UI helpers (progress bars, live build views, prompts)
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! The check trait and the static check registry.
|
||||||
|
//!
|
||||||
|
//! Checks are zero-sized structs, one module per packaging area, registered
|
||||||
|
//! in one explicit array — reviewable, greppable, and free of proc macros.
|
||||||
|
//! A registry test fails the build on duplicate tag names, undocumented
|
||||||
|
//! tags or casing drift, so the catalog cannot rot silently.
|
||||||
|
|
||||||
|
use crate::lint::collect::LintData;
|
||||||
|
use crate::lint::emit::Emitter;
|
||||||
|
use crate::lint::tag::Tag;
|
||||||
|
|
||||||
|
/// A group of checks over one area of the packaging (control, changelog,
|
||||||
|
/// git workflow, ...). One check instance may emit any of the tags it
|
||||||
|
/// declares; the emitter resolves metadata and applies suppression.
|
||||||
|
pub trait Check: Sync {
|
||||||
|
/// Registry identifier, also the `--check` value (one word, e.g. `pkh-git`).
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Static metadata of every tag this check may emit.
|
||||||
|
fn tags(&self) -> &'static [Tag];
|
||||||
|
|
||||||
|
/// Run against the collected package information, reporting findings
|
||||||
|
/// through `emit`.
|
||||||
|
fn run(&self, data: &LintData, emit: &mut Emitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every registered check, in catalog order.
|
||||||
|
pub static CHECKS: &[&dyn Check] = &[&super::checks::pkh::PkhGit as &dyn Check];
|
||||||
|
|
||||||
|
/// Look up a registered check by its id (`--check` value).
|
||||||
|
pub fn find_check(id: &str) -> Option<&'static dyn Check> {
|
||||||
|
CHECKS.iter().copied().find(|check| check.id() == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up tag metadata by tag name across the whole registry.
|
||||||
|
pub fn find_tag(name: &str) -> Option<&'static Tag> {
|
||||||
|
CHECKS
|
||||||
|
.iter()
|
||||||
|
.flat_map(|check| check.tags())
|
||||||
|
.find(|tag| tag.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_tag_names_are_unique_and_well_formed() {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for tag in CHECKS.iter().flat_map(|check| check.tags()) {
|
||||||
|
assert!(
|
||||||
|
!seen.contains(&tag.name),
|
||||||
|
"duplicate tag name: {}",
|
||||||
|
tag.name
|
||||||
|
);
|
||||||
|
seen.push(tag.name);
|
||||||
|
let valid = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || "+.-".contains(c);
|
||||||
|
assert!(
|
||||||
|
tag.name.starts_with(valid)
|
||||||
|
&& tag.name.chars().all(valid)
|
||||||
|
&& !tag.name.ends_with('-'),
|
||||||
|
"tag name is not kebab-case: {}",
|
||||||
|
tag.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!tag.description.trim().is_empty(),
|
||||||
|
"tag without a description: {}",
|
||||||
|
tag.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_ids_are_unique_and_resolvable() {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for check in CHECKS {
|
||||||
|
assert!(
|
||||||
|
!seen.contains(&check.id()),
|
||||||
|
"duplicate check id: {}",
|
||||||
|
check.id()
|
||||||
|
);
|
||||||
|
seen.push(check.id());
|
||||||
|
assert!(
|
||||||
|
find_check(check.id()).is_some(),
|
||||||
|
"find_check cannot resolve its own registry: {}",
|
||||||
|
check.id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkh_native_tags_are_namespaced() {
|
||||||
|
for check in CHECKS {
|
||||||
|
if check.id().starts_with("pkh-") {
|
||||||
|
for tag in check.tags() {
|
||||||
|
assert!(
|
||||||
|
tag.name.starts_with("pkh-"),
|
||||||
|
"pkh-native check '{}' emits non-namespaced tag '{}'",
|
||||||
|
check.id(),
|
||||||
|
tag.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! Check areas; one module per area, registered in [`crate::lint::check::CHECKS`].
|
||||||
|
|
||||||
|
/// Pkh-native workflow checks (git-centric trees, PPA uploads).
|
||||||
|
pub mod pkh;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Pkh-native checks: workflow knowledge lintian cannot have, because it
|
||||||
|
//! lives in pkh's flows (git-centric trees, PPA uploads, scaffolding).
|
||||||
|
|
||||||
|
use crate::lint::check::Check;
|
||||||
|
use crate::lint::collect::LintData;
|
||||||
|
use crate::lint::emit::Emitter;
|
||||||
|
use crate::lint::tag::{Certainty, Severity, Tag};
|
||||||
|
|
||||||
|
/// Metadata of every tag the pkh-native checks emit.
|
||||||
|
pub static TAGS: &[Tag] = &[Tag {
|
||||||
|
name: "pkh-debian-changes-not-committed",
|
||||||
|
severity: Severity::Warning,
|
||||||
|
certainty: Certainty::Certain,
|
||||||
|
experimental: false,
|
||||||
|
description: "The debian/ directory contains changes that are not committed to git. \
|
||||||
|
pkh builds and uploads the tree as-is (pkh deb, pkh put); committing first keeps \
|
||||||
|
the upload and the git history in sync.",
|
||||||
|
references: &[],
|
||||||
|
}];
|
||||||
|
|
||||||
|
/// Flags `debian/` content that exists in the tree but is not committed to
|
||||||
|
/// git. Skips trees outside any git repository: archive-pulled sources
|
||||||
|
/// legitimately have none.
|
||||||
|
pub struct PkhGit;
|
||||||
|
|
||||||
|
impl Check for PkhGit {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"pkh-git"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags(&self) -> &'static [Tag] {
|
||||||
|
TAGS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, data: &LintData, emit: &mut Emitter) {
|
||||||
|
let Some(git) = &data.git else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if git.dirty_debian.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let examples: Vec<&str> = git
|
||||||
|
.dirty_debian
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect();
|
||||||
|
let more = if git.dirty_debian.len() > examples.len() {
|
||||||
|
", ..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
emit.tag(
|
||||||
|
"pkh-debian-changes-not-committed",
|
||||||
|
format!(
|
||||||
|
"{} uncommitted change(s) under debian/ (e.g. {}{more})",
|
||||||
|
git.dirty_debian.len(),
|
||||||
|
examples.join(", ")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn write(path: &Path, content: &str) {
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_all(repo: &git2::Repository) {
|
||||||
|
let signature = git2::Signature::now("pkh test", "test@example.com").unwrap();
|
||||||
|
let mut index = repo.index().unwrap();
|
||||||
|
index
|
||||||
|
.add_all(["*"], git2::IndexAddOption::DEFAULT, None)
|
||||||
|
.unwrap();
|
||||||
|
// write_tree alone does not persist the index; without this, a
|
||||||
|
// committed worktree still reads as index-deleted + untracked.
|
||||||
|
index.write().unwrap();
|
||||||
|
let tree_id = index.write_tree().unwrap();
|
||||||
|
let tree = repo.find_tree(tree_id).unwrap();
|
||||||
|
repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_git_repository_is_no_finding() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
write(&dir.path().join("debian/control"), "Source: hello\n");
|
||||||
|
let data = LintData::collect(dir.path(), None, None);
|
||||||
|
assert!(data.git.is_none());
|
||||||
|
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let mut emitter = Emitter::new(data.source_name.clone(), &mut findings);
|
||||||
|
PkhGit.run(&data, &mut emitter);
|
||||||
|
assert!(findings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_worktree_is_no_finding() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
write(&dir.path().join("debian/control"), "Source: hello\n");
|
||||||
|
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||||
|
commit_all(&repo);
|
||||||
|
|
||||||
|
let data = LintData::collect(dir.path(), None, None);
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let mut emitter = Emitter::new(data.source_name.clone(), &mut findings);
|
||||||
|
PkhGit.run(&data, &mut emitter);
|
||||||
|
assert!(findings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dirty_debian_tree_is_a_finding() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
write(&dir.path().join("debian/control"), "Source: hello\n");
|
||||||
|
write(&dir.path().join("hello.txt"), "upstream\n");
|
||||||
|
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||||
|
commit_all(&repo);
|
||||||
|
|
||||||
|
// A modified tracked file and a fresh untracked patch: both count.
|
||||||
|
write(
|
||||||
|
&dir.path().join("debian/control"),
|
||||||
|
"Source: hello\nDepends: x\n",
|
||||||
|
);
|
||||||
|
write(&dir.path().join("debian/patches/new.patch"), "...\n");
|
||||||
|
write(&dir.path().join("hello.txt"), "changed upstream\n");
|
||||||
|
|
||||||
|
let data = LintData::collect(dir.path(), None, None);
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let mut emitter = Emitter::new(data.source_name.clone(), &mut findings);
|
||||||
|
PkhGit.run(&data, &mut emitter);
|
||||||
|
assert_eq!(findings.len(), 1);
|
||||||
|
let finding = &findings[0];
|
||||||
|
assert_eq!(finding.tag_name, "pkh-debian-changes-not-committed");
|
||||||
|
assert_eq!(finding.letter, 'W');
|
||||||
|
assert!(
|
||||||
|
finding
|
||||||
|
.message
|
||||||
|
.starts_with("2 uncommitted change(s) under debian/")
|
||||||
|
);
|
||||||
|
assert!(finding.message.contains("debian/control"));
|
||||||
|
assert!(finding.message.contains("debian/patches/new.patch"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! Collectors: package information gathered once per run and shared by every
|
||||||
|
//! check, mirroring lintian's collection phase without the on-disk lab.
|
||||||
|
//! Source trees are small, so everything is computed eagerly except git
|
||||||
|
//! status, which only exists when the tree is a git worktree.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::debian::control::ControlInfo;
|
||||||
|
|
||||||
|
/// Git worktree state relevant to pkh's git-centric workflow checks.
|
||||||
|
pub struct GitStatus {
|
||||||
|
/// Repo-relative paths under `debian/` with uncommitted content
|
||||||
|
/// (modified, staged or untracked), sorted.
|
||||||
|
pub dirty_debian: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the checks and the report renderer know about the linted tree.
|
||||||
|
pub struct LintData {
|
||||||
|
/// Root of the source tree being linted.
|
||||||
|
pub root: PathBuf,
|
||||||
|
/// Source package name, resolved from `debian/control`, else from the
|
||||||
|
/// changelog's first line, else the directory name.
|
||||||
|
pub source_name: String,
|
||||||
|
/// Target distribution (`--dist`); unresolved when None.
|
||||||
|
pub dist: Option<String>,
|
||||||
|
/// Target series (`--series`); unresolved when None.
|
||||||
|
pub series: Option<String>,
|
||||||
|
/// Git worktree state; None when the tree is not inside a git repository.
|
||||||
|
pub git: Option<GitStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LintData {
|
||||||
|
/// Collect information about the source tree at `root`.
|
||||||
|
pub fn collect(root: &Path, dist: Option<&str>, series: Option<&str>) -> LintData {
|
||||||
|
LintData {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
source_name: resolve_source_name(root),
|
||||||
|
dist: dist.map(str::to_string),
|
||||||
|
series: series.map(str::to_string),
|
||||||
|
git: collect_git(root),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source package name from `debian/control`, falling back to the changelog
|
||||||
|
/// header and then the directory name; the report needs a display name even
|
||||||
|
/// for broken trees.
|
||||||
|
fn resolve_source_name(root: &Path) -> String {
|
||||||
|
if let Ok(control) = ControlInfo::parse(&root.join("debian/control")) {
|
||||||
|
return control.source_name().to_string();
|
||||||
|
}
|
||||||
|
if let Ok(changelog) = std::fs::read_to_string(root.join("debian/changelog"))
|
||||||
|
&& let Some(first) = changelog.lines().next()
|
||||||
|
&& let Some(name) = first.split(" (").next()
|
||||||
|
&& !name.trim().is_empty()
|
||||||
|
{
|
||||||
|
return name.trim().to_string();
|
||||||
|
}
|
||||||
|
root.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "package".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dirty paths under `debian/` when the tree lives in a git worktree.
|
||||||
|
/// Untracked files count: a fresh patch nobody committed is exactly the
|
||||||
|
/// mistake the workflow checks exist to catch. None when there is no
|
||||||
|
/// repository (or git is somehow unusable here) — never a finding, since
|
||||||
|
/// archive-pulled trees legitimately have none.
|
||||||
|
fn collect_git(root: &Path) -> Option<GitStatus> {
|
||||||
|
let repo = git2::Repository::discover(root).ok()?;
|
||||||
|
let workdir = repo.workdir()?;
|
||||||
|
// Statuses are workdir-relative; trees nested inside a repository only
|
||||||
|
// care about their own slice of it.
|
||||||
|
let prefix = root.strip_prefix(workdir).unwrap_or(Path::new(""));
|
||||||
|
let debian_dir = prefix.join("debian");
|
||||||
|
|
||||||
|
let mut options = git2::StatusOptions::new();
|
||||||
|
options
|
||||||
|
.include_untracked(true)
|
||||||
|
.include_ignored(false)
|
||||||
|
.recurse_untracked_dirs(true);
|
||||||
|
let statuses = repo.statuses(Some(&mut options)).ok()?;
|
||||||
|
|
||||||
|
let mut dirty_debian = Vec::new();
|
||||||
|
for entry in statuses.iter() {
|
||||||
|
// CURRENT is the zero flag in libgit2, so any non-empty status is
|
||||||
|
// some kind of change (worktree or staged).
|
||||||
|
if entry.status().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path()?;
|
||||||
|
if Path::new(path).starts_with(&debian_dir) {
|
||||||
|
dirty_debian.push(path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirty_debian.sort();
|
||||||
|
Some(GitStatus { dirty_debian })
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! Findings, the run report, and the emitter checks report through.
|
||||||
|
//!
|
||||||
|
//! The emitter is deliberately thin: checks name a tag and give a message;
|
||||||
|
//! metadata, output letter and explanations come from the registry, so
|
||||||
|
//! findings from the native engine and findings parsed from the wrapped
|
||||||
|
//! lintian share one shape and one rendering path.
|
||||||
|
|
||||||
|
use crate::lint::check;
|
||||||
|
use crate::lint::tag::Tag;
|
||||||
|
|
||||||
|
/// Where a finding comes from.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Origin {
|
||||||
|
/// Emitted by pkh's native Rust checks.
|
||||||
|
Native,
|
||||||
|
/// Parsed from the wrapped lintian's output.
|
||||||
|
Lintian,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lint finding, whatever produced it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Finding {
|
||||||
|
/// Output letter (`E`, `W`, `I`, `P`, `X`, `O`, `C`): severity for
|
||||||
|
/// native findings, verbatim from the output line in wrapper mode.
|
||||||
|
pub letter: char,
|
||||||
|
/// Stable tag name (`missing-debian-copyright-file`).
|
||||||
|
pub tag_name: String,
|
||||||
|
/// Free-form details after the tag name; empty when the tag stands alone.
|
||||||
|
pub message: String,
|
||||||
|
/// Package the finding belongs to (`hello`).
|
||||||
|
pub package: String,
|
||||||
|
/// Processable type lintian displays after the package name (`source`,
|
||||||
|
/// `changes`, ...); None for binary findings, which lintian prints
|
||||||
|
/// without a type.
|
||||||
|
pub processable_type: Option<String>,
|
||||||
|
/// Explanation lines shown by `--info`: the `N:` blocks lintian attaches
|
||||||
|
/// to the finding in wrapper mode, or the native tag's description and
|
||||||
|
/// references.
|
||||||
|
pub explanation: Vec<String>,
|
||||||
|
/// Native or parsed-from-lintian.
|
||||||
|
pub origin: Origin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Finding {
|
||||||
|
/// Lowercase severity/classification name for this finding's letter, as
|
||||||
|
/// used in `--fail-on` values and JSON output.
|
||||||
|
pub fn severity_name(&self) -> &'static str {
|
||||||
|
match self.letter {
|
||||||
|
'E' => "error",
|
||||||
|
'W' => "warning",
|
||||||
|
'I' => "info",
|
||||||
|
'P' => "pedantic",
|
||||||
|
'X' => "experimental",
|
||||||
|
'O' => "overridden",
|
||||||
|
_ => "classification",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full result of one lint run: every finding, from every source.
|
||||||
|
pub struct LintReport {
|
||||||
|
/// All findings in emission order: the wrapper's first, then the native
|
||||||
|
/// ones (deduplicated by tag name against the wrapper's).
|
||||||
|
pub findings: Vec<Finding>,
|
||||||
|
/// Display name of the linted source package.
|
||||||
|
pub source_name: String,
|
||||||
|
/// The wrapper was skipped because lintian is not installed; the
|
||||||
|
/// renderer prints a notice and the run is native-only.
|
||||||
|
pub wrapper_unavailable: bool,
|
||||||
|
/// Run-level notes (`N:` lines): how the linted artifact was obtained,
|
||||||
|
/// and similar context that is not a finding.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sink checks report findings through. Resolves tag metadata from the
|
||||||
|
/// registry so checks only ever name the tag they mean.
|
||||||
|
pub struct Emitter<'a> {
|
||||||
|
package: String,
|
||||||
|
findings: &'a mut Vec<Finding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Emitter<'a> {
|
||||||
|
/// Emitter for findings of `package` (e.g. the source package name),
|
||||||
|
/// appending into `findings`.
|
||||||
|
pub fn new(package: String, findings: &'a mut Vec<Finding>) -> Emitter<'a> {
|
||||||
|
Emitter { package, findings }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit `tag_name` with `message` as its detail line. Native findings
|
||||||
|
/// always report on the source package; an unknown tag name (a registry
|
||||||
|
/// bug) still produces a finding rather than panicking — the registry
|
||||||
|
/// test makes that path unreachable in practice.
|
||||||
|
pub fn tag(&mut self, tag_name: &str, message: impl Into<String>) {
|
||||||
|
let (letter, explanation) = match check::find_tag(tag_name) {
|
||||||
|
Some(tag) => (tag.letter(), explanation_of(tag)),
|
||||||
|
None => ('E', Vec::new()),
|
||||||
|
};
|
||||||
|
self.findings.push(Finding {
|
||||||
|
letter,
|
||||||
|
tag_name: tag_name.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
package: self.package.clone(),
|
||||||
|
processable_type: Some("source".to_string()),
|
||||||
|
explanation,
|
||||||
|
origin: Origin::Native,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explanation `--info` shows for a native tag: its description plus any
|
||||||
|
/// references, prefixed like lintian's "Please refer to" pointers.
|
||||||
|
fn explanation_of(tag: &Tag) -> Vec<String> {
|
||||||
|
let mut lines = vec![tag.description.to_string()];
|
||||||
|
for reference in tag.references {
|
||||||
|
lines.push(format!("Please refer to {}", reference));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
//! `pkh lint`: lint a Debian source tree, with lintian feature parity.
|
||||||
|
//!
|
||||||
|
//! The strategy is *wrap first, port second* (see `plans/pkh-lint.md`): the
|
||||||
|
//! [wrapper](wrapper) runs the installed lintian over an ephemeral source
|
||||||
|
//! package for day-one parity with every lintian check, while the native
|
||||||
|
//! engine (check registry, collectors, emitter) hosts pkh-specific workflow
|
||||||
|
//! checks (`pkh-*` tags) and grows ported lintian checks incrementally.
|
||||||
|
//! Both findings merge into one report — deduplicated by tag name, which is
|
||||||
|
//! why native checks mirror lintian's tag names for equivalent checks —
|
||||||
|
//! rendered as lintian-shaped text or JSON, with lintian's exit-code
|
||||||
|
//! contract (0 clean, 1 findings at/above `--fail-on`, 2 runtime error).
|
||||||
|
|
||||||
|
pub mod check;
|
||||||
|
pub mod checks;
|
||||||
|
pub mod collect;
|
||||||
|
pub mod emit;
|
||||||
|
pub mod output;
|
||||||
|
pub mod tag;
|
||||||
|
pub mod wrapper;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::lint::check::Check;
|
||||||
|
use crate::lint::collect::LintData;
|
||||||
|
use crate::lint::emit::{Finding, LintReport};
|
||||||
|
|
||||||
|
/// Knobs of one `pkh lint` run, built from the CLI in `main.rs`.
|
||||||
|
pub struct LintOptions {
|
||||||
|
/// Source tree to lint.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Run the native engine only; never invoke the lintian wrapper.
|
||||||
|
pub native: bool,
|
||||||
|
/// Levels that make the exit code 1 (`--fail-on`, default: error).
|
||||||
|
pub fail_on: Vec<output::Level>,
|
||||||
|
/// Show tag explanations under each finding (`--info`, lintian's `-i`).
|
||||||
|
pub info: bool,
|
||||||
|
/// Also display info-level (`I:`) findings (lintian's `-I`).
|
||||||
|
pub display_info: bool,
|
||||||
|
/// Also display pedantic (`P:`) findings.
|
||||||
|
pub pedantic: bool,
|
||||||
|
/// Also display experimental (`X:`) findings.
|
||||||
|
pub experimental: bool,
|
||||||
|
/// Also display overridden (`O:`) findings.
|
||||||
|
pub show_overrides: bool,
|
||||||
|
/// Tag names to ignore for this run.
|
||||||
|
pub suppress_tags: Vec<String>,
|
||||||
|
/// Run only these native checks (`--check`, repeatable).
|
||||||
|
pub only_checks: Vec<String>,
|
||||||
|
/// Ignore any existing `pkh build` output and pack the tree fresh.
|
||||||
|
pub repack: bool,
|
||||||
|
/// Emit JSON instead of text.
|
||||||
|
pub json: bool,
|
||||||
|
/// Text colorization mode (`--color`, default auto: TTY without NO_COLOR).
|
||||||
|
pub color: output::ColorMode,
|
||||||
|
/// Target distribution (debian/ubuntu), when known.
|
||||||
|
pub dist: Option<String>,
|
||||||
|
/// Target series, when known.
|
||||||
|
pub series: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one lint: collect package information, run the native checks, wrap
|
||||||
|
/// lintian (unless `--native`), and merge everything into one report.
|
||||||
|
pub fn run(options: &LintOptions) -> Result<LintReport, String> {
|
||||||
|
let data = LintData::collect(
|
||||||
|
&options.path,
|
||||||
|
options.dist.as_deref(),
|
||||||
|
options.series.as_deref(),
|
||||||
|
);
|
||||||
|
let mut report = LintReport {
|
||||||
|
findings: Vec::new(),
|
||||||
|
source_name: data.source_name.clone(),
|
||||||
|
wrapper_unavailable: false,
|
||||||
|
notes: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !options.native {
|
||||||
|
match wrapper::run(&data.root, options)? {
|
||||||
|
Some(outcome) => {
|
||||||
|
report.findings.extend(outcome.findings);
|
||||||
|
report.notes = outcome.notes;
|
||||||
|
}
|
||||||
|
None => report.wrapper_unavailable = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected: Vec<&'static dyn Check> = if options.only_checks.is_empty() {
|
||||||
|
check::CHECKS.to_vec()
|
||||||
|
} else {
|
||||||
|
options
|
||||||
|
.only_checks
|
||||||
|
.iter()
|
||||||
|
.map(|id| check::find_check(id).ok_or_else(|| format!("Unknown --check '{id}'")))
|
||||||
|
.collect::<Result<_, _>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut native = Vec::new();
|
||||||
|
{
|
||||||
|
let mut emitter = emit::Emitter::new(report.source_name.clone(), &mut native);
|
||||||
|
for check in selected {
|
||||||
|
check.run(&data, &mut emitter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.findings.extend(merge(&report.findings, native));
|
||||||
|
|
||||||
|
if !options.suppress_tags.is_empty() {
|
||||||
|
report
|
||||||
|
.findings
|
||||||
|
.retain(|finding| !options.suppress_tags.contains(&finding.tag_name));
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `native` findings to `lintian`'s, dropping native duplicates by
|
||||||
|
/// tag name (the wrapper's verdict wins for tags both engines produce).
|
||||||
|
fn merge(lintian: &[Finding], native: Vec<Finding>) -> Vec<Finding> {
|
||||||
|
let known: HashSet<&str> = lintian.iter().map(|f| f.tag_name.as_str()).collect();
|
||||||
|
native
|
||||||
|
.into_iter()
|
||||||
|
.filter(|finding| !known.contains(finding.tag_name.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `--list-tags` catalog: one line per registered native tag.
|
||||||
|
pub fn list_tags() -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for check in check::CHECKS {
|
||||||
|
for tag in check.tags() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{} [{}] {}\n {}\n",
|
||||||
|
tag.letter(),
|
||||||
|
check.id(),
|
||||||
|
tag.name,
|
||||||
|
tag.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::lint::emit::Origin;
|
||||||
|
|
||||||
|
fn finding(tag: &str, origin: Origin) -> Finding {
|
||||||
|
Finding {
|
||||||
|
letter: 'W',
|
||||||
|
tag_name: tag.to_string(),
|
||||||
|
message: String::new(),
|
||||||
|
package: "hello".to_string(),
|
||||||
|
processable_type: Some("source".to_string()),
|
||||||
|
explanation: Vec::new(),
|
||||||
|
origin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_drops_native_duplicates_by_tag_name() {
|
||||||
|
let lintian = vec![finding("no-debian-copyright-in-source", Origin::Lintian)];
|
||||||
|
let native = vec![
|
||||||
|
finding("no-debian-copyright-in-source", Origin::Native),
|
||||||
|
finding("pkh-debian-changes-not-committed", Origin::Native),
|
||||||
|
];
|
||||||
|
let merged = merge(&lintian, native);
|
||||||
|
assert_eq!(merged.len(), 1);
|
||||||
|
assert_eq!(merged[0].tag_name, "pkh-debian-changes-not-committed");
|
||||||
|
assert_eq!(merged[0].origin, Origin::Native);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tags_resolve_from_the_registry() {
|
||||||
|
let tag = check::find_tag("pkh-debian-changes-not-committed")
|
||||||
|
.expect("pkh check tags must resolve");
|
||||||
|
assert_eq!(tag.letter(), 'W');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_native_tags_still_produce_findings() {
|
||||||
|
let mut sink = Vec::new();
|
||||||
|
let mut emitter = emit::Emitter::new("hello".to_string(), &mut sink);
|
||||||
|
emitter.tag("no-such-tag-anywhere", "boom");
|
||||||
|
assert_eq!(sink.len(), 1);
|
||||||
|
assert_eq!(sink[0].tag_name, "no-such-tag-anywhere");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
//! Rendering of a lint report (text and JSON) and the exit-code decision.
|
||||||
|
//!
|
||||||
|
//! Text output keeps lintian's line shape (`<L>: <pkg> <type>: <tag>
|
||||||
|
//! <details>`) so findings read identically in both modes; JSON is the
|
||||||
|
//! schema-stable form for CI. Display filtering (what is shown) and the
|
||||||
|
//! `--fail-on` threshold (what makes the exit code 1) are independent, like
|
||||||
|
//! lintian's. Colorization happens here, at render time — findings are
|
||||||
|
//! captured plain (`--color never` is passed to lintian) and re-painted by
|
||||||
|
//! severity, so colors are a renderer concern that survives the switch from
|
||||||
|
//! wrapped lintian output to native checks.
|
||||||
|
|
||||||
|
use crossterm::style::Stylize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::lint::LintOptions;
|
||||||
|
use crate::lint::emit::{Finding, LintReport, Origin};
|
||||||
|
|
||||||
|
/// When to colorize the text report.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ColorMode {
|
||||||
|
/// Colorize when stdout is a terminal and `NO_COLOR` is unset.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Always colorize (piping, CI logs).
|
||||||
|
Always,
|
||||||
|
/// Never colorize.
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorMode {
|
||||||
|
/// Parse one `--color` keyword.
|
||||||
|
pub fn parse(word: &str) -> Option<ColorMode> {
|
||||||
|
match word {
|
||||||
|
"auto" => Some(ColorMode::Auto),
|
||||||
|
"always" => Some(ColorMode::Always),
|
||||||
|
"never" => Some(ColorMode::Never),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the text renderer should emit ANSI colors.
|
||||||
|
fn should_color(self) -> bool {
|
||||||
|
match self {
|
||||||
|
ColorMode::Always => true,
|
||||||
|
ColorMode::Never => false,
|
||||||
|
ColorMode::Auto => {
|
||||||
|
std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||||
|
&& std::env::var_os("NO_COLOR").is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The output letter, painted with its severity color when `color` is set:
|
||||||
|
/// errors red (bold), warnings yellow, info cyan, pedantic/experimental
|
||||||
|
/// magenta, overridden green. Mirrors lintian's tty palette closely enough
|
||||||
|
/// for muscle memory.
|
||||||
|
fn paint_letter(letter: char, color: bool) -> String {
|
||||||
|
if !color {
|
||||||
|
return letter.to_string();
|
||||||
|
}
|
||||||
|
match letter {
|
||||||
|
'E' => "E".red().bold().to_string(),
|
||||||
|
'W' => "W".yellow().to_string(),
|
||||||
|
'I' => "I".cyan().to_string(),
|
||||||
|
'P' => "P".magenta().to_string(),
|
||||||
|
'X' => "X".magenta().to_string(),
|
||||||
|
'O' => "O".green().to_string(),
|
||||||
|
_ => letter.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named severity class, mirroring the values lintian's `--fail-on`
|
||||||
|
/// accepts. `Experimental` is the `X:` pseudo-level (the letter hides the
|
||||||
|
/// underlying severity) and `Overridden` lets gates count suppressed tags.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Level {
|
||||||
|
/// `E:` findings.
|
||||||
|
Error,
|
||||||
|
/// `W:` findings.
|
||||||
|
Warning,
|
||||||
|
/// `I:` findings.
|
||||||
|
Info,
|
||||||
|
/// `P:` findings.
|
||||||
|
Pedantic,
|
||||||
|
/// `X:` findings.
|
||||||
|
Experimental,
|
||||||
|
/// `O:` findings (only reported when overrides are shown).
|
||||||
|
Overridden,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Level {
|
||||||
|
/// The output letter the level stands for.
|
||||||
|
pub fn letter(self) -> char {
|
||||||
|
match self {
|
||||||
|
Level::Error => 'E',
|
||||||
|
Level::Warning => 'W',
|
||||||
|
Level::Info => 'I',
|
||||||
|
Level::Pedantic => 'P',
|
||||||
|
Level::Experimental => 'X',
|
||||||
|
Level::Overridden => 'O',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one comma-separated `--fail-on` keyword.
|
||||||
|
pub fn parse(word: &str) -> Option<Level> {
|
||||||
|
match word.trim() {
|
||||||
|
"error" => Some(Level::Error),
|
||||||
|
"warning" => Some(Level::Warning),
|
||||||
|
"info" => Some(Level::Info),
|
||||||
|
"pedantic" => Some(Level::Pedantic),
|
||||||
|
"experimental" => Some(Level::Experimental),
|
||||||
|
"override" => Some(Level::Overridden),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the whole `--fail-on` value (comma-separated level names).
|
||||||
|
pub fn parse_fail_on(spec: &str) -> Result<Vec<Level>, String> {
|
||||||
|
let levels: Vec<Level> = spec
|
||||||
|
.split(',')
|
||||||
|
.filter(|word| !word.trim().is_empty())
|
||||||
|
.map(Level::parse)
|
||||||
|
.collect::<Option<_>>()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Invalid --fail-on value '{spec}': expected comma-separated \
|
||||||
|
error, warning, info, pedantic, experimental or override"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if levels.is_empty() {
|
||||||
|
return Err("Empty --fail-on value: expected at least one level".to_string());
|
||||||
|
}
|
||||||
|
Ok(levels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The letter a finding displays as for display-level purposes: overridden
|
||||||
|
/// findings carry their `O:` letter, classification tags are JSON-only.
|
||||||
|
fn is_displayed(finding: &Finding, options: &LintOptions) -> bool {
|
||||||
|
match finding.letter {
|
||||||
|
'E' | 'W' => true,
|
||||||
|
'I' => options.display_info,
|
||||||
|
'P' => options.pedantic,
|
||||||
|
'X' => options.experimental,
|
||||||
|
'O' => options.show_overrides,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit code of the run: 1 when any finding (except classification tags) is
|
||||||
|
/// at a level listed in `--fail-on`, 0 otherwise. Runtime failures never
|
||||||
|
/// reach this function — the caller exits 2 directly.
|
||||||
|
pub fn exit_code(report: &LintReport, options: &LintOptions) -> i32 {
|
||||||
|
let failed = report
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| match level_of_letter(finding.letter) {
|
||||||
|
Some(level) => options.fail_on.contains(&level),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
if failed { 1 } else { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The level a finding's letter maps to for `--fail-on` purposes; None for
|
||||||
|
/// classification tags, which lintian also never fails on.
|
||||||
|
fn level_of_letter(letter: char) -> Option<Level> {
|
||||||
|
match letter {
|
||||||
|
'E' => Some(Level::Error),
|
||||||
|
'W' => Some(Level::Warning),
|
||||||
|
'I' => Some(Level::Info),
|
||||||
|
'P' => Some(Level::Pedantic),
|
||||||
|
'X' => Some(Level::Experimental),
|
||||||
|
'O' => Some(Level::Overridden),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report as lintian-shaped text: one `<L>: <pkg> <type>: <tag>
|
||||||
|
/// <details>` line per displayed finding, explanations under `--info`, then
|
||||||
|
/// an `N:` summary line.
|
||||||
|
pub fn render_text(report: &LintReport, options: &LintOptions) -> String {
|
||||||
|
let color = options.color.should_color();
|
||||||
|
let mut out = String::new();
|
||||||
|
if report.wrapper_unavailable {
|
||||||
|
out.push_str(
|
||||||
|
"N: lintian is not installed; showing pkh-native checks only \
|
||||||
|
(install lintian for full check coverage)\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for note in &report.notes {
|
||||||
|
out.push_str(&format!("N: {note}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut shown_counts = [('E', 0), ('W', 0), ('I', 0), ('P', 0), ('X', 0), ('O', 0)];
|
||||||
|
let mut shown = 0;
|
||||||
|
for finding in &report.findings {
|
||||||
|
if !is_displayed(finding, options) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shown += 1;
|
||||||
|
if let Some((_, count)) = shown_counts
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(letter, _)| *letter == finding.letter)
|
||||||
|
{
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{}: {}\n",
|
||||||
|
paint_letter(finding.letter, color),
|
||||||
|
line_subject(finding)
|
||||||
|
));
|
||||||
|
if options.info {
|
||||||
|
for line in &finding.explanation {
|
||||||
|
if line.is_empty() {
|
||||||
|
out.push_str("N:\n");
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("N: {}\n", line.trim_end()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for (letter, count) in shown_counts {
|
||||||
|
if count > 0 {
|
||||||
|
parts.push(format!("{} {}", count, paint_letter(letter, color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hidden = report.findings.len() - shown;
|
||||||
|
if parts.is_empty() {
|
||||||
|
out.push_str(&format!("N: no displayed tags; {hidden} hidden\n"));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"N: {shown} tag(s) shown ({}); {hidden} hidden\n",
|
||||||
|
parts.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hello source: tag details` — the part of a lintian line after the
|
||||||
|
/// letter, reproduced identically for both origins.
|
||||||
|
fn line_subject(finding: &Finding) -> String {
|
||||||
|
let mut line = String::from(&finding.package);
|
||||||
|
if let Some(ptype) = &finding.processable_type {
|
||||||
|
line.push(' ');
|
||||||
|
line.push_str(ptype);
|
||||||
|
}
|
||||||
|
line.push_str(": ");
|
||||||
|
line.push_str(&finding.tag_name);
|
||||||
|
if !finding.message.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
line.push_str(&finding.message);
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the report as pretty-printed JSON: every finding (displayed or
|
||||||
|
/// not, flagged as such) plus a summary carrying the exit-code verdict.
|
||||||
|
pub fn render_json(report: &LintReport, options: &LintOptions) -> String {
|
||||||
|
let findings: Vec<serde_json::Value> = report
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| {
|
||||||
|
json!({
|
||||||
|
"letter": finding.letter.to_string(),
|
||||||
|
"tag": finding.tag_name,
|
||||||
|
"severity": finding.severity_name(),
|
||||||
|
"package": finding.package,
|
||||||
|
"processable_type": finding.processable_type,
|
||||||
|
"message": finding.message,
|
||||||
|
"origin": match finding.origin {
|
||||||
|
Origin::Native => "native",
|
||||||
|
Origin::Lintian => "lintian",
|
||||||
|
},
|
||||||
|
"overridden": finding.letter == 'O',
|
||||||
|
"displayed": is_displayed(finding, options),
|
||||||
|
"explanation": if finding.explanation.is_empty() {
|
||||||
|
json!(null)
|
||||||
|
} else {
|
||||||
|
json!(finding.explanation)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let document = json!({
|
||||||
|
"source": report.source_name,
|
||||||
|
"wrapper_unavailable": report.wrapper_unavailable,
|
||||||
|
"notes": report.notes,
|
||||||
|
"findings": findings,
|
||||||
|
"summary": {
|
||||||
|
"failed": exit_code(report, options) == 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
serde_json::to_string_pretty(&document).expect("lint report JSON is serializable")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn options(fail_on: &[Level]) -> LintOptions {
|
||||||
|
LintOptions {
|
||||||
|
path: std::path::PathBuf::from("."),
|
||||||
|
native: false,
|
||||||
|
fail_on: fail_on.to_vec(),
|
||||||
|
info: false,
|
||||||
|
display_info: false,
|
||||||
|
pedantic: false,
|
||||||
|
experimental: false,
|
||||||
|
show_overrides: false,
|
||||||
|
suppress_tags: Vec::new(),
|
||||||
|
only_checks: Vec::new(),
|
||||||
|
repack: false,
|
||||||
|
json: false,
|
||||||
|
color: ColorMode::Never,
|
||||||
|
dist: None,
|
||||||
|
series: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(findings: &[(&str, char)]) -> LintReport {
|
||||||
|
LintReport {
|
||||||
|
findings: findings
|
||||||
|
.iter()
|
||||||
|
.map(|(tag, letter)| Finding {
|
||||||
|
letter: *letter,
|
||||||
|
tag_name: (*tag).to_string(),
|
||||||
|
message: String::new(),
|
||||||
|
package: "hello".to_string(),
|
||||||
|
processable_type: Some("source".to_string()),
|
||||||
|
explanation: Vec::new(),
|
||||||
|
origin: Origin::Lintian,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
source_name: "hello".to_string(),
|
||||||
|
wrapper_unavailable: false,
|
||||||
|
notes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_on_parses_and_rejects_unknown_levels() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_fail_on("error, warning").unwrap(),
|
||||||
|
vec![Level::Error, Level::Warning]
|
||||||
|
);
|
||||||
|
assert!(parse_fail_on("bogus").is_err());
|
||||||
|
assert!(parse_fail_on(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_code_only_counts_displayed_severity_letters() {
|
||||||
|
let default_opts = options(&[Level::Error]);
|
||||||
|
// Errors fail, warnings alone do not (the default threshold).
|
||||||
|
assert_eq!(exit_code(&report(&[("t", 'E')]), &default_opts), 1);
|
||||||
|
assert_eq!(
|
||||||
|
exit_code(&report(&[("t", 'W'), ("u", 'I')]), &default_opts),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
// Overridden findings never fail unless explicitly requested.
|
||||||
|
assert_eq!(exit_code(&report(&[("t", 'O')]), &default_opts), 0);
|
||||||
|
assert_eq!(
|
||||||
|
exit_code(
|
||||||
|
&report(&[("t", 'O')]),
|
||||||
|
&options(&[Level::Error, Level::Overridden])
|
||||||
|
),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
// Classification tags never fail.
|
||||||
|
assert_eq!(exit_code(&report(&[("t", 'C')]), &default_opts), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_rendering_respects_display_levels() {
|
||||||
|
let base = report(&[("e", 'E'), ("i", 'I'), ("o", 'O'), ("x", 'X')]);
|
||||||
|
let default = render_text(&base, &options(&[Level::Error]));
|
||||||
|
assert!(default.contains("E: hello source: e"));
|
||||||
|
assert!(!default.contains("I: "));
|
||||||
|
assert!(!default.contains("O: "));
|
||||||
|
assert!(!default.contains("X: "));
|
||||||
|
assert!(default.ends_with("N: 1 tag(s) shown (1 E); 3 hidden\n"));
|
||||||
|
|
||||||
|
let everything = LintOptions {
|
||||||
|
display_info: true,
|
||||||
|
experimental: true,
|
||||||
|
show_overrides: true,
|
||||||
|
..options(&[Level::Error])
|
||||||
|
};
|
||||||
|
let full = render_text(&base, &everything);
|
||||||
|
for prefix in ["E:", "I:", "O:", "X:"] {
|
||||||
|
assert!(
|
||||||
|
full.contains(&format!("{prefix} hello source:")),
|
||||||
|
"{prefix}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(full.ends_with("N: 4 tag(s) shown (1 E, 1 I, 1 X, 1 O); 0 hidden\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorization_is_render_only_and_mode_switchable() {
|
||||||
|
let run_report = report(&[("e", 'E'), ("w", 'W'), ("o", 'O')]);
|
||||||
|
|
||||||
|
let plain = options(&[Level::Error]);
|
||||||
|
let mut forced = options(&[Level::Error]);
|
||||||
|
forced.color = ColorMode::Always;
|
||||||
|
|
||||||
|
let plain = render_text(&run_report, &plain);
|
||||||
|
let colored = render_text(&run_report, &forced);
|
||||||
|
assert!(
|
||||||
|
!plain.contains('\u{1b}'),
|
||||||
|
"never/auto-on-pipe must stay plain"
|
||||||
|
);
|
||||||
|
assert!(colored.contains('\u{1b}'), "always must colorize");
|
||||||
|
// Colors wrap the letters only; the lintian line shape is intact.
|
||||||
|
assert!(colored.contains("hello source: e"));
|
||||||
|
// Mode parsing round-trips.
|
||||||
|
assert_eq!(ColorMode::parse("always"), Some(ColorMode::Always));
|
||||||
|
assert_eq!(ColorMode::parse("bogus"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_round_trips_with_verdicts() {
|
||||||
|
let run_report = report(&[("e", 'E'), ("w", 'W')]);
|
||||||
|
let default_opts = options(&[Level::Error]);
|
||||||
|
let document: serde_json::Value =
|
||||||
|
serde_json::from_str(&render_json(&run_report, &default_opts)).unwrap();
|
||||||
|
assert_eq!(document["source"], "hello");
|
||||||
|
assert_eq!(document["findings"][0]["letter"], "E");
|
||||||
|
assert_eq!(document["findings"][0]["severity"], "error");
|
||||||
|
assert_eq!(document["findings"][0]["origin"], "lintian");
|
||||||
|
assert_eq!(document["summary"]["failed"], true);
|
||||||
|
|
||||||
|
// An error finding fails at the warning threshold too, but not at
|
||||||
|
// pedantic-only, which nothing in this report reaches.
|
||||||
|
let warn_only = options(&[Level::Warning]);
|
||||||
|
assert_eq!(exit_code(&run_report, &warn_only), 1);
|
||||||
|
let document: serde_json::Value =
|
||||||
|
serde_json::from_str(&render_json(&run_report, &warn_only)).unwrap();
|
||||||
|
assert_eq!(document["summary"]["failed"], true);
|
||||||
|
|
||||||
|
let pedantic_only = options(&[Level::Pedantic]);
|
||||||
|
assert_eq!(exit_code(&run_report, &pedantic_only), 0);
|
||||||
|
let document: serde_json::Value =
|
||||||
|
serde_json::from_str(&render_json(&run_report, &pedantic_only)).unwrap();
|
||||||
|
assert_eq!(document["summary"]["failed"], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Lint tag model: severities, certainties and static tag metadata.
|
||||||
|
//!
|
||||||
|
//! Tags are the atomic diagnostics of a lint run, named after lintian's
|
||||||
|
//! model: a stable machine-readable name (`missing-debian-copyright-file`),
|
||||||
|
//! a severity, a certainty and a description. Checks declare the tags they
|
||||||
|
//! may emit as static [`Tag`] values; the wrapper's parsed findings carry the
|
||||||
|
//! letter lintian printed instead.
|
||||||
|
|
||||||
|
/// Severity of a finding, matching lintian's severity ladder.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Severity {
|
||||||
|
/// Policy violation or broken package data (`E:`).
|
||||||
|
Error,
|
||||||
|
/// Probable bug or policy deviation (`W:`).
|
||||||
|
Warning,
|
||||||
|
/// Informational note about packaging choices (`I:`).
|
||||||
|
Info,
|
||||||
|
/// Nitpick most packages may legitimately ignore (`P:`).
|
||||||
|
Pedantic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Severity {
|
||||||
|
/// The output letter lintian displays this severity as (`E`, `W`, ...).
|
||||||
|
pub fn letter(self) -> char {
|
||||||
|
match self {
|
||||||
|
Severity::Error => 'E',
|
||||||
|
Severity::Warning => 'W',
|
||||||
|
Severity::Info => 'I',
|
||||||
|
Severity::Pedantic => 'P',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase name used in `--fail-on` values and JSON output.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Severity::Error => "error",
|
||||||
|
Severity::Warning => "warning",
|
||||||
|
Severity::Info => "info",
|
||||||
|
Severity::Pedantic => "pedantic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How sure a check is that a finding is real. Not acted upon yet (a future
|
||||||
|
/// `--fail-on error,certain` would consume it), but captured from day one so
|
||||||
|
/// severity tuning is data-driven later.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Certainty {
|
||||||
|
/// The finding is a fact (e.g. a missing file).
|
||||||
|
Certain,
|
||||||
|
/// The finding is likely but has legitimate exceptions.
|
||||||
|
Possible,
|
||||||
|
/// The finding is a guess from weak signals.
|
||||||
|
WildGuess,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static metadata of one tag: what checks declare and the renderer resolves.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Tag {
|
||||||
|
/// Stable machine-readable name, lintian-compatible kebab-case
|
||||||
|
/// (`pkh-debian-changes-not-committed`).
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Severity the tag reports at.
|
||||||
|
pub severity: Severity,
|
||||||
|
/// How sure checks are when emitting this tag.
|
||||||
|
pub certainty: Certainty,
|
||||||
|
/// Whether the tag is experimental (`X:` output, hidden by default).
|
||||||
|
pub experimental: bool,
|
||||||
|
/// One-paragraph explanation, shown by `--info` and `--list-tags`.
|
||||||
|
pub description: &'static str,
|
||||||
|
/// References (policy sections, URLs) shown by `--info`.
|
||||||
|
pub references: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tag {
|
||||||
|
/// The output letter for this tag: experimental tags render as `X:`
|
||||||
|
/// regardless of their severity, like lintian.
|
||||||
|
pub fn letter(&self) -> char {
|
||||||
|
if self.experimental {
|
||||||
|
'X'
|
||||||
|
} else {
|
||||||
|
self.severity.letter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
//! The lintian wrapper: pkh's day-one feature-parity layer.
|
||||||
|
//!
|
||||||
|
//! Lintian only accepts built package files, never source trees, so the
|
||||||
|
//! wrapper needs a source artifact. It lints the `pkh build` output next to
|
||||||
|
//! the tree when it matches the current changelog entry and nothing in the
|
||||||
|
//! tree is newer than it (the fast path: no packing at all); otherwise it
|
||||||
|
//! packs the tree fresh with `dpkg-source -b` inside a temporary directory
|
||||||
|
//! and lints the resulting `.dsc` (`--repack` forces that path). Nothing is
|
||||||
|
//! written back into the linted tree; the temp directory is removed on drop.
|
||||||
|
//!
|
||||||
|
//! Parsing is load-bearing here (findings are merged with the native ones,
|
||||||
|
//! rendered uniformly and exported as JSON), so the parser is pinned by
|
||||||
|
//! golden tests captured from real lintian output. Lintian's own exit code
|
||||||
|
//! is *not* authoritative: lintian uses 2 both for "fail-on met" and for
|
||||||
|
//! runtime errors, while pkh derives the verdict from the parsed findings
|
||||||
|
//! and reserves 2 for actual runtime failures.
|
||||||
|
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::lint::LintOptions;
|
||||||
|
use crate::lint::emit::{Finding, Origin};
|
||||||
|
|
||||||
|
/// What one wrapper run produced: the parsed lintian findings plus `N:`
|
||||||
|
/// notes about how the artifact being linted was obtained.
|
||||||
|
pub struct WrapperOutcome {
|
||||||
|
/// Lintian's findings.
|
||||||
|
pub findings: Vec<Finding>,
|
||||||
|
/// Human-visible notes (artifact reuse, fresh packing) rendered as `N:`.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the wrapper over the source tree at `root`:
|
||||||
|
/// - `Ok(Some(outcome))` — lintian ran; these are its findings and notes,
|
||||||
|
/// - `Ok(None)` — lintian is not installed; the caller falls back to
|
||||||
|
/// native-only with a notice,
|
||||||
|
/// - `Err(message)` — runtime failure (tree unpacked badly, lintian crashed
|
||||||
|
/// without reportable output); the caller exits 2.
|
||||||
|
///
|
||||||
|
/// The artifact linted is the `pkh build` output next to the tree when it
|
||||||
|
/// matches the current changelog entry and no tree content is newer;
|
||||||
|
/// otherwise the tree is packed fresh (with `--repack` forcing that path).
|
||||||
|
pub fn run(root: &Path, options: &LintOptions) -> Result<Option<WrapperOutcome>, String> {
|
||||||
|
let tmp = TempDir::new()?;
|
||||||
|
let root = root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Cannot lint '{}': {e}", root.display()))?;
|
||||||
|
|
||||||
|
let (dsc, notes) = match usable_build_output(&root, options.repack) {
|
||||||
|
Some(dsc) => {
|
||||||
|
let note = format!(
|
||||||
|
"linting pkh build output {}",
|
||||||
|
crate::report::display_path(&dsc)
|
||||||
|
);
|
||||||
|
(dsc, vec![note])
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let dsc = pack(&root, &tmp)?;
|
||||||
|
let reason = if options.repack {
|
||||||
|
"the tree was packed fresh with dpkg-source (--repack ignored \
|
||||||
|
the existing pkh build output)"
|
||||||
|
.to_string()
|
||||||
|
} else if expected_build_output(&root).is_some() {
|
||||||
|
"the tree changed since pkh build, so it was packed fresh \
|
||||||
|
with dpkg-source (rerun pkh build to lint the build output)"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"no pkh build output next to the tree, so it was packed fresh \
|
||||||
|
with dpkg-source (pkh build produces one)"
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
(dsc, vec![reason])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lintian = Command::new("lintian");
|
||||||
|
lintian
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.args(["--no-cfg", "--color", "never"])
|
||||||
|
.arg("--info")
|
||||||
|
.arg(&dsc);
|
||||||
|
// Per-distro scoping: lintian auto-detects the *host* vendor, but pkh
|
||||||
|
// knows the *target* distro (-d); make the two agree, which matters on
|
||||||
|
// cross-distro hosts (linting an Ubuntu package on Debian or back).
|
||||||
|
if let Some(dist) = &options.dist
|
||||||
|
&& matches!(dist.as_str(), "ubuntu" | "debian")
|
||||||
|
{
|
||||||
|
lintian.arg("--profile").arg(dist);
|
||||||
|
}
|
||||||
|
if options.display_info {
|
||||||
|
lintian.arg("--display-info");
|
||||||
|
}
|
||||||
|
if options.pedantic {
|
||||||
|
lintian.arg("--pedantic");
|
||||||
|
}
|
||||||
|
if options.experimental {
|
||||||
|
lintian.arg("--display-experimental");
|
||||||
|
}
|
||||||
|
if options.show_overrides {
|
||||||
|
lintian.arg("--show-overrides");
|
||||||
|
}
|
||||||
|
if !options.suppress_tags.is_empty() {
|
||||||
|
lintian
|
||||||
|
.arg("--suppress-tags")
|
||||||
|
.arg(options.suppress_tags.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = match lintian.output() {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(format!("Could not run lintian: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let findings = parse(&stdout);
|
||||||
|
if !output.status.success() && findings.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"lintian exited with {} without reporting findings:\n{}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(WrapperOutcome { findings, notes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack the tree into an ephemeral source package and return its `.dsc`.
|
||||||
|
///
|
||||||
|
/// Compression is deliberately weak (`-Zgzip -z1`): the artifact only ever
|
||||||
|
/// goes to lintian and is deleted with the temp directory, and xz on a
|
||||||
|
/// large tree dominates the whole run (measured: 9.8 s xz vs 2.7 s gzip on
|
||||||
|
/// a 111 MB tree). Locale-independent subprocess output: dpkg messages can
|
||||||
|
/// be localized, and the error sniffing relies on English wording.
|
||||||
|
fn pack(root: &Path, tmp: &TempDir) -> Result<PathBuf, String> {
|
||||||
|
// 3.0 (quilt) trees need the orig tarball(s) reachable from the working
|
||||||
|
// directory, and dpkg-source searches cwd — link them from the tree's
|
||||||
|
// parent, where pkh build / git ubuntu export-orig leave them.
|
||||||
|
link_orig_tarballs(root, tmp.path());
|
||||||
|
|
||||||
|
let output = Command::new("dpkg-source")
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.args(["-b", "-Zgzip", "-z1"])
|
||||||
|
.arg(root)
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Could not run dpkg-source: {e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if stderr.to_lowercase().contains("uncommitted") {
|
||||||
|
return Err(
|
||||||
|
"The source tree has uncommitted changes that dpkg-source refuses \
|
||||||
|
to pack. Commit them first, or use --native to lint with pkh's \
|
||||||
|
native checks only."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"dpkg-source -b failed, the tree may not be a valid source package:\n{}",
|
||||||
|
stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
find_dsc(tmp.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pkh build` output matching the tree's current changelog entry, when
|
||||||
|
/// it exists and no tree content is newer than it: linting a stale artifact
|
||||||
|
/// would report the packaging of the past, so staleness forces a fresh pack.
|
||||||
|
fn usable_build_output(root: &Path, force_repack: bool) -> Option<PathBuf> {
|
||||||
|
if force_repack {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dsc = expected_build_output(root)?;
|
||||||
|
let built = std::fs::metadata(&dsc).ok()?.modified().ok()?;
|
||||||
|
if tree_newer_than(root, built) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(dsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pkh build` output path matching the tree's current changelog entry
|
||||||
|
/// (`../<source>_<version>.dsc`, pkh build's own naming), if it exists.
|
||||||
|
fn expected_build_output(root: &Path) -> Option<PathBuf> {
|
||||||
|
let entry =
|
||||||
|
crate::debian::changelog::parse_changelog_entry(&root.join("debian/changelog")).ok()?;
|
||||||
|
let dsc = root
|
||||||
|
.parent()?
|
||||||
|
.join(format!("{}_{}.dsc", entry.source, entry.version.no_epoch()));
|
||||||
|
std::fs::metadata(&dsc).ok()?;
|
||||||
|
Some(dsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any tree content is newer than `built`. Skips `.git` and `.pc`:
|
||||||
|
/// commits and quilt bookkeeping churn their mtimes without touching what
|
||||||
|
/// the source package contains.
|
||||||
|
fn tree_newer_than(root: &Path, built: SystemTime) -> bool {
|
||||||
|
const SKIP: &[&str] = &[".git", ".pc"];
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if SKIP.iter().any(|skip| name.to_string_lossy() == *skip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match entry.file_type() {
|
||||||
|
Ok(ft) if ft.is_dir() => stack.push(entry.path()),
|
||||||
|
_ => {
|
||||||
|
let newer = entry
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.is_some_and(|modified| modified > built);
|
||||||
|
if newer {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symlink the orig (and orig component) tarballs for the tree's upstream
|
||||||
|
/// version from the tree's parent into `dest`, ignoring absence — native
|
||||||
|
/// trees have none, and missing tarballs surface as a dpkg-source error.
|
||||||
|
fn link_orig_tarballs(root: &Path, dest: &Path) {
|
||||||
|
let Some(parent) = root.parent() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(entry) = crate::debian::changelog::parse_changelog_entry(&root.join("debian/changelog"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let prefixes = [
|
||||||
|
format!("{}_{}.orig.tar.", entry.source, entry.version.upstream),
|
||||||
|
format!("{}_{}.orig-", entry.source, entry.version.upstream),
|
||||||
|
];
|
||||||
|
let Ok(entries) = std::fs::read_dir(parent) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for candidate in entries.flatten() {
|
||||||
|
let name = candidate.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if !candidate.file_type().is_ok_and(|ft| ft.is_file())
|
||||||
|
|| !prefixes
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| name.starts_with(prefix.as_str()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = std::os::unix::fs::symlink(parent.join(name.as_ref()), dest.join(name.as_ref()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse lintian's output into findings. Tag lines carry the finding; `N:`
|
||||||
|
/// note lines following a tag line are its explanation (`--info` output) and
|
||||||
|
/// attach to it. Anything else is ignored.
|
||||||
|
fn parse(text: &str) -> Vec<Finding> {
|
||||||
|
let tag_line = Regex::new(r"^(?P<letter>[EWIPXOC]): (?P<rest>.*)$").expect("static regex");
|
||||||
|
let subject = Regex::new(
|
||||||
|
r"^(?P<pkg>\S+?)(?: (?P<ptype>source|binary|udeb|changes|buildinfo))?: (?P<tag>\S+)(?: (?P<details>.*))?$",
|
||||||
|
)
|
||||||
|
.expect("static regex");
|
||||||
|
let note_line = Regex::new(r"^N:(?: (?P<text>.*))?$").expect("static regex");
|
||||||
|
|
||||||
|
let mut findings: Vec<Finding> = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(note) = note_line.captures(line) {
|
||||||
|
// Attach to the finding above, like lintian lays out --info.
|
||||||
|
if let (Some(text), Some(last)) = (note.name("text"), findings.last_mut())
|
||||||
|
&& !text.as_str().trim().is_empty()
|
||||||
|
{
|
||||||
|
last.explanation.push(text.as_str().trim().to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(head) = tag_line.captures(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(subject) = subject.captures(&head["rest"]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
findings.push(Finding {
|
||||||
|
letter: head["letter"].chars().next().unwrap_or('E'),
|
||||||
|
tag_name: subject["tag"].to_string(),
|
||||||
|
message: subject
|
||||||
|
.name("details")
|
||||||
|
.map_or(String::new(), |d| d.as_str().to_string()),
|
||||||
|
package: subject["pkg"].to_string(),
|
||||||
|
processable_type: subject.name("ptype").map(|p| p.as_str().to_string()),
|
||||||
|
explanation: Vec::new(),
|
||||||
|
origin: Origin::Lintian,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
findings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single `.dsc` the ephemeral source package produced.
|
||||||
|
fn find_dsc(dir: &Path) -> Result<PathBuf, String> {
|
||||||
|
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||||
|
.map_err(|e| format!("Cannot read the temporary build directory: {e}"))?
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().is_some_and(|ext| ext == "dsc"))
|
||||||
|
.collect();
|
||||||
|
match entries.pop() {
|
||||||
|
Some(dsc) => Ok(dsc),
|
||||||
|
None => Err(
|
||||||
|
"dpkg-source produced no .dsc; the tree may not be a valid source package".to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporary directory removed on drop; the hand-rolled stand-in for
|
||||||
|
/// `tempfile`, which is dev-only in this crate.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Result<TempDir, String> {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let path = std::env::temp_dir().join(format!("pkh-lint-{}-{unique}", std::process::id()));
|
||||||
|
std::fs::create_dir(&path)
|
||||||
|
.map_err(|e| format!("Could not create a temporary directory: {e}"))?;
|
||||||
|
Ok(TempDir(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Captured from lintian 2.129.0ubuntu2.1 on a broken native source
|
||||||
|
/// package (default display level).
|
||||||
|
const SOURCE_OUTPUT: &str = "\
|
||||||
|
E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1]
|
||||||
|
E: hello source: package-uses-debhelper-but-lacks-build-depends [debian/rules]
|
||||||
|
W: hello source: debhelper-but-no-misc-depends hello
|
||||||
|
W: hello source: debhelper-compat-file-is-missing
|
||||||
|
W: hello source: no-debian-copyright-in-source
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Captured from lintian 2.129.0ubuntu2.1 on a binary package: binary
|
||||||
|
/// findings carry no processable type after the package name.
|
||||||
|
const BINARY_OUTPUT: &str = "\
|
||||||
|
E: badpkg: description-too-short test
|
||||||
|
E: badpkg: extended-description-is-empty
|
||||||
|
W: badpkg: empty-binary-package
|
||||||
|
W: badpkg: recommended-field badpkg_1.0-1_all.deb Priority
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Captured with --show-overrides: overridden findings print as `O:`.
|
||||||
|
const OVERRIDDEN_OUTPUT: &str = "\
|
||||||
|
E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1]
|
||||||
|
O: hello source: debhelper-compat-file-is-missing
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Captured with --info: each tag line is followed by `N:` explanation
|
||||||
|
/// lines that belong to it.
|
||||||
|
const INFO_OUTPUT: &str = "\
|
||||||
|
N:
|
||||||
|
E: hello source: malformed-debian-changelog-version 0.1-1 (for native) [debian/changelog:1]
|
||||||
|
N:
|
||||||
|
N: The version string in the latest changelog entry was not parsed correctly.
|
||||||
|
N: Usually, that means it does not conform to policy.
|
||||||
|
N:
|
||||||
|
N:
|
||||||
|
E: hello source: package-uses-debhelper-but-lacks-build-depends [debian/rules]
|
||||||
|
N:
|
||||||
|
N: If a package uses debhelper, it must declare a Build-Depends on debhelper
|
||||||
|
N: or on the debhelper-compat virtual package. For example:
|
||||||
|
N:
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_source_output_with_type() {
|
||||||
|
let findings = parse(SOURCE_OUTPUT);
|
||||||
|
assert_eq!(findings.len(), 5);
|
||||||
|
let first = &findings[0];
|
||||||
|
assert_eq!(first.letter, 'E');
|
||||||
|
assert_eq!(first.package, "hello");
|
||||||
|
assert_eq!(first.processable_type.as_deref(), Some("source"));
|
||||||
|
assert_eq!(first.tag_name, "malformed-debian-changelog-version");
|
||||||
|
assert_eq!(first.message, "0.1-1 (for native) [debian/changelog:1]");
|
||||||
|
assert_eq!(findings[2].letter, 'W');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_binary_output_without_type() {
|
||||||
|
let findings = parse(BINARY_OUTPUT);
|
||||||
|
assert_eq!(findings.len(), 4);
|
||||||
|
let first = &findings[0];
|
||||||
|
assert_eq!(first.package, "badpkg");
|
||||||
|
assert_eq!(first.processable_type, None);
|
||||||
|
assert_eq!(first.tag_name, "description-too-short");
|
||||||
|
// Details containing a file name with dots survive intact.
|
||||||
|
assert_eq!(findings[3].message, "badpkg_1.0-1_all.deb Priority");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_overridden_lines() {
|
||||||
|
let findings = parse(OVERRIDDEN_OUTPUT);
|
||||||
|
assert_eq!(findings.len(), 2);
|
||||||
|
assert_eq!(findings[1].letter, 'O');
|
||||||
|
assert_eq!(findings[1].tag_name, "debhelper-compat-file-is-missing");
|
||||||
|
assert_eq!(findings[1].message, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attaches_info_notes_to_the_preceding_finding() {
|
||||||
|
let findings = parse(INFO_OUTPUT);
|
||||||
|
assert_eq!(findings.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
findings[0].explanation,
|
||||||
|
vec![
|
||||||
|
"The version string in the latest changelog entry was not parsed correctly.",
|
||||||
|
"Usually, that means it does not conform to policy.",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(findings[1].explanation.len(), 2);
|
||||||
|
assert!(findings[1].explanation[0].starts_with("If a package uses debhelper"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_stray_lines() {
|
||||||
|
let findings =
|
||||||
|
parse("N: lintian ran\ngarbage line\nC: hello source: some-classification\n");
|
||||||
|
// The C: classification line parses (kept for JSON), garbage drops.
|
||||||
|
assert_eq!(findings.len(), 1);
|
||||||
|
assert_eq!(findings[0].letter, 'C');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lintable tree at `<outer>/<name>` with a changelog entry
|
||||||
|
/// `pkg (1.0-1) resolute`; returns the outer dir (the tree's parent,
|
||||||
|
/// where pkh build would place artifacts).
|
||||||
|
fn tree() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let outer = tempfile::tempdir().unwrap();
|
||||||
|
let root = outer.path().join("pkg-1.0");
|
||||||
|
std::fs::create_dir_all(root.join("debian")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.join("debian/changelog"),
|
||||||
|
"pkg (1.0-1) resolute; urgency=medium\n\n * x\n\n -- J <j@e.org> Sat, 19 Sep 2026 12:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(outer, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dsc_of(outer: &tempfile::TempDir) -> std::path::PathBuf {
|
||||||
|
outer.path().join("pkg_1.0-1.dsc")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_build_output_is_reused() {
|
||||||
|
let (outer, root) = tree();
|
||||||
|
std::fs::write(dsc_of(&outer), "dummy dsc").unwrap();
|
||||||
|
// The dsc was written after every tree file: current.
|
||||||
|
assert_eq!(usable_build_output(&root, false), Some(dsc_of(&outer)));
|
||||||
|
// Forcing repack skips it.
|
||||||
|
assert_eq!(usable_build_output(&root, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_or_mismatched_build_output_is_rejected() {
|
||||||
|
let (outer, root) = tree();
|
||||||
|
// A tree file written after the dsc makes the artifact stale. The
|
||||||
|
// sleep crosses the coarse clock tick mtimes are stamped with, so
|
||||||
|
// the control file is strictly newer than the dsc.
|
||||||
|
std::fs::write(dsc_of(&outer), "dummy dsc").unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
std::fs::write(root.join("debian/control"), "Source: pkg\n").unwrap();
|
||||||
|
assert_eq!(usable_build_output(&root, false), None);
|
||||||
|
|
||||||
|
// A dsc of a different version does not represent this tree.
|
||||||
|
let old_dsc = outer.path().join("pkg_0.9-1.dsc");
|
||||||
|
std::fs::write(&old_dsc, "dummy dsc").unwrap();
|
||||||
|
assert_eq!(usable_build_output(&root, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_build_output_is_rejected() {
|
||||||
|
let (outer, root) = tree();
|
||||||
|
assert_eq!(usable_build_output(&root, false), None);
|
||||||
|
assert!(!dsc_of(&outer).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orig_tarballs_are_linked_for_packing() {
|
||||||
|
let (outer, root) = tree();
|
||||||
|
std::fs::write(outer.path().join("pkg_1.0.orig.tar.xz"), "orig").unwrap();
|
||||||
|
std::fs::write(outer.path().join("pkg_1.0.orig-data.tar.gz"), "comp").unwrap();
|
||||||
|
std::fs::write(outer.path().join("unrelated_1.0.orig.tar.xz"), "no").unwrap();
|
||||||
|
std::fs::write(outer.path().join("pkg_1.0-1.dsc"), "no").unwrap();
|
||||||
|
|
||||||
|
let dest = tempfile::tempdir().unwrap();
|
||||||
|
link_orig_tarballs(&root, dest.path());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dest.path()
|
||||||
|
.join("pkg_1.0.orig.tar.xz")
|
||||||
|
.symlink_metadata()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dest.path()
|
||||||
|
.join("pkg_1.0.orig-data.tar.gz")
|
||||||
|
.symlink_metadata()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(!dest.path().join("unrelated_1.0.orig.tar.xz").exists());
|
||||||
|
// The dsc is not an orig tarball and must not be linked.
|
||||||
|
assert!(!dest.path().join("pkg_1.0-1.dsc").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
+514
-228
@@ -3,12 +3,9 @@ use std::io::Write;
|
|||||||
|
|
||||||
extern crate clap;
|
extern crate clap;
|
||||||
use clap::{Command, arg, command};
|
use clap::{Command, arg, command};
|
||||||
use pkh::context::ContextConfig;
|
|
||||||
|
|
||||||
extern crate flate2;
|
extern crate flate2;
|
||||||
|
|
||||||
use pkh::changelog::generate_entry;
|
|
||||||
|
|
||||||
use indicatif_log_bridge::LogWrapper;
|
use indicatif_log_bridge::LogWrapper;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
|
|
||||||
@@ -23,6 +20,155 @@ fn current_dir_or_exit() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CLI-side Ctrl+C wiring. The passive state (interrupted flag, cleanup
|
||||||
|
/// hook registry, reporter slot) lives in `pkh::interrupt`; everything that
|
||||||
|
/// installs, prints or exits lives here: the SIGINT handler only wakes a
|
||||||
|
/// watchdog through a self-pipe (async-signal-safe), and the watchdog runs
|
||||||
|
/// the whole shutdown in thread context — the live view's reporter clears
|
||||||
|
/// the terminal, the notice is printed, further Ctrl+C is absorbed as a
|
||||||
|
/// no-op, the cleanup hooks release their resources, and the process exits
|
||||||
|
/// with the conventional status 130, skipping destructors.
|
||||||
|
mod interrupt {
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||||
|
|
||||||
|
/// Whether the interrupt notice has been shown; the first caller prints
|
||||||
|
/// it, later ones stay silent
|
||||||
|
static NOTICE_SHOWN: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Whether handler, self-pipe and watchdog are in place
|
||||||
|
static INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Write end of the self-pipe the signal handler wakes the watchdog
|
||||||
|
/// through; `-1` until [`install`] set it up
|
||||||
|
static SELF_PIPE_WRITE: AtomicI32 = AtomicI32::new(-1);
|
||||||
|
|
||||||
|
/// Install the process-global Ctrl+C (SIGINT) handler; idempotent.
|
||||||
|
///
|
||||||
|
/// When the self-pipe or the watchdog cannot be set up, the default
|
||||||
|
/// SIGINT disposition is kept (the process dies immediately) rather
|
||||||
|
/// than installing a handler that could not run the shutdown.
|
||||||
|
pub fn install() {
|
||||||
|
if INSTALLED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fds = [0 as libc::c_int; 2];
|
||||||
|
// SAFETY: pipe(2) into a two-element array we own
|
||||||
|
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
|
||||||
|
INSTALLED.store(false, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (read_fd, write_fd) = (fds[0], fds[1]);
|
||||||
|
|
||||||
|
// The write end is used from the signal handler: non-blocking, so
|
||||||
|
// even a full pipe degrades to a dropped wake-up instead of
|
||||||
|
// blocking the handler.
|
||||||
|
// SAFETY: fcntl(2) on a pipe file descriptor we just created
|
||||||
|
unsafe {
|
||||||
|
let flags = libc::fcntl(write_fd, libc::F_GETFL);
|
||||||
|
libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||||
|
}
|
||||||
|
SELF_PIPE_WRITE.store(write_fd, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pkh-interrupt".to_string())
|
||||||
|
.spawn(move || watchdog(read_fd));
|
||||||
|
if spawned.is_err() {
|
||||||
|
// SAFETY: closing pipe file descriptors we just created
|
||||||
|
unsafe {
|
||||||
|
libc::close(read_fd);
|
||||||
|
libc::close(write_fd);
|
||||||
|
}
|
||||||
|
SELF_PIPE_WRITE.store(-1, Ordering::SeqCst);
|
||||||
|
INSTALLED.store(false, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: installing a signal handler whose body only records the
|
||||||
|
// interruption and writes to the self-pipe (async-signal-safe)
|
||||||
|
unsafe {
|
||||||
|
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Never returns: park the calling thread until the watchdog exits the
|
||||||
|
/// process
|
||||||
|
///
|
||||||
|
/// The watchdog owns the interrupt shutdown; a caller that would
|
||||||
|
/// otherwise reach its own `std::process::exit` and kill the process
|
||||||
|
/// mid-cleanup must park here instead.
|
||||||
|
pub fn wait_for_shutdown() -> ! {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal handler body: record the interruption and wake the watchdog
|
||||||
|
/// through the self-pipe
|
||||||
|
extern "C" fn on_sigint(_sig: libc::c_int) {
|
||||||
|
pkh::interrupt::mark_interrupted();
|
||||||
|
let fd = SELF_PIPE_WRITE.load(Ordering::SeqCst);
|
||||||
|
if fd >= 0 {
|
||||||
|
// SAFETY: write(2) of one byte to the self-pipe is
|
||||||
|
// async-signal-safe; a failed write (e.g. EAGAIN) drops the
|
||||||
|
// wake-up instead of blocking the handler
|
||||||
|
unsafe {
|
||||||
|
libc::write(fd, b"x".as_ptr().cast(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watchdog body: block until the signal handler's byte arrives, then
|
||||||
|
/// run the shutdown sequence
|
||||||
|
fn watchdog(read_fd: libc::c_int) {
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
// SAFETY: read(2) into a local buffer of the announced length
|
||||||
|
let received = unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), 1) };
|
||||||
|
// The write end is never closed, so a short read cannot happen in
|
||||||
|
// practice; on error there is nothing to clean up either way.
|
||||||
|
if received > 0 {
|
||||||
|
run_interrupt_sequence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reporter, notice, cleanup hooks, exit 130: the whole shutdown, run
|
||||||
|
/// in the watchdog thread immediately on Ctrl+C — never in the signal
|
||||||
|
/// handler itself
|
||||||
|
///
|
||||||
|
/// Further Ctrl+C while this runs writes bytes nobody reads: absorbed
|
||||||
|
/// as a no-op (send SIGTERM/SIGKILL if a hook ever hangs).
|
||||||
|
fn run_interrupt_sequence() {
|
||||||
|
let hint =
|
||||||
|
pkh::interrupt::take_reporter().and_then(|report| {
|
||||||
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(report)) {
|
||||||
|
Ok(hint) => hint,
|
||||||
|
Err(_) => {
|
||||||
|
log::error!("Interrupt reporter panicked");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
show_notice();
|
||||||
|
if let Some(hint) = hint {
|
||||||
|
eprintln!("{hint}");
|
||||||
|
}
|
||||||
|
pkh::interrupt::run_cleanup_hooks();
|
||||||
|
// SAFETY: raw exit bypassing destructors, intended at interrupt time
|
||||||
|
unsafe {
|
||||||
|
libc::_exit(130);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the interrupt notice, once per process: the first caller
|
||||||
|
/// prints it, later ones stay silent
|
||||||
|
fn show_notice() {
|
||||||
|
if NOTICE_SHOWN.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!("CTRL+C: Build interrupted by user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
let logger =
|
let logger =
|
||||||
@@ -34,7 +180,6 @@ fn main() {
|
|||||||
LogWrapper::new(multi.clone(), logger).try_init().unwrap();
|
LogWrapper::new(multi.clone(), logger).try_init().unwrap();
|
||||||
let matches = command!()
|
let matches = command!()
|
||||||
.subcommand_required(true)
|
.subcommand_required(true)
|
||||||
.disable_version_flag(true)
|
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("new")
|
Command::new("new")
|
||||||
.about("Scaffold a new Debian source package (buildable right away)")
|
.about("Scaffold a new Debian source package (buildable right away)")
|
||||||
@@ -178,13 +323,20 @@ fn main() {
|
|||||||
Command::new("chlog")
|
Command::new("chlog")
|
||||||
.about("Auto-generate changelog entry, editing it, committing it afterwards")
|
.about("Auto-generate changelog entry, editing it, committing it afterwards")
|
||||||
.arg(arg!(-s --series <series> "Target distribution series").required(false))
|
.arg(arg!(-s --series <series> "Target distribution series").required(false))
|
||||||
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
.arg(arg!(--backport "Number the entry as a backport of the target series (Debian: 1.0-1 becomes 1.0-1~bpo12+1; Ubuntu: 3.1-1ubuntu2 becomes 3.1-1ubuntu2~24.04.1)").required(false)
|
||||||
.arg(arg!(-v --version <version> "Target version").required(false)),
|
.conflicts_with_all(["nmu", "rebuild"]))
|
||||||
|
.arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false)
|
||||||
|
.conflicts_with("rebuild"))
|
||||||
|
.arg(arg!(--rebuild "Number the entry as a no-change rebuild (1.0-1 becomes 1.0-1build1)").required(false))
|
||||||
|
.arg(arg!(-v --version <version> "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false)),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("build")
|
Command::new("build")
|
||||||
.about("Build the source package (into a .dsc)")
|
.about("Build the source package (into a .dsc)")
|
||||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false))
|
||||||
|
.arg(arg!(--orig <when> "Original source tarball in the upload [auto, always, never] (default: auto)").required(false)
|
||||||
|
.long_help("Whether the upload distributes the original source tarball(s), like dpkg-genchanges' -sa/-si/-sd source styles.\nauto: include them only when the upstream version changed since the previous changelog entry (the default);\nalways: force inclusion, even for a revision bump of the same upstream version;\nnever: never include them, even for a new upstream version.\nAn explicit value is ignored with a warning for native packages (they have no separate orig tarball).")
|
||||||
|
.value_parser(["auto", "always", "never"])),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("put")
|
Command::new("put")
|
||||||
@@ -213,31 +365,61 @@ fn main() {
|
|||||||
.long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")),
|
.long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("context")
|
Command::new("lint")
|
||||||
.about("Manage contexts")
|
.about("Lint the package (lintian wrapper + pkh-native checks)")
|
||||||
.subcommand_required(true)
|
.arg(arg!([path] "Source tree to lint (default: the current directory)").required(false))
|
||||||
.subcommand(
|
.arg(arg!(-d --dist <dist> "Target distribution (debian, ubuntu)").required(false))
|
||||||
Command::new("create")
|
.arg(arg!(-s --series <series> "Target distribution series").required(false))
|
||||||
.about("Create a new context")
|
.arg(arg!(--native "Run pkh-native checks only, without the lintian wrapper").required(false))
|
||||||
.arg(arg!(<name> "Context name"))
|
.arg(arg!(--info "Show tag explanations under each finding").required(false))
|
||||||
.arg(arg!(--type <type> "Context type: ssh (only type supported for now)"))
|
.arg(
|
||||||
.arg(arg!(--endpoint <endpoint> "Context endpoint (for example: ssh://user@host:port)"))
|
clap::Arg::new("display_info")
|
||||||
|
.long("display-info")
|
||||||
|
.action(clap::ArgAction::SetTrue)
|
||||||
|
.help("Also display info-level tags (I:)"),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.arg(arg!(--pedantic "Also display pedantic tags (P:)").required(false))
|
||||||
Command::new("rm")
|
.arg(arg!(--experimental "Also display experimental tags (X:)").required(false))
|
||||||
.about("Remove a context")
|
.arg(
|
||||||
.arg(arg!(<name> "Context name"))
|
clap::Arg::new("show_overrides")
|
||||||
|
.long("show-overrides")
|
||||||
|
.action(clap::ArgAction::SetTrue)
|
||||||
|
.help("Also display overridden tags (O:)"),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.arg(
|
||||||
Command::new("ls")
|
clap::Arg::new("fail_on")
|
||||||
.about("List contexts")
|
.long("fail-on")
|
||||||
|
.value_name("LEVELS")
|
||||||
|
.help("Comma-separated severities failing the run: error, warning, info, pedantic, experimental, override (default: error)"),
|
||||||
)
|
)
|
||||||
.subcommand(Command::new("show").about("Show current context"))
|
.arg(
|
||||||
.subcommand(
|
clap::Arg::new("suppress_tags")
|
||||||
Command::new("use")
|
.long("suppress-tags")
|
||||||
.about("Set current context")
|
.value_name("LIST")
|
||||||
.arg(arg!(<name> "Context name"))
|
.help("Comma-separated tag names to ignore for this run"),
|
||||||
)
|
)
|
||||||
|
.arg(
|
||||||
|
clap::Arg::new("check")
|
||||||
|
.long("check")
|
||||||
|
.value_name("NAME")
|
||||||
|
.action(clap::ArgAction::Append)
|
||||||
|
.help("Run only this pkh-native check (can be specified multiple times)"),
|
||||||
|
)
|
||||||
|
.arg(arg!(--repack "Ignore existing pkh build output and pack the tree fresh for linting").required(false))
|
||||||
|
.arg(arg!(--json "Emit the report as JSON").required(false))
|
||||||
|
.arg(
|
||||||
|
clap::Arg::new("color")
|
||||||
|
.long("color")
|
||||||
|
.value_name("WHEN")
|
||||||
|
.value_parser(["auto", "always", "never"])
|
||||||
|
.help("Colorize the report: auto, always or never (default: auto)"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
clap::Arg::new("list_tags")
|
||||||
|
.long("list-tags")
|
||||||
|
.action(clap::ArgAction::SetTrue)
|
||||||
|
.help("Print the pkh-native tag catalog and exit"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("prune")
|
Command::new("prune")
|
||||||
@@ -318,10 +500,14 @@ fn main() {
|
|||||||
// the structural self-checks inside `scaffold` always run), with
|
// the structural self-checks inside `scaffold` always run), with
|
||||||
// the scaffold outcome (e.g. a failed vendoring) shaping the
|
// the scaffold outcome (e.g. a failed vendoring) shaping the
|
||||||
// offer.
|
// offer.
|
||||||
|
let prompter = pkh::ui::prompt::TerminalPrompter;
|
||||||
if let Err(e) = rt.block_on(async {
|
if let Err(e) = rt.block_on(async {
|
||||||
let opts = pkh::new::questions::run(cli).await?;
|
let opts = pkh::new::questions::run(cli, &prompter).await?;
|
||||||
let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
|
let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
|
||||||
pkh::new::questions::offer_verification(&opts, &outcome, &multi, no_verify).await;
|
pkh::new::questions::offer_verification(
|
||||||
|
&opts, &outcome, &multi, no_verify, &prompter,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok::<(), Box<dyn std::error::Error>>(())
|
Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
}) {
|
}) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
@@ -346,15 +532,14 @@ fn main() {
|
|||||||
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
|
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
|
||||||
|
|
||||||
// Convert PPA to base URL if provided
|
// Convert PPA to base URL if provided
|
||||||
let base_url = ppa.map(|ppa_str| {
|
let base_url = match ppa.map(pkh::package_info::split_ppa) {
|
||||||
// PPA format: user/ppa_name
|
Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)),
|
||||||
let parts: Vec<&str> = ppa_str.split('/').collect();
|
Some(Err(e)) => {
|
||||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
error!("{e}");
|
||||||
error!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
pkh::package_info::ppa_to_base_url(parts[0], parts[1])
|
None => None,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Since pull is async, we need to block on it
|
// Since pull is async, we need to block on it
|
||||||
if let Err(e) = rt.block_on(async {
|
if let Err(e) = rt.block_on(async {
|
||||||
@@ -383,79 +568,74 @@ fn main() {
|
|||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
||||||
let cli_series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
let cli_series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
||||||
|
let kind = if sub_matches
|
||||||
|
.get_one::<bool>("backport")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
pkh::changelog::EntryKind::Backport
|
||||||
|
} else if sub_matches.get_one::<bool>("nmu").copied().unwrap_or(false) {
|
||||||
|
pkh::changelog::EntryKind::Nmu
|
||||||
|
} else if sub_matches
|
||||||
|
.get_one::<bool>("rebuild")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
pkh::changelog::EntryKind::Rebuild
|
||||||
|
} else {
|
||||||
|
pkh::changelog::EntryKind::Normal
|
||||||
|
};
|
||||||
|
|
||||||
// Determine target series: CLI flag > interactive selector > current changelog series
|
// Determine target series: CLI flag > interactive selector > current changelog series
|
||||||
let target_series = if let Some(s) = cli_series {
|
let target_series = if let Some(s) = cli_series {
|
||||||
Some(s.to_string())
|
Some(s.to_string())
|
||||||
} else {
|
} else {
|
||||||
// Parse current changelog to determine the default series
|
|
||||||
let changelog_path = cwd.join("debian/changelog");
|
let changelog_path = cwd.join("debian/changelog");
|
||||||
match pkh::changelog::parse_changelog_header(&changelog_path) {
|
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
|
||||||
Ok((_pkg, _ver, current_series)) => {
|
Some(pkh::changelog::SeriesCandidates::Choose {
|
||||||
// UNRELEASED is not a real series: offer it as a
|
options,
|
||||||
// pinned first entry (selecting it keeps the changelog
|
values,
|
||||||
// unreleased) on top of the current vendor's series
|
default,
|
||||||
// list, defaulting to the development series. Any
|
fallback,
|
||||||
// other series resolves through the series list of
|
}) => match pkh::ui::select_series(&options, &default) {
|
||||||
// its own distribution.
|
Ok(selected) => {
|
||||||
match rt.block_on(async {
|
Some(pkh::changelog::selected_series(&options, &values, selected))
|
||||||
if pkh::distro_info::is_unreleased(¤t_series) {
|
|
||||||
// Vendors keep original casing ("Ubuntu"),
|
|
||||||
// while the series data keys are lowercase
|
|
||||||
let dist = pkh::build::env::current_vendor().to_lowercase();
|
|
||||||
let mut series_list =
|
|
||||||
vec![pkh::distro_info::UNRELEASED.to_string()];
|
|
||||||
series_list.extend(
|
|
||||||
pkh::distro_info::get_ordered_series_name(&dist).await?,
|
|
||||||
);
|
|
||||||
Ok(series_list)
|
|
||||||
} else {
|
|
||||||
let dist =
|
|
||||||
pkh::distro_info::get_dist_from_series(¤t_series).await?;
|
|
||||||
pkh::distro_info::get_ordered_series_name(&dist).await
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Ok(series_list) => {
|
|
||||||
// Default to the development series (the
|
|
||||||
// first real entry) when the changelog is
|
|
||||||
// UNRELEASED, not to the pinned entry itself
|
|
||||||
let default = if pkh::distro_info::is_unreleased(¤t_series)
|
|
||||||
&& series_list.len() > 1
|
|
||||||
{
|
|
||||||
series_list[1].clone()
|
|
||||||
} else {
|
|
||||||
current_series.clone()
|
|
||||||
};
|
|
||||||
match pkh::ui::select_series(&series_list, &default) {
|
|
||||||
Ok(selected) => Some(selected),
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"Series selection failed: {}. Using current series '{}' instead.",
|
|
||||||
e, current_series
|
|
||||||
);
|
|
||||||
Some(current_series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Could not fetch series list, use current series as default
|
|
||||||
Some(current_series)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(_) => None,
|
error!(
|
||||||
|
"Series selection failed: {}. Using current series '{}' instead.",
|
||||||
|
e, fallback
|
||||||
|
);
|
||||||
|
Some(fallback)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Could not fetch the series list: use the current series
|
||||||
|
Some(pkh::changelog::SeriesCandidates::Keep(current)) => Some(current),
|
||||||
|
// No parsable changelog: leave the series decision to
|
||||||
|
// generate_entry
|
||||||
|
None => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = generate_entry(
|
let entry = match rt.block_on(pkh::changelog::generate_entry(
|
||||||
"debian/changelog",
|
"debian/changelog",
|
||||||
Some(&cwd),
|
Some(&cwd),
|
||||||
version,
|
version,
|
||||||
target_series.as_deref(),
|
target_series.as_deref(),
|
||||||
) {
|
kind,
|
||||||
error!("{}", e);
|
)) {
|
||||||
std::process::exit(1);
|
Ok(entry) => entry,
|
||||||
}
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"Found package: {}, version: {}",
|
||||||
|
entry.package, entry.previous_version
|
||||||
|
);
|
||||||
|
println!("New version: {}", entry.new_version);
|
||||||
|
println!("Added new changelog entry to {}", entry.path.display());
|
||||||
|
|
||||||
let editor = match std::env::var("EDITOR") {
|
let editor = match std::env::var("EDITOR") {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
@@ -482,33 +662,84 @@ fn main() {
|
|||||||
}
|
}
|
||||||
Some(("build", sub_matches)) => {
|
Some(("build", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
|
interrupt::install();
|
||||||
let verbose = sub_matches
|
let verbose = sub_matches
|
||||||
.get_one::<bool>("verbose")
|
.get_one::<bool>("verbose")
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Live build view: disabled by --verbose or when stdout is not a
|
// Live build view, unless --verbose (DebUi additionally disables
|
||||||
// terminal (DebUi handles the non-TTY case itself)
|
// itself when stdout is not a terminal)
|
||||||
let ui = if verbose {
|
let quiet = pkh::report::Quiet;
|
||||||
|
let live = if verbose {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
Some(pkh::ui::deb::DebUi::new(&multi))
|
||||||
|
};
|
||||||
|
let view: &dyn pkh::report::BuildView = live
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v as &dyn pkh::report::BuildView)
|
||||||
|
.unwrap_or(&quiet);
|
||||||
|
let prompter = pkh::ui::prompt::TerminalPrompter;
|
||||||
|
|
||||||
|
let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) {
|
||||||
|
Some("always") => pkh::build::OrigSourceMode::Always,
|
||||||
|
Some("never") => pkh::build::OrigSourceMode::Never,
|
||||||
|
_ => pkh::build::OrigSourceMode::Auto,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) {
|
match pkh::build::build_source_package(pkh::build::BuildSourceOptions {
|
||||||
error!("{}", e);
|
source: Some(cwd),
|
||||||
// Unmet build dependencies/conflicts exit with status 3,
|
options: pkh::build::SourceBuildOptions {
|
||||||
// like dpkg-buildpackage does.
|
orig_source,
|
||||||
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
..Default::default()
|
||||||
.is_some()
|
},
|
||||||
{
|
view,
|
||||||
std::process::exit(3);
|
prompter: &prompter,
|
||||||
|
}) {
|
||||||
|
Ok(output) => {
|
||||||
|
// The live view lists the artifacts itself when it
|
||||||
|
// renders; otherwise (verbose mode or non-TTY stdout)
|
||||||
|
// print them as plain lines.
|
||||||
|
if !view.is_enabled() {
|
||||||
|
for artifact in output.artifacts() {
|
||||||
|
println!(" {}", pkh::report::display_path(&artifact));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.signed {
|
||||||
|
println!("Package built and signed successfully!");
|
||||||
|
} else {
|
||||||
|
println!("Package built successfully (unsigned).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// On Ctrl+C the interrupt watchdog owns the shutdown
|
||||||
|
// (see `pkh deb`): park here instead of racing it
|
||||||
|
if pkh::interrupt::interrupted() {
|
||||||
|
interrupt::wait_for_shutdown();
|
||||||
|
}
|
||||||
|
// The unmet-dependency diagnostics first, then the
|
||||||
|
// summary: the exact rendering the flow used to do.
|
||||||
|
if let Some(unmet) =
|
||||||
|
e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
||||||
|
{
|
||||||
|
eprintln!("{}", unmet.0.message());
|
||||||
|
}
|
||||||
|
error!("{}", e);
|
||||||
|
// Unmet build dependencies/conflicts exit with status 3,
|
||||||
|
// like dpkg-buildpackage does.
|
||||||
|
if e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
std::process::exit(3);
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(("put", sub_matches)) => {
|
Some(("put", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
|
interrupt::install();
|
||||||
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
|
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
|
||||||
let changes = sub_matches
|
let changes = sub_matches
|
||||||
.get_one::<String>("changes")
|
.get_one::<String>("changes")
|
||||||
@@ -527,41 +758,48 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let view = pkh::ui::deb::DebUi::new(&multi);
|
||||||
|
let prompter = pkh::ui::prompt::TerminalPrompter;
|
||||||
let options = pkh::put::PutOptions {
|
let options = pkh::put::PutOptions {
|
||||||
ppa: ppa.to_string(),
|
ppa: ppa.to_string(),
|
||||||
changes,
|
changes,
|
||||||
force,
|
force,
|
||||||
cwd,
|
cwd,
|
||||||
|
view: &view,
|
||||||
|
prompter: &prompter,
|
||||||
};
|
};
|
||||||
if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) {
|
if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) {
|
||||||
|
// On Ctrl+C the interrupt watchdog owns the shutdown (see
|
||||||
|
// `pkh deb`): park here instead of racing it
|
||||||
|
if pkh::interrupt::interrupted() {
|
||||||
|
interrupt::wait_for_shutdown();
|
||||||
|
}
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(("deb", sub_matches)) => {
|
Some(("deb", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
// Ctrl+C during the build must say what happened and release the
|
||||||
let pocket = sub_matches.get_one::<String>("pocket").map(|s| s.as_str());
|
// ephemeral chroot instead of dying on the default disposition.
|
||||||
let arch = sub_matches.get_one::<String>("arch").map(|s| s.as_str());
|
// The live view (when enabled) registers its own reporter on top
|
||||||
let cross = sub_matches.get_one::<bool>("cross").unwrap_or(&false);
|
// of this to clear the widget first.
|
||||||
let ppa: Vec<&str> = sub_matches
|
interrupt::install();
|
||||||
|
let series = sub_matches.get_one::<String>("series").cloned();
|
||||||
|
let pocket = sub_matches.get_one::<String>("pocket").cloned();
|
||||||
|
let arch = sub_matches.get_one::<String>("arch").cloned();
|
||||||
|
let cross = sub_matches
|
||||||
|
.get_one::<bool>("cross")
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
let ppa: Vec<String> = sub_matches
|
||||||
.get_many::<String>("ppa")
|
.get_many::<String>("ppa")
|
||||||
.map(|v| v.map(|s| s.as_str()).collect())
|
.map(|v| v.cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let ppa = if ppa.is_empty() {
|
let inject: Vec<String> = sub_matches
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(ppa.as_slice())
|
|
||||||
};
|
|
||||||
let inject_packages: Vec<&str> = sub_matches
|
|
||||||
.get_many::<String>("inject")
|
.get_many::<String>("inject")
|
||||||
.map(|v| v.map(|s| s.as_str()).collect())
|
.map(|v| v.cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let inject_packages = if inject_packages.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(inject_packages.as_slice())
|
|
||||||
};
|
|
||||||
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
|
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
|
||||||
let mode: Option<pkh::deb::BuildMode> = match mode {
|
let mode: Option<pkh::deb::BuildMode> = match mode {
|
||||||
Some("local") => Some(pkh::deb::BuildMode::Local),
|
Some("local") => Some(pkh::deb::BuildMode::Local),
|
||||||
@@ -580,127 +818,51 @@ fn main() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live build view: disabled by --verbose or when stdout is not a
|
// Live build view, unless --verbose (DebUi additionally disables
|
||||||
// terminal (DebUi handles the non-TTY case itself)
|
// itself when stdout is not a terminal)
|
||||||
let ui = if verbose {
|
let quiet = pkh::report::Quiet;
|
||||||
|
let live = if verbose {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
Some(pkh::ui::deb::DebUi::new(&multi))
|
||||||
};
|
};
|
||||||
|
let view: &dyn pkh::report::BuildView = live
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v as &dyn pkh::report::BuildView)
|
||||||
|
.unwrap_or(&quiet);
|
||||||
|
|
||||||
let result = rt.block_on(async {
|
let result = rt.block_on(async {
|
||||||
pkh::deb::build_binary_package(
|
pkh::deb::build_binary_package(pkh::deb::DebBuildOptions {
|
||||||
arch,
|
arch,
|
||||||
series,
|
series,
|
||||||
pocket,
|
pocket,
|
||||||
Some(cwd.as_path()),
|
cwd: Some(cwd.clone()),
|
||||||
*cross,
|
cross,
|
||||||
mode,
|
mode,
|
||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject,
|
||||||
None,
|
|
||||||
ui.clone(),
|
|
||||||
jobs,
|
jobs,
|
||||||
)
|
view,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(artifacts) => {
|
Ok(_) => info!("Done."),
|
||||||
let _ = artifacts;
|
|
||||||
info!("Done.");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// On Ctrl+C the interrupt watchdog owns the shutdown: it
|
||||||
|
// has already shown the notice, is releasing the build
|
||||||
|
// resources, and will exit with 130 — park here instead
|
||||||
|
// of racing it with another exit.
|
||||||
|
if pkh::interrupt::interrupted() {
|
||||||
|
interrupt::wait_for_shutdown();
|
||||||
|
}
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(("context", sub_matches)) => {
|
|
||||||
let mgr = pkh::context::manager();
|
|
||||||
|
|
||||||
match sub_matches.subcommand() {
|
|
||||||
Some(("create", args)) => {
|
|
||||||
let name = args.get_one::<String>("name").unwrap();
|
|
||||||
let type_str = args
|
|
||||||
.get_one::<String>("type")
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("local");
|
|
||||||
|
|
||||||
let context = match type_str {
|
|
||||||
"local" => ContextConfig::Local,
|
|
||||||
"ssh" => {
|
|
||||||
let endpoint =
|
|
||||||
args.get_one::<String>("endpoint").unwrap_or_else(|| {
|
|
||||||
error!(
|
|
||||||
"An --endpoint is required to create an ssh context. \
|
|
||||||
Expected format: [ssh://][user@]host[:port]"
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse host, user, port from endpoint
|
|
||||||
// Formats: [ssh://][user@]host[:port]
|
|
||||||
let endpoint_re = regex::Regex::new(r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$").unwrap();
|
|
||||||
let endpoint_cap = endpoint_re.captures(endpoint).unwrap_or_else(|| {
|
|
||||||
error!("Invalid endpoint format: '{}'. Expected [ssh://][user@]host[:port]", endpoint);
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
let host = endpoint_cap.name("host").unwrap().as_str().to_string();
|
|
||||||
let user = endpoint_cap.name("user").map(|m| m.as_str().to_string());
|
|
||||||
let port = endpoint_cap.name("port").map(|m| {
|
|
||||||
m.as_str().parse::<u16>().unwrap_or_else(|_| {
|
|
||||||
error!("Invalid port number");
|
|
||||||
std::process::exit(1);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
ContextConfig::Ssh { host, user, port }
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
error!("Unknown context type: {}", type_str);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = mgr.add_context(name, context) {
|
|
||||||
error!("Failed to create context: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
info!("Context '{}' created.", name);
|
|
||||||
}
|
|
||||||
Some(("rm", args)) => {
|
|
||||||
let name = args.get_one::<String>("name").unwrap();
|
|
||||||
if let Err(e) = mgr.remove_context(name) {
|
|
||||||
error!("Failed to remove context: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
info!("Context '{}' removed.", name);
|
|
||||||
}
|
|
||||||
Some(("ls", _)) => {
|
|
||||||
let contexts = mgr.list_contexts();
|
|
||||||
let current = mgr.current_name();
|
|
||||||
for ctx in contexts {
|
|
||||||
if ctx == current {
|
|
||||||
println!("* {}", ctx);
|
|
||||||
} else {
|
|
||||||
println!(" {}", ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(("show", _)) => {}
|
|
||||||
Some(("use", args)) => {
|
|
||||||
let name = args.get_one::<String>("name").unwrap();
|
|
||||||
if let Err(e) = mgr.set_current(name) {
|
|
||||||
error!("Failed to set context: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
info!("Switched to context '{}'.", name);
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(("prune", sub_matches)) => {
|
Some(("prune", sub_matches)) => {
|
||||||
let dry_run = sub_matches
|
let dry_run = sub_matches
|
||||||
.get_one::<bool>("dry_run")
|
.get_one::<bool>("dry_run")
|
||||||
@@ -753,6 +915,130 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(("lint", sub_matches)) => {
|
||||||
|
if sub_matches.get_flag("list_tags") {
|
||||||
|
print!("{}", pkh::lint::list_tags());
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = sub_matches
|
||||||
|
.get_one::<String>("path")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(current_dir_or_exit);
|
||||||
|
let fail_on = match pkh::lint::output::parse_fail_on(
|
||||||
|
sub_matches
|
||||||
|
.get_one::<String>("fail_on")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("error"),
|
||||||
|
) {
|
||||||
|
Ok(levels) => levels,
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let options = pkh::lint::LintOptions {
|
||||||
|
path,
|
||||||
|
native: sub_matches.get_flag("native"),
|
||||||
|
fail_on,
|
||||||
|
info: sub_matches.get_flag("info"),
|
||||||
|
display_info: sub_matches.get_flag("display_info"),
|
||||||
|
pedantic: sub_matches.get_flag("pedantic"),
|
||||||
|
experimental: sub_matches.get_flag("experimental"),
|
||||||
|
show_overrides: sub_matches.get_flag("show_overrides"),
|
||||||
|
suppress_tags: sub_matches
|
||||||
|
.get_one::<String>("suppress_tags")
|
||||||
|
.map(|list| {
|
||||||
|
list.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|tag| !tag.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
only_checks: sub_matches
|
||||||
|
.get_many::<String>("check")
|
||||||
|
.map(|values| values.cloned().collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
repack: sub_matches.get_flag("repack"),
|
||||||
|
json: sub_matches.get_flag("json"),
|
||||||
|
color: match sub_matches.get_one::<String>("color").map(String::as_str) {
|
||||||
|
Some("always") => pkh::lint::output::ColorMode::Always,
|
||||||
|
Some("never") => pkh::lint::output::ColorMode::Never,
|
||||||
|
_ => pkh::lint::output::ColorMode::Auto,
|
||||||
|
},
|
||||||
|
dist: sub_matches.get_one::<String>("dist").cloned(),
|
||||||
|
series: sub_matches.get_one::<String>("series").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match pkh::lint::run(&options) {
|
||||||
|
Ok(report) => {
|
||||||
|
if options.json {
|
||||||
|
println!("{}", pkh::lint::output::render_json(&report, &options));
|
||||||
|
} else {
|
||||||
|
print!("{}", pkh::lint::output::render_text(&report, &options));
|
||||||
|
}
|
||||||
|
std::process::exit(pkh::lint::output::exit_code(&report, &options));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{}", e);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
|
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::interrupt;
|
||||||
|
|
||||||
|
/// End-to-end check of the whole sequence: installed handler → self-pipe
|
||||||
|
/// → watchdog → notice + hooks → exit status 130. The sequence ends in
|
||||||
|
/// `libc::_exit`, so it cannot be exercised in-process: this test
|
||||||
|
/// re-spawns the test binary in child mode (env var), where the test
|
||||||
|
/// installs the handler and raises SIGINT at itself.
|
||||||
|
#[test]
|
||||||
|
fn sigint_sequence_prints_the_notice_and_exits_130() {
|
||||||
|
const CHILD_ENV: &str = "PKH_SIGINT_TEST_CHILD";
|
||||||
|
if std::env::var(CHILD_ENV).is_ok() {
|
||||||
|
// Child mode: install, register a pending hook, then interrupt
|
||||||
|
// ourselves. If the sequence never runs, the sleep below turns
|
||||||
|
// the failure into a wrong (zero) exit code instead of a hang.
|
||||||
|
interrupt::install();
|
||||||
|
// Alive until the watchdog drains it: a dropped guard would
|
||||||
|
// deregister the hook and the drain would run empty
|
||||||
|
let _hook = pkh::interrupt::register_cleanup_hook(Box::new(|| ()));
|
||||||
|
// SAFETY: kill(2) to our own process with SIGINT
|
||||||
|
unsafe {
|
||||||
|
libc::kill(libc::getpid(), libc::SIGINT);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe = std::env::current_exe().expect("locate the test executable");
|
||||||
|
let output = std::process::Command::new(exe)
|
||||||
|
// --nocapture: libtest's capture buffer would otherwise swallow
|
||||||
|
// the watchdog's notice (threads spawned during a test inherit
|
||||||
|
// the capture), and the process exits before the harness prints
|
||||||
|
// anything it captured
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"tests::sigint_sequence_prints_the_notice_and_exits_130",
|
||||||
|
"--test-threads=1",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env(CHILD_ENV, "1")
|
||||||
|
.output()
|
||||||
|
.expect("re-spawn the test binary");
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert_eq!(output.status.code(), Some(130), "child stderr:\n{stderr}");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("CTRL+C: Build interrupted by user."),
|
||||||
|
"child stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-12
@@ -51,7 +51,7 @@ pub fn orig_tarball_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render every common `debian/` file of the package.
|
/// Render every common `debian/` file of the package.
|
||||||
pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
|
pub fn files(opts: &NewOptions, template: &Template) -> Vec<OutputFile> {
|
||||||
let mut files = vec![
|
let mut files = vec![
|
||||||
source_format(opts),
|
source_format(opts),
|
||||||
changelog(opts),
|
changelog(opts),
|
||||||
@@ -195,7 +195,7 @@ fn render_continuation_text(text: &str) -> String {
|
|||||||
/// comes from the template (`all` for shell/empty), and a non-empty
|
/// comes from the template (`all` for shell/empty), and a non-empty
|
||||||
/// `opts.depends` (the empty/metapackage flavor) lands in the binary
|
/// `opts.depends` (the empty/metapackage flavor) lands in the binary
|
||||||
/// stanza's `Depends` field.
|
/// stanza's `Depends` field.
|
||||||
fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
fn control(opts: &NewOptions, template: &Template) -> OutputFile {
|
||||||
let mut control = String::new();
|
let mut control = String::new();
|
||||||
|
|
||||||
// Source stanza.
|
// Source stanza.
|
||||||
@@ -238,7 +238,7 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
|||||||
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
||||||
/// template's dh line (plus the template's extra overrides, when any),
|
/// template's dh line (plus the template's extra overrides, when any),
|
||||||
/// written with the executable bit.
|
/// written with the executable bit.
|
||||||
fn rules(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
fn rules(opts: &NewOptions, template: &Template) -> OutputFile {
|
||||||
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
||||||
let extra = template.rules_extra(opts);
|
let extra = template.rules_extra(opts);
|
||||||
if !extra.is_empty() {
|
if !extra.is_empty() {
|
||||||
@@ -436,7 +436,7 @@ pub fn create_orig_tarball_excluding(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball {}",
|
"Created orig tarball {}",
|
||||||
crate::ui::display_path(&tarball_path)
|
crate::report::display_path(&tarball_path)
|
||||||
);
|
);
|
||||||
Ok(tarball_path)
|
Ok(tarball_path)
|
||||||
}
|
}
|
||||||
@@ -533,7 +533,7 @@ mod tests {
|
|||||||
fn opts() -> NewOptions {
|
fn opts() -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Shell,
|
template: TemplateId::SHELL,
|
||||||
source_dir: SourceDir::Skeleton,
|
source_dir: SourceDir::Skeleton,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -559,7 +559,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn source_format_and_local_options() {
|
fn source_format_and_local_options() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||||
let find = |path: &str| {
|
let find = |path: &str| {
|
||||||
files
|
files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -582,7 +582,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let files = super::files(
|
let files = super::files(
|
||||||
&native,
|
&native,
|
||||||
crate::new::templates::get(TemplateId::Shell).unwrap(),
|
crate::new::templates::get(TemplateId::SHELL).unwrap(),
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
files
|
files
|
||||||
@@ -642,7 +642,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn control_rendering_and_parse() {
|
fn control_rendering_and_parse() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||||
|
|
||||||
// RFC822 continuation: first dep on the field line, the rest indented.
|
// RFC822 continuation: first dep on the field line, the rest indented.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -679,7 +679,7 @@ mod tests {
|
|||||||
homepage: None,
|
homepage: None,
|
||||||
..opts()
|
..opts()
|
||||||
};
|
};
|
||||||
let control = super::control(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
let control = super::control(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||||
assert!(!control.contents.contains("Homepage:"));
|
assert!(!control.contents.contains("Homepage:"));
|
||||||
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
let parsed = crate::debian::ControlInfo::parse_content(&control.contents).unwrap();
|
||||||
assert!(parsed.source.get("Homepage").is_none());
|
assert!(parsed.source.get("Homepage").is_none());
|
||||||
@@ -688,7 +688,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rules_is_executable_minimal_makefile() {
|
fn rules_is_executable_minimal_makefile() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let rules = super::rules(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
let rules = super::rules(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||||
assert!(rules.executable);
|
assert!(rules.executable);
|
||||||
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||||
}
|
}
|
||||||
@@ -701,7 +701,7 @@ mod tests {
|
|||||||
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
let files = super::files(&o, crate::new::templates::get(TemplateId::SHELL).unwrap());
|
||||||
let find = |path: &str| {
|
let find = |path: &str| {
|
||||||
files
|
files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -731,7 +731,7 @@ mod tests {
|
|||||||
// Without the extras none of the files are rendered.
|
// Without the extras none of the files are rendered.
|
||||||
let plain = super::files(
|
let plain = super::files(
|
||||||
&opts(),
|
&opts(),
|
||||||
crate::new::templates::get(TemplateId::Shell).unwrap(),
|
crate::new::templates::get(TemplateId::SHELL).unwrap(),
|
||||||
);
|
);
|
||||||
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
||||||
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
||||||
|
|||||||
+61
-91
@@ -4,13 +4,16 @@
|
|||||||
//! The rule set is deliberately simple and table-driven (highest precedence
|
//! The rule set is deliberately simple and table-driven (highest precedence
|
||||||
//! first):
|
//! first):
|
||||||
//!
|
//!
|
||||||
//! 1. well-known build-system marker files at the top level of the
|
//! 1. the `detect.files` marker files declared by the template manifests
|
||||||
//! directory (`Cargo.toml`, `pyproject.toml`/`setup.py`/`setup.cfg`,
|
//! (`data/templates/<id>/manifest.yml`, in registry order: `Cargo.toml`,
|
||||||
//! `meson.build`, `CMakeLists.txt`, `configure.ac`, `go.mod`,
|
//! `pyproject.toml`/`setup.py`/`setup.cfg`, `meson.build`,
|
||||||
//! `Makefile`) — more than one distinct template matching is
|
//! `CMakeLists.txt`, `configure.ac`, `go.mod`, `Makefile`) looked for at
|
||||||
//! [`Detection::Ambiguous`],
|
//! the top level of the directory — more than one distinct template
|
||||||
|
//! matching is [`Detection::Ambiguous`]; templates without markers
|
||||||
|
//! (shell: the single-script heuristic below; empty: never detected)
|
||||||
|
//! declare none,
|
||||||
//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file
|
//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file
|
||||||
//! whose first line is a `#!` shebang) → [`TemplateId::Shell`],
|
//! whose first line is a `#!` shebang) → [`TemplateId::SHELL`],
|
||||||
//! several scripts or none → nothing,
|
//! several scripts or none → nothing,
|
||||||
//! 3. otherwise [`Detection::Empty`].
|
//! 3. otherwise [`Detection::Empty`].
|
||||||
//!
|
//!
|
||||||
@@ -23,7 +26,9 @@ use std::path::Path;
|
|||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
|
use super::licenses;
|
||||||
use super::options::TemplateId;
|
use super::options::TemplateId;
|
||||||
|
use super::templates;
|
||||||
|
|
||||||
/// Outcome of the detection.
|
/// Outcome of the detection.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -37,26 +42,18 @@ pub enum Detection {
|
|||||||
Empty,
|
Empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker files per template, in precedence order (see the module docs).
|
/// Detect the template matching the project in `dir`: the manifests'
|
||||||
const MARKERS: [(TemplateId, &[&str]); 7] = [
|
/// marker files in registry order (the detection priority), then the
|
||||||
(TemplateId::Rust, &["Cargo.toml"]),
|
/// shell single-script heuristic.
|
||||||
(
|
|
||||||
TemplateId::Python,
|
|
||||||
&["pyproject.toml", "setup.py", "setup.cfg"],
|
|
||||||
),
|
|
||||||
(TemplateId::Meson, &["meson.build"]),
|
|
||||||
(TemplateId::Cmake, &["CMakeLists.txt"]),
|
|
||||||
(TemplateId::Autotools, &["configure.ac"]),
|
|
||||||
(TemplateId::Go, &["go.mod"]),
|
|
||||||
(TemplateId::Makefile, &["Makefile"]),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Detect the template matching the project in `dir`.
|
|
||||||
pub fn detect(dir: &Path) -> Detection {
|
pub fn detect(dir: &Path) -> Detection {
|
||||||
let mut hits: Vec<TemplateId> = Vec::new();
|
let mut hits: Vec<TemplateId> = Vec::new();
|
||||||
for (id, markers) in MARKERS {
|
for template in templates::all() {
|
||||||
if markers.iter().any(|marker| dir.join(marker).exists()) && !hits.contains(&id) {
|
let markers = template.detect_files();
|
||||||
hits.push(id);
|
if !markers.is_empty()
|
||||||
|
&& markers.iter().any(|marker| dir.join(marker).exists())
|
||||||
|
&& !hits.contains(&template.id())
|
||||||
|
{
|
||||||
|
hits.push(template.id());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ pub fn detect(dir: &Path) -> Detection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if single_script(dir).is_some() {
|
if single_script(dir).is_some() {
|
||||||
Detection::Single(TemplateId::Shell)
|
Detection::Single(TemplateId::SHELL)
|
||||||
} else {
|
} else {
|
||||||
Detection::Empty
|
Detection::Empty
|
||||||
}
|
}
|
||||||
@@ -109,23 +106,14 @@ fn has_shebang(path: &Path) -> bool {
|
|||||||
content.starts_with(b"#!")
|
content.starts_with(b"#!")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// License files looked at by [`sniff_license`], in preference order.
|
|
||||||
const LICENSE_FILES: [&str; 5] = [
|
|
||||||
"LICENSE",
|
|
||||||
"LICENSE.md",
|
|
||||||
"LICENSE.txt",
|
|
||||||
"COPYING",
|
|
||||||
"COPYING.txt",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
|
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
|
||||||
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
|
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
|
||||||
/// matched against a short list of recognizable licenses (MIT, BSD-2/3,
|
/// matched against the marker sets of the bundled license table
|
||||||
/// Apache-2.0, GPL-2/3, LGPL-2.1/3, ISC). `None` when no license file
|
/// (`data/licenses.yml`: MIT, BSD-2/3, Apache-2.0, GPL-2/3, LGPL-2.1/3,
|
||||||
/// exists or nothing recognizable is found.
|
/// ISC). `None` when no license file exists or nothing recognizable is
|
||||||
|
/// found.
|
||||||
pub fn sniff_license(dir: &Path) -> Option<String> {
|
pub fn sniff_license(dir: &Path) -> Option<String> {
|
||||||
let content = LICENSE_FILES
|
let content = licenses::detect_files()
|
||||||
.iter()
|
|
||||||
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
|
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
|
||||||
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
|
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
|
||||||
// first top-level file whose name looks like a license notice.
|
// first top-level file whose name looks like a license notice.
|
||||||
@@ -166,43 +154,10 @@ pub fn sniff_license(dir: &Path) -> Option<String> {
|
|||||||
return Some(id);
|
return Some(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The recognizable-license markers live in the bundled table; the
|
||||||
|
// LICENSE_TEXTS test below is their behavioral lock.
|
||||||
let text = content.to_ascii_lowercase();
|
let text = content.to_ascii_lowercase();
|
||||||
if text.contains("apache license") && text.contains("version 2") {
|
licenses::detect_from_text(&text).map(str::to_string)
|
||||||
return Some("Apache-2.0".to_string());
|
|
||||||
}
|
|
||||||
if text.contains("lesser general public license") {
|
|
||||||
return if text.contains("version 3") && !text.contains("version 2.1") {
|
|
||||||
Some("LGPL-3.0+".to_string())
|
|
||||||
} else {
|
|
||||||
Some("LGPL-2.1+".to_string())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if text.contains("general public license") {
|
|
||||||
return if text.contains("version 3") {
|
|
||||||
Some("GPL-3.0+".to_string())
|
|
||||||
} else {
|
|
||||||
Some("GPL-2.0+".to_string())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if text.contains("mit license") || text.contains("permission is hereby granted, free of charge")
|
|
||||||
{
|
|
||||||
return Some("MIT".to_string());
|
|
||||||
}
|
|
||||||
if text.contains("isc license")
|
|
||||||
|| text.contains("permission to use, copy, modify, and/or distribute this software")
|
|
||||||
{
|
|
||||||
return Some("ISC".to_string());
|
|
||||||
}
|
|
||||||
if text.contains("redistribution and use in source and binary forms") {
|
|
||||||
// The third clause (name endorsement) is what sets BSD-3 apart
|
|
||||||
// from BSD-2.
|
|
||||||
return if text.contains("endorse or promote") {
|
|
||||||
Some("BSD-3-Clause".to_string())
|
|
||||||
} else {
|
|
||||||
Some("BSD-2-Clause".to_string())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -217,15 +172,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn marker_files_map_to_templates() {
|
fn marker_files_map_to_templates() {
|
||||||
let cases = [
|
let cases = [
|
||||||
("Cargo.toml", TemplateId::Rust),
|
("Cargo.toml", TemplateId::RUST),
|
||||||
("pyproject.toml", TemplateId::Python),
|
("pyproject.toml", TemplateId::PYTHON),
|
||||||
("setup.py", TemplateId::Python),
|
("setup.py", TemplateId::PYTHON),
|
||||||
("setup.cfg", TemplateId::Python),
|
("setup.cfg", TemplateId::PYTHON),
|
||||||
("meson.build", TemplateId::Meson),
|
("meson.build", TemplateId::MESON),
|
||||||
("CMakeLists.txt", TemplateId::Cmake),
|
("CMakeLists.txt", TemplateId::CMAKE),
|
||||||
("configure.ac", TemplateId::Autotools),
|
("configure.ac", TemplateId::AUTOTOOLS),
|
||||||
("go.mod", TemplateId::Go),
|
("go.mod", TemplateId::GO),
|
||||||
("Makefile", TemplateId::Makefile),
|
("Makefile", TemplateId::MAKEFILE),
|
||||||
];
|
];
|
||||||
for (marker, expected) in cases {
|
for (marker, expected) in cases {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -241,21 +196,21 @@ mod tests {
|
|||||||
touch(dir.path(), "Makefile");
|
touch(dir.path(), "Makefile");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect(dir.path()),
|
detect(dir.path()),
|
||||||
Detection::Ambiguous(vec![TemplateId::Rust, TemplateId::Makefile])
|
Detection::Ambiguous(vec![TemplateId::RUST, TemplateId::MAKEFILE])
|
||||||
);
|
);
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
touch(dir.path(), "pyproject.toml");
|
touch(dir.path(), "pyproject.toml");
|
||||||
touch(dir.path(), "setup.py");
|
touch(dir.path(), "setup.py");
|
||||||
// Both markers map to the same template: one hit, not ambiguous.
|
// Both markers map to the same template: one hit, not ambiguous.
|
||||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Python));
|
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::PYTHON));
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
touch(dir.path(), "meson.build");
|
touch(dir.path(), "meson.build");
|
||||||
touch(dir.path(), "CMakeLists.txt");
|
touch(dir.path(), "CMakeLists.txt");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect(dir.path()),
|
detect(dir.path()),
|
||||||
Detection::Ambiguous(vec![TemplateId::Meson, TemplateId::Cmake])
|
Detection::Ambiguous(vec![TemplateId::MESON, TemplateId::CMAKE])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +219,12 @@ mod tests {
|
|||||||
// .sh extension.
|
// .sh extension.
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
touch(dir.path(), "run.sh");
|
touch(dir.path(), "run.sh");
|
||||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell));
|
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
|
||||||
|
|
||||||
// Shebang without extension.
|
// Shebang without extension.
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap();
|
std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap();
|
||||||
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::Shell));
|
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
|
||||||
|
|
||||||
// Two scripts: not exactly one, nothing recognized.
|
// Two scripts: not exactly one, nothing recognized.
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -302,8 +257,10 @@ mod tests {
|
|||||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinctive (shortened) excerpts of the recognizable license texts.
|
/// Distinctive (shortened) excerpts of the recognizable license texts:
|
||||||
const LICENSE_TEXTS: [(&str, &str); 9] = [
|
/// the behavioral lock of the marker sets in `data/licenses.yml` — a
|
||||||
|
/// bad marker edit fails here, not on real packages.
|
||||||
|
const LICENSE_TEXTS: [(&str, &str); 11] = [
|
||||||
(
|
(
|
||||||
"MIT",
|
"MIT",
|
||||||
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
|
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
|
||||||
@@ -337,6 +294,19 @@ mod tests {
|
|||||||
"ISC",
|
"ISC",
|
||||||
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
|
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
|
||||||
),
|
),
|
||||||
|
// Dual-licensed preamble: the GPL reference outranks the MIT
|
||||||
|
// boilerplate, like the old hardcoded cascade decided.
|
||||||
|
(
|
||||||
|
"GPL-2.0+",
|
||||||
|
"MIT License\n\nAlternatively, under the terms of the GNU General Public License,\
|
||||||
|
\nversion 2 of the License.",
|
||||||
|
),
|
||||||
|
// LGPL text naming both versions: the 2.1 wording wins.
|
||||||
|
(
|
||||||
|
"LGPL-2.1+",
|
||||||
|
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999\n\
|
||||||
|
This is version 2.1; version 3 is available separately.",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! License reference data for `pkh new`, bundled as `data/licenses.yml`.
|
||||||
|
//!
|
||||||
|
//! The license knowledge of the scaffolder used to live in three places —
|
||||||
|
//! the wizard menu (`questions.rs`), the parse/SPDX mapping (`options.rs`)
|
||||||
|
//! and the license-file sniffing inputs (`detect.rs`) — kept in sync by
|
||||||
|
//! comments only. It is one table now: [`entries`] carries each curated
|
||||||
|
//! license's menu label, accepted spellings and detection markers,
|
||||||
|
//! [`detect_files`] the candidate license file names and [`url_template`]
|
||||||
|
//! the SPDX page URL template, so the lists cannot drift apart and adding
|
||||||
|
//! a license is a YAML entry.
|
||||||
|
//!
|
||||||
|
//! The `License` enum stays in `options.rs` (`NewOptions` and the template
|
||||||
|
//! rendering match on its variants); the enum's variants and the table's
|
||||||
|
//! ids are locked together by a consistency test there. The free-text
|
||||||
|
//! "Other (enter a SPDX identifier)" wizard entry is UX, not data, and
|
||||||
|
//! stays in Rust — like `License::Custom`'s code-driven parse path.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::embed_data;
|
||||||
|
|
||||||
|
/// One marker set of the license-text sniff: the set matches when every
|
||||||
|
/// marker of `all` occurs in the lowercased license text and none of
|
||||||
|
/// `unless` does (all-markers-within-a-list, any-marker-list semantics).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct DetectMarkerSet {
|
||||||
|
/// Substrings that must all occur in the license text
|
||||||
|
pub(crate) all: Vec<String>,
|
||||||
|
/// Substrings that must all be absent for the set to match
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) unless: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One curated license of the bundled table: everything `pkh new` knows
|
||||||
|
/// about it — identifier, menu label, accepted spellings and sniff markers
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct LicenseEntry {
|
||||||
|
/// SPDX identifier: written to `debian/copyright`, returned by the
|
||||||
|
/// license sniff, substituted into [`url_template`]
|
||||||
|
pub(crate) id: String,
|
||||||
|
/// Label offered by the wizard license menu
|
||||||
|
pub(crate) menu: String,
|
||||||
|
/// Inputs accepted by `License::parse` (case-insensitive); must
|
||||||
|
/// include the id itself
|
||||||
|
pub(crate) spellings: Vec<String>,
|
||||||
|
/// Marker sets of the license-text sniff (see [`detect_from_text`])
|
||||||
|
pub(crate) detect_markers: Vec<DetectMarkerSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled license table (`data/licenses.yml`): the curated licenses
|
||||||
|
/// plus the shared sniffing inputs and the SPDX URL template
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LicensesData {
|
||||||
|
/// SPDX license page URL template (`{id}` placeholder)
|
||||||
|
license_url_template: String,
|
||||||
|
/// Candidate license file names of the sniff, in preference order
|
||||||
|
detect_files: Vec<String>,
|
||||||
|
/// The curated licenses, in wizard-menu order
|
||||||
|
licenses: Vec<LicenseEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
embed_data! {
|
||||||
|
static ref LICENSES_DATA: LicensesData = "../../data/licenses.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curated license entries, in the order offered by the wizard menu
|
||||||
|
pub(crate) fn entries() -> &'static [LicenseEntry] {
|
||||||
|
&LICENSES_DATA.licenses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry whose `spellings` contain `input` (case-insensitive): the
|
||||||
|
/// table lookup behind `License::parse`'s curated arm
|
||||||
|
pub(crate) fn entry_for_spelling(input: &str) -> Option<&'static LicenseEntry> {
|
||||||
|
entries().iter().find(|entry| {
|
||||||
|
entry
|
||||||
|
.spellings
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.eq_ignore_ascii_case(input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candidate license file names looked at by the license sniff, in
|
||||||
|
/// preference order, shared by every license
|
||||||
|
pub(crate) fn detect_files() -> impl Iterator<Item = &'static str> {
|
||||||
|
LICENSES_DATA.detect_files.iter().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SPDX identifier of the (lowercased) license `text`, `None` when
|
||||||
|
/// nothing is recognizable: the first entry (menu order) with a matching
|
||||||
|
/// marker set wins. The caller lowercases the text; the markers are
|
||||||
|
/// lowercase substrings.
|
||||||
|
pub(crate) fn detect_from_text(text: &str) -> Option<&'static str> {
|
||||||
|
entries()
|
||||||
|
.iter()
|
||||||
|
.find(|entry| markers_match(entry, text))
|
||||||
|
.map(|entry| entry.id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any marker set of `entry` matches `text` (see
|
||||||
|
/// [`DetectMarkerSet`] for the semantics)
|
||||||
|
fn markers_match(entry: &LicenseEntry, text: &str) -> bool {
|
||||||
|
entry.detect_markers.iter().any(|set| {
|
||||||
|
set.all.iter().all(|marker| text.contains(marker))
|
||||||
|
&& set.unless.iter().all(|marker| !text.contains(marker))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SPDX license page URL template of the table, carrying the license
|
||||||
|
/// identifier as an `{id}` placeholder
|
||||||
|
pub(crate) fn url_template() -> &'static str {
|
||||||
|
LICENSES_DATA.license_url_template.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every entry is menu-ready: non-empty id/menu, at least one spelling
|
||||||
|
/// and one non-empty marker set, and no duplicate ids or menus (the
|
||||||
|
/// wizard select would silently shadow a duplicate menu label).
|
||||||
|
#[test]
|
||||||
|
fn entries_are_well_formed() {
|
||||||
|
assert!(!entries().is_empty());
|
||||||
|
let mut ids: Vec<&str> = Vec::new();
|
||||||
|
let mut menus: Vec<&str> = Vec::new();
|
||||||
|
for entry in entries() {
|
||||||
|
assert!(!entry.id.is_empty());
|
||||||
|
assert!(!entry.menu.is_empty());
|
||||||
|
assert!(!entry.spellings.is_empty());
|
||||||
|
assert!(
|
||||||
|
!entry.detect_markers.is_empty(),
|
||||||
|
"entry '{}' has no marker set",
|
||||||
|
entry.id
|
||||||
|
);
|
||||||
|
for set in &entry.detect_markers {
|
||||||
|
assert!(
|
||||||
|
!set.all.is_empty(),
|
||||||
|
"entry '{}' has an empty marker set",
|
||||||
|
entry.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ids.push(entry.id.as_str());
|
||||||
|
menus.push(entry.menu.as_str());
|
||||||
|
}
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
menus.sort_unstable();
|
||||||
|
menus.dedup();
|
||||||
|
assert_eq!(ids.len(), entries().len(), "duplicate ids");
|
||||||
|
assert_eq!(menus.len(), entries().len(), "duplicate menus");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared sniffing inputs and the URL template: the canonical file
|
||||||
|
/// names in preference order, and a template the spdx_url
|
||||||
|
/// substitution can render.
|
||||||
|
#[test]
|
||||||
|
fn sniff_inputs_and_url_template() {
|
||||||
|
let files: Vec<&str> = detect_files().collect();
|
||||||
|
assert!(!files.is_empty());
|
||||||
|
assert_eq!(files[0], "LICENSE");
|
||||||
|
assert!(files.contains(&"COPYING"));
|
||||||
|
assert!(url_template().starts_with("https://spdx.org/licenses/"));
|
||||||
|
assert!(url_template().contains("{id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marker semantics on hand-built texts: every `all` marker must
|
||||||
|
/// occur, every `unless` marker must not, any set of an entry
|
||||||
|
/// suffices, and the sets are mutually exclusive across entries —
|
||||||
|
/// the LGPL/GPL substring relation and the multi-license texts land
|
||||||
|
/// on the same entry the old hardcoded cascade picked.
|
||||||
|
#[test]
|
||||||
|
fn marker_sets_keep_the_old_cascade_results() {
|
||||||
|
// Nothing recognizable.
|
||||||
|
assert_eq!(detect_from_text("do whatever you want"), None);
|
||||||
|
// Version discrimination of the GPL family.
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("gnu general public license\nversion 3"),
|
||||||
|
Some("GPL-3.0+")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("gnu general public license\nversion 2, june 1991"),
|
||||||
|
Some("GPL-2.0+")
|
||||||
|
);
|
||||||
|
// "lesser general public license" contains "general public
|
||||||
|
// license": LGPL texts must stay on their entries.
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("gnu lesser general public license\nversion 3"),
|
||||||
|
Some("LGPL-3.0+")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("gnu lesser general public license\nversion 2.1"),
|
||||||
|
Some("LGPL-2.1+")
|
||||||
|
);
|
||||||
|
// A text naming both versions is the 2.1 wording (version 2.1
|
||||||
|
// wins), like the old unless-less branch pair did.
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("gnu lesser general public license\nversion 3, like version 2.1"),
|
||||||
|
Some("LGPL-2.1+")
|
||||||
|
);
|
||||||
|
// Multi-license texts: the GPL/Apache reference is the stronger
|
||||||
|
// one, so MIT does not steal them.
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("mit license\nunder the gnu general public license, version 2"),
|
||||||
|
Some("GPL-2.0+")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text(
|
||||||
|
"permission is hereby granted, free of charge\ndual-licensed under the apache license version 2"
|
||||||
|
),
|
||||||
|
Some("Apache-2.0")
|
||||||
|
);
|
||||||
|
// BSD-2 vs BSD-3: the endorsement clause is the discriminator.
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text("redistribution and use in source and binary forms"),
|
||||||
|
Some("BSD-2-Clause")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
detect_from_text(
|
||||||
|
"redistribution and use in source and binary forms\nmay be used to endorse or promote"
|
||||||
|
),
|
||||||
|
Some("BSD-3-Clause")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-23
@@ -16,6 +16,8 @@
|
|||||||
pub mod debian;
|
pub mod debian;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
/// License reference data for the scaffolder (bundled `data/licenses.yml`)
|
||||||
|
pub(crate) mod licenses;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
pub mod orig;
|
pub mod orig;
|
||||||
pub mod origin;
|
pub mod origin;
|
||||||
@@ -179,7 +181,7 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<ScaffoldOutcome
|
|||||||
// mode, plus the template's own entries (rust: the vendored layout) in
|
// mode, plus the template's own entries (rust: the vendored layout) in
|
||||||
// every mode — missing ones appended, an existing file never
|
// every mode — missing ones appended, an existing file never
|
||||||
// overwritten.
|
// overwritten.
|
||||||
let template_gitignore = template.gitignore_entries(opts);
|
let template_gitignore = template.gitignore_entries();
|
||||||
let mut entries: Vec<&str> = Vec::new();
|
let mut entries: Vec<&str> = Vec::new();
|
||||||
let mut header = None;
|
let mut header = None;
|
||||||
if skeleton {
|
if skeleton {
|
||||||
@@ -214,7 +216,7 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<ScaffoldOutcome
|
|||||||
if opts.source_format == SourceFormat::Quilt {
|
if opts.source_format == SourceFormat::Quilt {
|
||||||
pb.set_message("Creating orig tarball");
|
pb.set_message("Creating orig tarball");
|
||||||
let vendored_rust =
|
let vendored_rust =
|
||||||
template.id() == options::TemplateId::Rust && orig::has_vendored_dir(&target);
|
template.id() == options::TemplateId::RUST && orig::has_vendored_dir(&target);
|
||||||
let created = orig::create_orig(
|
let created = orig::create_orig(
|
||||||
&target,
|
&target,
|
||||||
&opts.name,
|
&opts.name,
|
||||||
@@ -249,7 +251,7 @@ fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
|
|||||||
// `display_path` yields an empty string when the target is the cwd
|
// `display_path` yields an empty string when the target is the cwd
|
||||||
// itself (Here mode): `Created .` would be cryptic, so spell the
|
// itself (Here mode): `Created .` would be cryptic, so spell the
|
||||||
// location out; the skeleton/path modes keep the `<dir>` display.
|
// location out; the skeleton/path modes keep the `<dir>` display.
|
||||||
let display = crate::ui::display_path(&target);
|
let display = crate::report::display_path(&target);
|
||||||
let location = if display.is_empty() {
|
let location = if display.is_empty() {
|
||||||
"package in the current directory".to_string()
|
"package in the current directory".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -347,7 +349,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_in(
|
scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -421,7 +423,7 @@ mod tests {
|
|||||||
#[serial]
|
#[serial]
|
||||||
fn scaffold_skeleton_forced_quilt_snapshots_the_tree() {
|
fn scaffold_skeleton_forced_quilt_snapshots_the_tree() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
|
let mut o = opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton);
|
||||||
o.source_format = SourceFormat::Quilt;
|
o.source_format = SourceFormat::Quilt;
|
||||||
o.orig = Some(OrigOrigin::Snapshot);
|
o.orig = Some(OrigOrigin::Snapshot);
|
||||||
scaffold_in(dir.path(), o).unwrap();
|
scaffold_in(dir.path(), o).unwrap();
|
||||||
@@ -469,7 +471,7 @@ mod tests {
|
|||||||
fn scaffold_empty_base_and_metapackage_flavors() {
|
fn scaffold_empty_base_and_metapackage_flavors() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
// Metapackage flavor: non-empty depends.
|
// Metapackage flavor: non-empty depends.
|
||||||
let mut o = opts(TemplateId::Empty, "metapkg", SourceDir::Skeleton);
|
let mut o = opts(TemplateId::EMPTY, "metapkg", SourceDir::Skeleton);
|
||||||
o.depends = vec!["hello".into(), "hello-data (>= 1.0)".into()];
|
o.depends = vec!["hello".into(), "hello-data (>= 1.0)".into()];
|
||||||
scaffold_in(dir.path(), o).unwrap();
|
scaffold_in(dir.path(), o).unwrap();
|
||||||
|
|
||||||
@@ -491,7 +493,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_in(
|
scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Empty, "basepkg", SourceDir::Skeleton),
|
opts(TemplateId::EMPTY, "basepkg", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let control =
|
let control =
|
||||||
@@ -503,7 +505,7 @@ mod tests {
|
|||||||
#[serial]
|
#[serial]
|
||||||
fn scaffold_release_targets_series() {
|
fn scaffold_release_targets_series() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut o = opts(TemplateId::Empty, "released", SourceDir::Skeleton);
|
let mut o = opts(TemplateId::EMPTY, "released", SourceDir::Skeleton);
|
||||||
o.release = true;
|
o.release = true;
|
||||||
scaffold_in(dir.path(), o).unwrap();
|
scaffold_in(dir.path(), o).unwrap();
|
||||||
|
|
||||||
@@ -523,7 +525,7 @@ mod tests {
|
|||||||
let tree = dir.path().join("packdir");
|
let tree = dir.path().join("packdir");
|
||||||
std::fs::create_dir_all(&tree).unwrap();
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
|
std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
|
||||||
scaffold_in(&tree, opts(TemplateId::Shell, "runtool", SourceDir::Here)).unwrap();
|
scaffold_in(&tree, opts(TemplateId::SHELL, "runtool", SourceDir::Here)).unwrap();
|
||||||
|
|
||||||
// debian/ lands directly in the directory; no skeleton file, no
|
// debian/ lands directly in the directory; no skeleton file, no
|
||||||
// root .gitignore (the shell template contributes none and the
|
// root .gitignore (the shell template contributes none and the
|
||||||
@@ -572,7 +574,7 @@ mod tests {
|
|||||||
std::fs::write(tree.join("debian/control"), "Source: mytool\n").unwrap();
|
std::fs::write(tree.join("debian/control"), "Source: mytool\n").unwrap();
|
||||||
let err = scaffold_in(
|
let err = scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("debian/control"), "{err}");
|
assert!(err.to_string().contains("debian/control"), "{err}");
|
||||||
@@ -583,7 +585,7 @@ mod tests {
|
|||||||
std::fs::write(dir.path().join("mytool/junk"), "x").unwrap();
|
std::fs::write(dir.path().join("mytool/junk"), "x").unwrap();
|
||||||
let err = scaffold_in(
|
let err = scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("not empty"), "{err}");
|
assert!(err.to_string().contains("not empty"), "{err}");
|
||||||
@@ -591,7 +593,7 @@ mod tests {
|
|||||||
// Existing orig tarball (quilt only): nothing gets written.
|
// Existing orig tarball (quilt only): nothing gets written.
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
|
std::fs::write(dir.path().join("mytool_0.1.0.orig.tar.xz"), b"old").unwrap();
|
||||||
let mut o = opts(TemplateId::Shell, "mytool", SourceDir::Skeleton);
|
let mut o = opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton);
|
||||||
o.source_format = SourceFormat::Quilt;
|
o.source_format = SourceFormat::Quilt;
|
||||||
o.orig = Some(OrigOrigin::Snapshot);
|
o.orig = Some(OrigOrigin::Snapshot);
|
||||||
let err = scaffold_in(dir.path(), o).unwrap_err();
|
let err = scaffold_in(dir.path(), o).unwrap_err();
|
||||||
@@ -603,7 +605,7 @@ mod tests {
|
|||||||
let err = scaffold_in(
|
let err = scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(
|
opts(
|
||||||
TemplateId::Shell,
|
TemplateId::SHELL,
|
||||||
"mytool",
|
"mytool",
|
||||||
SourceDir::Path(dir.path().join("missing")),
|
SourceDir::Path(dir.path().join("missing")),
|
||||||
),
|
),
|
||||||
@@ -620,7 +622,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_in(
|
scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Shell, "nativepkg", SourceDir::Skeleton),
|
opts(TemplateId::SHELL, "nativepkg", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -647,7 +649,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let outcome = scaffold_in(
|
let outcome = scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::RUST, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -709,7 +711,7 @@ mod tests {
|
|||||||
let tree = dir.path().join("packdir");
|
let tree = dir.path().join("packdir");
|
||||||
std::fs::create_dir_all(&tree).unwrap();
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
std::fs::write(tree.join(".gitignore"), "# my project\n*.log\n").unwrap();
|
std::fs::write(tree.join(".gitignore"), "# my project\n*.log\n").unwrap();
|
||||||
scaffold_in(&tree, opts(TemplateId::Rust, "mytool", SourceDir::Here)).unwrap();
|
scaffold_in(&tree, opts(TemplateId::RUST, "mytool", SourceDir::Here)).unwrap();
|
||||||
|
|
||||||
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
|
let gitignore = std::fs::read_to_string(tree.join(".gitignore")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -751,7 +753,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
std::fs::write(source.join("src/main.rs"), "fn main() {}\n").unwrap();
|
std::fs::write(source.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||||
|
|
||||||
let mut o = opts(TemplateId::Rust, "mytool", SourceDir::Path(source.clone()));
|
let mut o = opts(TemplateId::RUST, "mytool", SourceDir::Path(source.clone()));
|
||||||
o.source_format = SourceFormat::Quilt;
|
o.source_format = SourceFormat::Quilt;
|
||||||
o.orig = Some(OrigOrigin::Snapshot);
|
o.orig = Some(OrigOrigin::Snapshot);
|
||||||
let outcome = scaffold_in(dir.path(), o).unwrap();
|
let outcome = scaffold_in(dir.path(), o).unwrap();
|
||||||
@@ -817,7 +819,7 @@ mod tests {
|
|||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&source,
|
&source,
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
|
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
|
||||||
@@ -846,7 +848,7 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_in(
|
scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::PYTHON, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -878,12 +880,12 @@ mod tests {
|
|||||||
let tree = dir.path().join("mytool");
|
let tree = dir.path().join("mytool");
|
||||||
std::fs::create_dir_all(&tree).unwrap();
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
|
std::fs::write(tree.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
|
||||||
scaffold_in(&tree, opts(TemplateId::Shell, "mytool", SourceDir::Here)).unwrap();
|
scaffold_in(&tree, opts(TemplateId::SHELL, "mytool", SourceDir::Here)).unwrap();
|
||||||
|
|
||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&dir.path().join("mytool"),
|
&dir.path().join("mytool"),
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -907,14 +909,14 @@ mod tests {
|
|||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
scaffold_in(
|
scaffold_in(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
opts(TemplateId::Shell, "mytool", SourceDir::Skeleton),
|
opts(TemplateId::SHELL, "mytool", SourceDir::Skeleton),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let output = crate::build::run_source_build(
|
let output = crate::build::run_source_build(
|
||||||
&dir.path().join("mytool"),
|
&dir.path().join("mytool"),
|
||||||
&crate::build::SourceBuildOptions::default(),
|
&crate::build::SourceBuildOptions::default(),
|
||||||
None,
|
&crate::report::Quiet,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
|
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
|
||||||
|
|||||||
+138
-99
@@ -15,77 +15,64 @@ use crate::debian::DebianVersion;
|
|||||||
use crate::debian::deps::{Deps, ParseOpts};
|
use crate::debian::deps::{Deps, ParseOpts};
|
||||||
use crate::distro_info;
|
use crate::distro_info;
|
||||||
use crate::new::detect::{self, Detection};
|
use crate::new::detect::{self, Detection};
|
||||||
|
use crate::new::licenses;
|
||||||
use crate::new::origin::{Forge, GitOrigin};
|
use crate::new::origin::{Forge, GitOrigin};
|
||||||
use crate::new::templates;
|
use crate::new::templates;
|
||||||
|
|
||||||
/// Build systems / project kinds `pkh new` knows about.
|
/// Build systems / project kinds `pkh new` knows about: a lightweight id
|
||||||
|
/// wrapping the stable CLI string (the `--lang` value).
|
||||||
///
|
///
|
||||||
/// The identifiers are stable CLI surface: `--lang` accepts every variant,
|
/// The templates themselves are defined by the per-template manifests
|
||||||
/// and every variant has a template implementation registered in
|
/// under `data/templates/<id>/` (see [`crate::new::templates`]): label,
|
||||||
/// [`crate::new::templates`].
|
/// detection markers, policy metadata and file bodies all live there, and
|
||||||
|
/// every id below must have a manifest registered — the registry is built
|
||||||
|
/// from exactly these constants and a consistency test keeps the two in
|
||||||
|
/// lockstep.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum TemplateId {
|
pub struct TemplateId(&'static str);
|
||||||
/// Rust project (`Cargo.toml`)
|
|
||||||
Rust,
|
|
||||||
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`)
|
|
||||||
Python,
|
|
||||||
/// C/C++ with Meson (`meson.build`)
|
|
||||||
Meson,
|
|
||||||
/// C/C++ with CMake (`CMakeLists.txt`)
|
|
||||||
Cmake,
|
|
||||||
/// C/C++ with Autotools (`configure.ac`)
|
|
||||||
Autotools,
|
|
||||||
/// Go module (`go.mod`)
|
|
||||||
Go,
|
|
||||||
/// Shell script / single interpreted file
|
|
||||||
Shell,
|
|
||||||
/// Generic Makefile-based project
|
|
||||||
Makefile,
|
|
||||||
/// Metapackage / empty base (no build system)
|
|
||||||
Empty,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateId {
|
impl TemplateId {
|
||||||
/// Every template id, in the order offered by the wizard language menu.
|
/// Rust project (`Cargo.toml`).
|
||||||
pub fn all() -> [TemplateId; 9] {
|
pub const RUST: TemplateId = TemplateId("rust");
|
||||||
[
|
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`).
|
||||||
TemplateId::Rust,
|
pub const PYTHON: TemplateId = TemplateId("python");
|
||||||
TemplateId::Python,
|
/// C/C++ with Meson (`meson.build`).
|
||||||
TemplateId::Meson,
|
pub const MESON: TemplateId = TemplateId("meson");
|
||||||
TemplateId::Cmake,
|
/// C/C++ with CMake (`CMakeLists.txt`).
|
||||||
TemplateId::Autotools,
|
pub const CMAKE: TemplateId = TemplateId("cmake");
|
||||||
TemplateId::Go,
|
/// C/C++ with Autotools (`configure.ac`).
|
||||||
TemplateId::Shell,
|
pub const AUTOTOOLS: TemplateId = TemplateId("autotools");
|
||||||
TemplateId::Makefile,
|
/// Go module (`go.mod`).
|
||||||
TemplateId::Empty,
|
pub const GO: TemplateId = TemplateId("go");
|
||||||
]
|
/// Shell script / single interpreted file.
|
||||||
|
pub const SHELL: TemplateId = TemplateId("shell");
|
||||||
|
/// Generic Makefile-based project.
|
||||||
|
pub const MAKEFILE: TemplateId = TemplateId("makefile");
|
||||||
|
/// Metapackage / empty base (no build system).
|
||||||
|
pub const EMPTY: TemplateId = TemplateId("empty");
|
||||||
|
|
||||||
|
/// Every template id, in the order offered by the wizard language menu
|
||||||
|
/// (the registry order of the manifests).
|
||||||
|
pub fn all() -> &'static [TemplateId] {
|
||||||
|
templates::ids()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical CLI identifier of this template.
|
/// Canonical CLI identifier of this template.
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
self.0
|
||||||
TemplateId::Rust => "rust",
|
|
||||||
TemplateId::Python => "python",
|
|
||||||
TemplateId::Meson => "meson",
|
|
||||||
TemplateId::Cmake => "cmake",
|
|
||||||
TemplateId::Autotools => "autotools",
|
|
||||||
TemplateId::Go => "go",
|
|
||||||
TemplateId::Shell => "shell",
|
|
||||||
TemplateId::Makefile => "makefile",
|
|
||||||
TemplateId::Empty => "empty",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a CLI identifier, accepting exactly the canonical spellings.
|
/// Parse a CLI identifier, accepting exactly the registered spellings.
|
||||||
pub fn parse(s: &str) -> Result<TemplateId, String> {
|
pub fn parse(s: &str) -> Result<TemplateId, String> {
|
||||||
TemplateId::all()
|
Self::all()
|
||||||
.into_iter()
|
.iter()
|
||||||
|
.copied()
|
||||||
.find(|id| id.as_str() == s)
|
.find(|id| id.as_str() == s)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown language/template '{}'. Supported values are: {}.",
|
"Unknown language/template '{}'. Supported values are: {}.",
|
||||||
s,
|
s,
|
||||||
TemplateId::all()
|
Self::all()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| id.as_str())
|
.map(|id| id.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -95,26 +82,15 @@ impl TemplateId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Human-readable menu label of this template, as offered by the wizard
|
/// Human-readable menu label of this template, as offered by the wizard
|
||||||
/// language question (and reused in the summary screen).
|
/// language question (and reused in the summary screen): the
|
||||||
|
/// manifest's `label`.
|
||||||
pub fn display_name(&self) -> &'static str {
|
pub fn display_name(&self) -> &'static str {
|
||||||
match self {
|
templates::get(*self).map(|t| t.label()).unwrap_or(self.0)
|
||||||
TemplateId::Rust => "Rust (Cargo.toml)",
|
|
||||||
TemplateId::Python => "Python (pyproject.toml / setup.py)",
|
|
||||||
TemplateId::Meson => "C/C++ (Meson)",
|
|
||||||
TemplateId::Cmake => "C/C++ (CMake)",
|
|
||||||
TemplateId::Autotools => "C/C++ (Autotools)",
|
|
||||||
TemplateId::Go => "Go module",
|
|
||||||
TemplateId::Shell => "Shell script / single interpreted file",
|
|
||||||
TemplateId::Makefile => "Generic (Makefile)",
|
|
||||||
TemplateId::Empty => "Metapackage / empty base (no build system)",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The template whose menu label (or CLI identifier) is `label`.
|
/// The template whose menu label (or CLI identifier) is `label`.
|
||||||
pub fn from_label(label: &str) -> Option<TemplateId> {
|
pub fn from_label(label: &str) -> Option<TemplateId> {
|
||||||
TemplateId::all()
|
templates::from_label(label)
|
||||||
.into_iter()
|
|
||||||
.find(|id| id.display_name() == label || id.as_str() == label)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +189,10 @@ impl OrigOrigin {
|
|||||||
|
|
||||||
/// Upstream license of the package: a curated SPDX list plus a free-text
|
/// Upstream license of the package: a curated SPDX list plus a free-text
|
||||||
/// fallback for anything else (including "unknown" until the user picks one).
|
/// fallback for anything else (including "unknown" until the user picks one).
|
||||||
|
/// The curated identifiers, their accepted spellings and the menu labels
|
||||||
|
/// come from the bundled license table (`data/licenses.yml`); this enum
|
||||||
|
/// stays code because [`NewOptions`] and the template rendering match on
|
||||||
|
/// its variants — a consistency test keeps the two in lockstep.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum License {
|
pub enum License {
|
||||||
/// MIT
|
/// MIT
|
||||||
@@ -239,21 +219,34 @@ pub enum License {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl License {
|
impl License {
|
||||||
/// Map a license string to a [`License`]: curated SPDX identifiers are
|
/// Map a license string to a [`License`]: the curated SPDX identifiers
|
||||||
/// matched case-insensitively, anything else becomes
|
/// of the bundled license table (`data/licenses.yml`, matched through
|
||||||
/// [`License::Custom`] verbatim.
|
/// their spellings, case-insensitively) become their variant,
|
||||||
|
/// anything else becomes [`License::Custom`] verbatim (lowercased).
|
||||||
pub fn parse(s: &str) -> License {
|
pub fn parse(s: &str) -> License {
|
||||||
match s.to_ascii_lowercase().as_str() {
|
match licenses::entry_for_spelling(s) {
|
||||||
"mit" => License::Mit,
|
Some(entry) => Self::from_spdx(&entry.id)
|
||||||
"apache-2.0" => License::Apache2,
|
.expect("licenses.yml ids and License variants are kept in sync by a test"),
|
||||||
"gpl-2.0+" => License::Gpl2Plus,
|
None => License::Custom(s.to_ascii_lowercase()),
|
||||||
"gpl-3.0+" => License::Gpl3Plus,
|
}
|
||||||
"lgpl-2.1+" => License::Lgpl21Plus,
|
}
|
||||||
"lgpl-3.0+" => License::Lgpl3Plus,
|
|
||||||
"bsd-2-clause" => License::Bsd2Clause,
|
/// The variant of a curated license's SPDX identifier — the inverse of
|
||||||
"bsd-3-clause" => License::Bsd3Clause,
|
/// [`License::spdx`] for the non-`Custom` variants, `None` for any
|
||||||
"isc" => License::Isc,
|
/// other string. The ids accepted here are locked to the bundled
|
||||||
other => License::Custom(other.to_string()),
|
/// license table by a consistency test.
|
||||||
|
fn from_spdx(id: &str) -> Option<License> {
|
||||||
|
match id {
|
||||||
|
"MIT" => Some(License::Mit),
|
||||||
|
"Apache-2.0" => Some(License::Apache2),
|
||||||
|
"GPL-2.0+" => Some(License::Gpl2Plus),
|
||||||
|
"GPL-3.0+" => Some(License::Gpl3Plus),
|
||||||
|
"LGPL-2.1+" => Some(License::Lgpl21Plus),
|
||||||
|
"LGPL-3.0+" => Some(License::Lgpl3Plus),
|
||||||
|
"BSD-2-Clause" => Some(License::Bsd2Clause),
|
||||||
|
"BSD-3-Clause" => Some(License::Bsd3Clause),
|
||||||
|
"ISC" => Some(License::Isc),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,12 +267,11 @@ impl License {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// SPDX license data page URL (without the trailing `+` of the
|
/// SPDX license data page URL (without the trailing `+` of the
|
||||||
/// "or later" spellings), for the copyright reference paragraph.
|
/// "or later" spellings), for the copyright reference paragraph: the
|
||||||
|
/// identifier substituted into the URL template of the bundled
|
||||||
|
/// license table.
|
||||||
pub fn spdx_url(&self) -> String {
|
pub fn spdx_url(&self) -> String {
|
||||||
format!(
|
licenses::url_template().replace("{id}", self.spdx().trim_end_matches('+'))
|
||||||
"https://spdx.org/licenses/{}.html",
|
|
||||||
self.spdx().trim_end_matches('+')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +625,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
|
|||||||
SourceDir::Skeleton => {
|
SourceDir::Skeleton => {
|
||||||
if cli.defaults {
|
if cli.defaults {
|
||||||
log::info!("No language given, --defaults picks the 'empty' template");
|
log::info!("No language given, --defaults picks the 'empty' template");
|
||||||
Some(TemplateId::Empty)
|
Some(TemplateId::EMPTY)
|
||||||
} else {
|
} else {
|
||||||
missing.push(format!(
|
missing.push(format!(
|
||||||
"--lang <{}|...> (no language given and there is nothing \
|
"--lang <{}|...> (no language given and there is nothing \
|
||||||
@@ -678,7 +670,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
|
|||||||
the 'empty' template",
|
the 'empty' template",
|
||||||
dir.display()
|
dir.display()
|
||||||
);
|
);
|
||||||
Some(TemplateId::Empty)
|
Some(TemplateId::EMPTY)
|
||||||
} else {
|
} else {
|
||||||
missing.push(
|
missing.push(
|
||||||
"--lang <id> (could not detect a build system; \
|
"--lang <id> (could not detect a build system; \
|
||||||
@@ -828,7 +820,7 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
|
|||||||
|
|
||||||
Ok(NewOptions {
|
Ok(NewOptions {
|
||||||
name,
|
name,
|
||||||
template: template.unwrap_or(TemplateId::Empty),
|
template: template.unwrap_or(TemplateId::EMPTY),
|
||||||
source_dir,
|
source_dir,
|
||||||
upstream_version,
|
upstream_version,
|
||||||
revision,
|
revision,
|
||||||
@@ -975,7 +967,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn template_ids_roundtrip() {
|
fn template_ids_roundtrip() {
|
||||||
for id in TemplateId::all() {
|
for id in TemplateId::all().iter().copied() {
|
||||||
assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id);
|
assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id);
|
||||||
// Every id resolves from its menu label too, and labels are
|
// Every id resolves from its menu label too, and labels are
|
||||||
// unique.
|
// unique.
|
||||||
@@ -984,9 +976,9 @@ mod tests {
|
|||||||
assert!(TemplateId::parse("cobol").is_err());
|
assert!(TemplateId::parse("cobol").is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
TemplateId::from_label("Rust (Cargo.toml)"),
|
TemplateId::from_label("Rust (Cargo.toml)"),
|
||||||
Some(TemplateId::Rust)
|
Some(TemplateId::RUST)
|
||||||
);
|
);
|
||||||
assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::Rust));
|
assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::RUST));
|
||||||
assert_eq!(TemplateId::from_label("nope"), None);
|
assert_eq!(TemplateId::from_label("nope"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1074,7 @@ mod tests {
|
|||||||
// The full version round-trips through DebianVersion.
|
// The full version round-trips through DebianVersion.
|
||||||
let opts = NewOptions {
|
let opts = NewOptions {
|
||||||
name: "t".into(),
|
name: "t".into(),
|
||||||
template: TemplateId::Empty,
|
template: TemplateId::EMPTY,
|
||||||
source_dir: SourceDir::Here,
|
source_dir: SourceDir::Here,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -1177,6 +1169,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bundled license table and the `License` enum are two halves of
|
||||||
|
/// one list and must never drift apart: every entry id must map to a
|
||||||
|
/// variant (and the entry must accept its own id as a spelling, so
|
||||||
|
/// the menu answers parse back), every variant must have an entry,
|
||||||
|
/// and the counts must agree. The hardcoded variant list here is the
|
||||||
|
/// lock: adding a variant or a YAML entry without the other half
|
||||||
|
/// fails this test.
|
||||||
|
#[test]
|
||||||
|
fn licenses_table_and_enum_are_in_sync() {
|
||||||
|
let variants = [
|
||||||
|
License::Mit,
|
||||||
|
License::Apache2,
|
||||||
|
License::Gpl2Plus,
|
||||||
|
License::Gpl3Plus,
|
||||||
|
License::Lgpl21Plus,
|
||||||
|
License::Lgpl3Plus,
|
||||||
|
License::Bsd2Clause,
|
||||||
|
License::Bsd3Clause,
|
||||||
|
License::Isc,
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
licenses::entries().len(),
|
||||||
|
variants.len(),
|
||||||
|
"licenses.yml and the License enum carry a different number of licenses"
|
||||||
|
);
|
||||||
|
for variant in variants {
|
||||||
|
let entry = licenses::entries()
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == variant.spdx())
|
||||||
|
.unwrap_or_else(|| panic!("no licenses.yml entry for '{}'", variant.spdx()));
|
||||||
|
assert_eq!(entry.id, variant.spdx());
|
||||||
|
assert!(
|
||||||
|
entry
|
||||||
|
.spellings
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.eq_ignore_ascii_case(&entry.id)),
|
||||||
|
"entry '{}' must accept its own id as a spelling",
|
||||||
|
entry.id
|
||||||
|
);
|
||||||
|
// The menu label parses back into the same variant.
|
||||||
|
assert_eq!(License::parse(&entry.menu), variant);
|
||||||
|
}
|
||||||
|
for entry in licenses::entries() {
|
||||||
|
let variant = License::from_spdx(&entry.id).unwrap_or_else(|| {
|
||||||
|
panic!("licenses.yml entry '{}' has no License variant", entry.id)
|
||||||
|
});
|
||||||
|
assert_eq!(variant.spdx(), entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collision_detection() {
|
fn collision_detection() {
|
||||||
assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok());
|
assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok());
|
||||||
@@ -1234,7 +1276,7 @@ mod tests {
|
|||||||
let opts = resolve(cli).await.unwrap();
|
let opts = resolve(cli).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(opts.name, "my-tool");
|
assert_eq!(opts.name, "my-tool");
|
||||||
assert_eq!(opts.template, TemplateId::Empty);
|
assert_eq!(opts.template, TemplateId::EMPTY);
|
||||||
assert!(matches!(opts.source_dir, SourceDir::Path(_)));
|
assert!(matches!(opts.source_dir, SourceDir::Path(_)));
|
||||||
assert_eq!(opts.upstream_version, "0.1.0");
|
assert_eq!(opts.upstream_version, "0.1.0");
|
||||||
assert_eq!(opts.revision, 1);
|
assert_eq!(opts.revision, 1);
|
||||||
@@ -1275,7 +1317,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let opts = resolve(cli).await.unwrap();
|
let opts = resolve(cli).await.unwrap();
|
||||||
assert_eq!(opts.template, TemplateId::Go);
|
assert_eq!(opts.template, TemplateId::GO);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1537,10 +1579,7 @@ mod tests {
|
|||||||
resolve(cli).await.unwrap().orig,
|
resolve(cli).await.unwrap().orig,
|
||||||
Some(OrigOrigin::Release {
|
Some(OrigOrigin::Release {
|
||||||
tag: "v1.2.3".to_string(),
|
tag: "v1.2.3".to_string(),
|
||||||
forge: crate::new::origin::Forge::GitHub {
|
forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(),
|
||||||
owner: "foo".into(),
|
|
||||||
repo: "bar".into()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+6
-8
@@ -183,7 +183,7 @@ pub fn create_vendor_component(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created vendored-dependencies component {}",
|
"Created vendored-dependencies component {}",
|
||||||
crate::ui::display_path(&component_path)
|
crate::report::display_path(&component_path)
|
||||||
);
|
);
|
||||||
Ok(component_path)
|
Ok(component_path)
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ fn git_archive_tarball(
|
|||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from git archive of {tag}: {}",
|
"Created orig tarball from git archive of {tag}: {}",
|
||||||
crate::ui::display_path(&dest)
|
crate::report::display_path(&dest)
|
||||||
);
|
);
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ fn download_release(
|
|||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from the release download of {tag}: {}",
|
"Created orig tarball from the release download of {tag}: {}",
|
||||||
crate::ui::display_path(&path)
|
crate::report::display_path(&path)
|
||||||
);
|
);
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ fn fetch_and_repack(
|
|||||||
log::info!(
|
log::info!(
|
||||||
"Created orig tarball from {}: {}",
|
"Created orig tarball from {}: {}",
|
||||||
source,
|
source,
|
||||||
crate::ui::display_path(&dest)
|
crate::report::display_path(&dest)
|
||||||
);
|
);
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
@@ -1535,10 +1535,8 @@ mod tests {
|
|||||||
"0.1.0",
|
"0.1.0",
|
||||||
&OrigOrigin::Release {
|
&OrigOrigin::Release {
|
||||||
tag: "v0.1.0".to_string(),
|
tag: "v0.1.0".to_string(),
|
||||||
forge: Forge::GitHub {
|
forge: Forge::parse("https://github.com/pkh-nonexistent-org/pkh-nonexistent-repo")
|
||||||
owner: "pkh-nonexistent-org".into(),
|
.unwrap(),
|
||||||
repo: "pkh-nonexistent-repo".into(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
+143
-59
@@ -8,48 +8,85 @@
|
|||||||
//! - is HEAD exactly on a tag, and which upstream version does it name,
|
//! - is HEAD exactly on a tag, and which upstream version does it name,
|
||||||
//! - which is the last tag reachable from HEAD (for the
|
//! - which is the last tag reachable from HEAD (for the
|
||||||
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
|
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
|
||||||
//! - where does the `origin` remote point: only `github.com` and
|
//! - where does the `origin` remote point: only the hosts of the bundled
|
||||||
//! `gitlab.com` are recognized as forges — self-hosted GitLab instances
|
//! forge table (`data/forges.yml`) are recognized as forges — self-hosted
|
||||||
//! are deliberately not (the release-download URL shapes differ).
|
//! GitLab instances are deliberately not part of it (the
|
||||||
|
//! release-download URL shapes differ), and the table also carries each
|
||||||
|
//! forge's tarball URL templates.
|
||||||
//!
|
//!
|
||||||
//! Detection never touches the network: adding a remote stores its URL in
|
//! Detection never touches the network: adding a remote stores its URL in
|
||||||
//! the local config only, which is all this module reads.
|
//! the local config only, which is all this module reads.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// A forge hosting the project, parsed from the `origin` remote URL.
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::data::embed_data;
|
||||||
|
|
||||||
|
/// Family a forge belongs to. Documentation of which URL-shape family a
|
||||||
|
/// table entry belongs to — the tarball templates fully describe the
|
||||||
|
/// URLs, so nothing branches on the kind (yet). Parsed strictly: an
|
||||||
|
/// unknown kind fails the load rather than being silently ignored.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum ForgeKind {
|
||||||
|
/// `github.com` and its codeload archive service
|
||||||
|
GitHub,
|
||||||
|
/// `gitlab.com`
|
||||||
|
GitLab,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the bundled forge table: the release-tarball URL
|
||||||
|
/// templates of a forge host
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ForgeEntry {
|
||||||
|
/// Family of the forge (see [`ForgeKind`]). Kept even though nothing
|
||||||
|
/// branches on it (the templates fully describe the URLs): it
|
||||||
|
/// documents the entry's URL-shape family and the strict enum
|
||||||
|
/// validates it at load time.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
kind: ForgeKind,
|
||||||
|
/// Release-tarball URL templates, best candidate first (tried
|
||||||
|
/// sequentially by the download)
|
||||||
|
tarball_templates: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled forge table (`data/forges.yml`): host name → entry
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ForgesData {
|
||||||
|
/// Recognized forge hosts
|
||||||
|
forges: HashMap<String, ForgeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
embed_data! {
|
||||||
|
static ref FORGES_DATA: ForgesData = "../../data/forges.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A forge hosting the project, parsed from the `origin` remote URL: one
|
||||||
|
/// of the hosts of the bundled forge table, plus the repository it points
|
||||||
|
/// at.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Forge {
|
pub struct Forge {
|
||||||
/// `github.com/<owner>/<repo>`
|
/// Host name of the forge (the key of its `forges.yml` entry).
|
||||||
GitHub {
|
host: &'static str,
|
||||||
/// Repository owner (user or organization).
|
/// Repository owner (user or organization).
|
||||||
owner: String,
|
owner: String,
|
||||||
/// Repository name, without the `.git` suffix.
|
/// Repository name, without the `.git` suffix.
|
||||||
repo: String,
|
repo: String,
|
||||||
},
|
|
||||||
/// `gitlab.com/<owner>/<repo>`
|
|
||||||
GitLab {
|
|
||||||
/// Repository owner (user or group).
|
|
||||||
owner: String,
|
|
||||||
/// Repository name, without the `.git` suffix.
|
|
||||||
repo: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Forge {
|
impl Forge {
|
||||||
/// Host name of the forge.
|
/// Host name of the forge.
|
||||||
pub fn host(&self) -> &'static str {
|
pub fn host(&self) -> &'static str {
|
||||||
match self {
|
self.host
|
||||||
Forge::GitHub { .. } => "github.com",
|
|
||||||
Forge::GitLab { .. } => "gitlab.com",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
|
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
|
||||||
/// `http://`, `git://` and `git@host:` spellings. Only `github.com` and
|
/// `http://`, `git://` and `git@host:` spellings. Only the hosts
|
||||||
/// `gitlab.com` are recognized; anything else (self-hosted GitLab,
|
/// listed in the bundled forge table are recognized; anything else
|
||||||
/// Bitbucket, plain URLs…) yields `None`.
|
/// (self-hosted GitLab, Bitbucket, plain URLs…) yields `None`.
|
||||||
pub fn parse(url: &str) -> Option<Forge> {
|
pub fn parse(url: &str) -> Option<Forge> {
|
||||||
let url = url.trim();
|
let url = url.trim();
|
||||||
// Normalize `git@host:path` to `host/path` and strip any scheme.
|
// Normalize `git@host:path` to `host/path` and strip any scheme.
|
||||||
@@ -74,34 +111,34 @@ impl Forge {
|
|||||||
if owner.is_empty() || repo.is_empty() {
|
if owner.is_empty() || repo.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
match host {
|
// The table key doubles as the Forge's host, so the two can never
|
||||||
"github.com" => Some(Forge::GitHub {
|
// disagree about how the forge is spelled.
|
||||||
owner: owner.to_string(),
|
let (host, _entry) = FORGES_DATA.forges.get_key_value(host)?;
|
||||||
repo: repo.to_string(),
|
Some(Forge {
|
||||||
}),
|
host,
|
||||||
"gitlab.com" => Some(Forge::GitLab {
|
owner: owner.to_string(),
|
||||||
owner: owner.to_string(),
|
repo: repo.to_string(),
|
||||||
repo: repo.to_string(),
|
})
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release-tarball URLs of `tag`, best candidate first. GitHub prefers
|
/// Release-tarball URLs of `tag`, best candidate first: the templates
|
||||||
/// the codeload direct link (no redirect) and falls back to the
|
/// of the forge's table entry, with `{owner}`, `{repo}` and `{tag}`
|
||||||
/// `github.com` archive URL; GitLab has a single archive URL.
|
/// substituted, in file order (the download tries them sequentially).
|
||||||
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
|
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
|
||||||
match self {
|
let entry = FORGES_DATA
|
||||||
Forge::GitHub { owner, repo } => vec![
|
.forges
|
||||||
format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"),
|
.get(self.host)
|
||||||
format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"),
|
.expect("the host of a parsed Forge is a key of the forge table");
|
||||||
],
|
entry
|
||||||
Forge::GitLab { owner, repo } => {
|
.tarball_templates
|
||||||
vec![format!(
|
.iter()
|
||||||
"https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz"
|
.map(|template| {
|
||||||
)]
|
template
|
||||||
}
|
.replace("{owner}", &self.owner)
|
||||||
}
|
.replace("{repo}", &self.repo)
|
||||||
|
.replace("{tag}", tag)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,35 +336,40 @@ mod tests {
|
|||||||
fn forge_parses_remote_url_shapes() {
|
fn forge_parses_remote_url_shapes() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Forge::parse("https://github.com/foo/bar.git"),
|
Forge::parse("https://github.com/foo/bar.git"),
|
||||||
Some(Forge::GitHub {
|
Some(Forge {
|
||||||
|
host: "github.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Forge::parse("git@github.com:foo/bar.git"),
|
Forge::parse("git@github.com:foo/bar.git"),
|
||||||
Some(Forge::GitHub {
|
Some(Forge {
|
||||||
|
host: "github.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Forge::parse("git://github.com/foo/bar"),
|
Forge::parse("git://github.com/foo/bar"),
|
||||||
Some(Forge::GitHub {
|
Some(Forge {
|
||||||
|
host: "github.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
|
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
|
||||||
Some(Forge::GitLab {
|
Some(Forge {
|
||||||
|
host: "gitlab.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
|
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
|
||||||
Some(Forge::GitLab {
|
Some(Forge {
|
||||||
|
host: "gitlab.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
@@ -342,7 +384,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forge_release_tarball_urls() {
|
fn forge_release_tarball_urls() {
|
||||||
let gh = Forge::GitHub {
|
let gh = Forge {
|
||||||
|
host: "github.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into(),
|
repo: "bar".into(),
|
||||||
};
|
};
|
||||||
@@ -353,7 +396,8 @@ mod tests {
|
|||||||
"https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(),
|
"https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
let gl = Forge::GitLab {
|
let gl = Forge {
|
||||||
|
host: "gitlab.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into(),
|
repo: "bar".into(),
|
||||||
};
|
};
|
||||||
@@ -363,6 +407,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every table entry must be substitutable: a template missing one of
|
||||||
|
/// the placeholders would download from a literal `{name}` URL.
|
||||||
|
#[test]
|
||||||
|
fn forge_templates_carry_every_placeholder() {
|
||||||
|
for (host, entry) in &FORGES_DATA.forges {
|
||||||
|
assert!(
|
||||||
|
!entry.tarball_templates.is_empty(),
|
||||||
|
"forge '{host}' has no tarball template"
|
||||||
|
);
|
||||||
|
for template in &entry.tarball_templates {
|
||||||
|
for placeholder in ["{owner}", "{repo}", "{tag}"] {
|
||||||
|
assert!(
|
||||||
|
template.contains(placeholder),
|
||||||
|
"template '{template}' of forge '{host}' lacks {placeholder}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `kind` field is validated at load time: an unknown forge family
|
||||||
|
/// fails the parse instead of being silently accepted.
|
||||||
|
#[test]
|
||||||
|
fn forge_kind_is_validated() {
|
||||||
|
assert!(serde_yaml::from_str::<ForgeEntry>("kind: github").is_err());
|
||||||
|
assert!(
|
||||||
|
serde_yaml::from_str::<ForgeEntry>(
|
||||||
|
"kind: github\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_yaml::from_str::<ForgeEntry>(
|
||||||
|
"kind: codeberg\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitized_tag_versions() {
|
fn sanitized_tag_versions() {
|
||||||
assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string()));
|
assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string()));
|
||||||
@@ -466,7 +549,8 @@ mod tests {
|
|||||||
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
origin.forge,
|
origin.forge,
|
||||||
Some(Forge::GitHub {
|
Some(Forge {
|
||||||
|
host: "github.com",
|
||||||
owner: "foo".into(),
|
owner: "foo".into(),
|
||||||
repo: "bar".into()
|
repo: "bar".into()
|
||||||
})
|
})
|
||||||
|
|||||||
+156
-150
@@ -1,11 +1,11 @@
|
|||||||
//! The `pkh new` interactive wizard.
|
//! The `pkh new` interactive wizard.
|
||||||
//!
|
//!
|
||||||
//! [`run`] is the single entry point: on an interactive terminal it asks the
|
//! [`run`] is the single entry point: when the prompter can interact it asks
|
||||||
//! questions of the spec's "Proposed UX" transcript, fills a
|
//! the questions of the spec's "Proposed UX" transcript, fills a
|
||||||
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
||||||
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
||||||
//! detection and validation — so the non-interactive and interactive paths
|
//! detection and validation — so the non-interactive and interactive paths
|
||||||
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes
|
//! cannot drift apart. Headless (or with `--defaults`) it goes
|
||||||
//! straight through [`options::resolve`], whose error lists every missing
|
//! straight through [`options::resolve`], whose error lists every missing
|
||||||
//! answer.
|
//! answer.
|
||||||
//!
|
//!
|
||||||
@@ -13,21 +13,21 @@
|
|||||||
//! verification builds of the spec ([`offer_verification`]); a failed
|
//! verification builds of the spec ([`offer_verification`]); a failed
|
||||||
//! verification never undoes the scaffold.
|
//! verification never undoes the scaffold.
|
||||||
//!
|
//!
|
||||||
//! The prompt calls live in `run_wizard` and `offer_verification` only;
|
//! The prompter calls live in `run_wizard` and `offer_verification` only;
|
||||||
//! everything else in this module is pure and unit-tested.
|
//! everything else in this module is pure and unit-tested.
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::IsTerminal;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use indicatif::MultiProgress;
|
use indicatif::MultiProgress;
|
||||||
|
|
||||||
use crate::new::detect::{self, Detection};
|
use crate::new::detect::{self, Detection};
|
||||||
use crate::new::git;
|
use crate::new::git;
|
||||||
|
use crate::new::licenses;
|
||||||
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
|
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
|
||||||
use crate::new::origin::GitOrigin;
|
use crate::new::origin::GitOrigin;
|
||||||
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
|
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
|
||||||
use crate::ui::prompt;
|
use crate::report::{Prompter, Validator};
|
||||||
|
|
||||||
/// Answer of the "where is the source code?" question: fresh skeleton.
|
/// Answer of the "where is the source code?" question: fresh skeleton.
|
||||||
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
||||||
@@ -39,22 +39,8 @@ const SOURCE_PATH: &str = "Package the sources in another directory…";
|
|||||||
/// The "everything else" entry of the license menu.
|
/// The "everything else" entry of the license menu.
|
||||||
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
||||||
|
|
||||||
/// The curated SPDX identifiers of the license menu (without the free-text
|
/// Labels of the interactive `select` questions. The prompter renders
|
||||||
/// entry), matching [`options::License::parse`]'s known spellings.
|
/// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
|
||||||
pub const KNOWN_LICENSES: [&str; 9] = [
|
|
||||||
"MIT",
|
|
||||||
"Apache-2.0",
|
|
||||||
"GPL-2.0+",
|
|
||||||
"GPL-3.0+",
|
|
||||||
"LGPL-2.1+",
|
|
||||||
"LGPL-3.0+",
|
|
||||||
"BSD-2-Clause",
|
|
||||||
"BSD-3-Clause",
|
|
||||||
"ISC",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Labels of the interactive `select` questions. `prompt::select` renders
|
|
||||||
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
|
|
||||||
/// formatting of its own — so each label carries its own separator:
|
/// formatting of its own — so each label carries its own separator:
|
||||||
/// field-style prompts end with `": "`, question-style ones with `"? "`.
|
/// field-style prompts end with `": "`, question-style ones with `"? "`.
|
||||||
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
|
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
|
||||||
@@ -75,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
|
|||||||
ORIG_LABEL,
|
ORIG_LABEL,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
|
/// Run the `pkh new` flow: the wizard when the prompter can interact,
|
||||||
/// [`options::resolve`] otherwise (and with `--defaults`).
|
/// plain [`options::resolve`] otherwise (and with `--defaults`).
|
||||||
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
if cli.defaults || !is_interactive() {
|
if cli.defaults || !prompter.interactive() {
|
||||||
return Ok(options::resolve(cli).await?);
|
return Ok(options::resolve(cli).await?);
|
||||||
}
|
}
|
||||||
run_wizard(cli).await
|
run_wizard(cli, prompter).await
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether both ends of the terminal are interactive; the wizard and the
|
|
||||||
/// verification offers only run when this holds (the prompts' non-TTY
|
|
||||||
/// fallbacks would otherwise silently take defaults).
|
|
||||||
fn is_interactive() -> bool {
|
|
||||||
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wizard question flow (spec "Proposed UX"), in order:
|
/// The wizard question flow (spec "Proposed UX"), in order:
|
||||||
@@ -100,7 +79,10 @@ fn is_interactive() -> bool {
|
|||||||
/// (`empty` template only), git init — then the summary screen and the
|
/// (`empty` template only), git init — then the summary screen and the
|
||||||
/// final `Generate?` confirmation. Every question with an explicit flag
|
/// final `Generate?` confirmation. Every question with an explicit flag
|
||||||
/// answer is skipped (flag > detected/probe > default merge order).
|
/// answer is skipped (flag > detected/probe > default merge order).
|
||||||
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
async fn run_wizard(
|
||||||
|
mut cli: NewCli,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
|
) -> Result<NewOptions, Box<dyn Error>> {
|
||||||
let cwd = std::env::current_dir()?;
|
let cwd = std::env::current_dir()?;
|
||||||
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
||||||
let (detection, mut probe) = detect_and_probe(&detect_dir);
|
let (detection, mut probe) = detect_and_probe(&detect_dir);
|
||||||
@@ -114,7 +96,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// basename of the current directory.
|
// basename of the current directory.
|
||||||
if cli.name.is_none() {
|
if cli.name.is_none() {
|
||||||
let default = default_package_name(&cwd, probe.as_ref());
|
let default = default_package_name(&cwd, probe.as_ref());
|
||||||
let answer = ask_text("Package name", &default, options::validate_source_name)?;
|
let answer = ask_text(
|
||||||
|
prompter,
|
||||||
|
"Package name",
|
||||||
|
&default,
|
||||||
|
options::validate_source_name,
|
||||||
|
)?;
|
||||||
cli.name = Some(answer);
|
cli.name = Some(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +139,10 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
LanguageChoice::Ask(preselected) => {
|
LanguageChoice::Ask(preselected) => {
|
||||||
let menu = language_menu(&[]);
|
let menu = language_menu(&[]);
|
||||||
let default = preselected
|
let default = preselected
|
||||||
.unwrap_or(TemplateId::Empty)
|
.unwrap_or(TemplateId::EMPTY)
|
||||||
.display_name()
|
.display_name()
|
||||||
.to_string();
|
.to_string();
|
||||||
let id = select_template(&menu, &default)?;
|
let id = select_template(prompter, &menu, &default)?;
|
||||||
cli.lang = Some(id.as_str().to_string());
|
cli.lang = Some(id.as_str().to_string());
|
||||||
}
|
}
|
||||||
LanguageChoice::Ambiguous(candidates) => {
|
LanguageChoice::Ambiguous(candidates) => {
|
||||||
@@ -170,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
let menu = language_menu(&candidates);
|
let menu = language_menu(&candidates);
|
||||||
let id = select_template(&menu, &menu[0])?;
|
let id = select_template(prompter, &menu, &menu[0])?;
|
||||||
cli.lang = Some(id.as_str().to_string());
|
cli.lang = Some(id.as_str().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +166,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// The rust toolchain pin of the packaged project does not travel into
|
// The rust toolchain pin of the packaged project does not travel into
|
||||||
// the chroot build: surface it now so a too-old pin is not a surprise
|
// the chroot build: surface it now so a too-old pin is not a surprise
|
||||||
// when `pkh deb` compiles with the distribution's rustc.
|
// when `pkh deb` compiles with the distribution's rustc.
|
||||||
let toolchain_pin = if template == TemplateId::Rust {
|
let toolchain_pin = if template == TemplateId::RUST {
|
||||||
probe.as_ref().and_then(|p| p.toolchain_pin.clone())
|
probe.as_ref().and_then(|p| p.toolchain_pin.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -206,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
SOURCE_HERE
|
SOURCE_HERE
|
||||||
};
|
};
|
||||||
let answer = select_from(SOURCE_LABEL, &options, default, |answer| {
|
let answer = select_from(prompter, SOURCE_LABEL, &options, default, |answer| {
|
||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
if answer == SOURCE_HERE {
|
if answer == SOURCE_HERE {
|
||||||
cli.source = Some(cwd.clone());
|
cli.source = Some(cwd.clone());
|
||||||
} else if answer == SOURCE_PATH {
|
} else if answer == SOURCE_PATH {
|
||||||
let validator = |path: &str| validate_directory_answer(path);
|
let validator = |path: &str| validate_directory_answer(path);
|
||||||
let path = prompt::text("Source directory", "", Some(&validator))?;
|
let path = prompter.text("Source directory", "", Some(&validator))?;
|
||||||
cli.source = Some(PathBuf::from(path));
|
cli.source = Some(PathBuf::from(path));
|
||||||
}
|
}
|
||||||
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
||||||
@@ -279,7 +266,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.unwrap_or_else(|| "0.1.0".to_string());
|
.unwrap_or_else(|| "0.1.0".to_string());
|
||||||
let revision = cli.revision.unwrap_or(1);
|
let revision = cli.revision.unwrap_or(1);
|
||||||
let validate = move |version: &str| options::validate_upstream_version(version, revision);
|
let validate = move |version: &str| options::validate_upstream_version(version, revision);
|
||||||
let answer = ask_text("Upstream version", &default, validate)?;
|
let answer = ask_text(prompter, "Upstream version", &default, validate)?;
|
||||||
cli.upstream_version = Some(answer.clone());
|
cli.upstream_version = Some(answer.clone());
|
||||||
|
|
||||||
// The typed version names an existing tag HEAD is not on: offer to
|
// The typed version names an existing tag HEAD is not on: offer to
|
||||||
@@ -292,7 +279,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
"Version {answer} matches tag {tag}, but HEAD is not that \
|
"Version {answer} matches tag {tag}, but HEAD is not that \
|
||||||
tag. Check out {tag} now?"
|
tag. Check out {tag} now?"
|
||||||
);
|
);
|
||||||
if prompt::confirm(&question, false)? {
|
if prompter.confirm(&question, false)? {
|
||||||
let dir = packaged_dir.as_deref().expect("origin implies a directory");
|
let dir = packaged_dir.as_deref().expect("origin implies a directory");
|
||||||
crate::new::origin::checkout_tag(dir, tag)?;
|
crate::new::origin::checkout_tag(dir, tag)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -324,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
} else {
|
} else {
|
||||||
"Snapshot this working tree".to_string()
|
"Snapshot this working tree".to_string()
|
||||||
};
|
};
|
||||||
let answer = select_from(ORIG_LABEL, &labels, &default, |answer| {
|
let answer = select_from(prompter, ORIG_LABEL, &labels, &default, |answer| {
|
||||||
labels.contains(&answer.to_string())
|
labels.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
let chosen = choices
|
let chosen = choices
|
||||||
@@ -335,14 +322,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
cli.orig_from = Some(chosen.to_string());
|
cli.orig_from = Some(chosen.to_string());
|
||||||
if chosen == "path" {
|
if chosen == "path" {
|
||||||
let validator = |path: &str| options::validate_orig_path(path);
|
let validator = |path: &str| options::validate_orig_path(path);
|
||||||
let path = prompt::text("Tarball path or URL", "", Some(&validator))?;
|
let path = prompter.text("Tarball path or URL", "", Some(&validator))?;
|
||||||
cli.orig_path = Some(path);
|
cli.orig_path = Some(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Debian revision.
|
// 5. Debian revision.
|
||||||
if cli.revision.is_none() {
|
if cli.revision.is_none() {
|
||||||
let answer = ask_text("Debian revision", "1", validate_revision_answer)?;
|
let answer = ask_text(prompter, "Debian revision", "1", validate_revision_answer)?;
|
||||||
cli.revision = answer.parse::<u32>().ok();
|
cli.revision = answer.parse::<u32>().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.and_then(|p| p.description.clone())
|
.and_then(|p| p.description.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let validate = required_answer("the description");
|
let validate = required_answer("the description");
|
||||||
let answer = ask_text("One-line description", &default, validate)?;
|
let answer = ask_text(prompter, "One-line description", &default, validate)?;
|
||||||
cli.description = Some(answer);
|
cli.description = Some(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
options::validate_homepage(url)
|
options::validate_homepage(url)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let answer = ask_text("Homepage (blank to skip)", &default, validate)?;
|
let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
|
||||||
if !answer.is_empty() {
|
if !answer.is_empty() {
|
||||||
cli.homepage = Some(answer);
|
cli.homepage = Some(answer);
|
||||||
}
|
}
|
||||||
@@ -386,12 +373,17 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
.or_else(|| detect::sniff_license(&detect_dir));
|
.or_else(|| detect::sniff_license(&detect_dir));
|
||||||
let (default, custom_default) = license_question_default(detected.as_deref());
|
let (default, custom_default) = license_question_default(detected.as_deref());
|
||||||
let options = license_menu();
|
let options = license_menu();
|
||||||
let answer = select_from(LICENSE_LABEL, &options, &default, |answer| {
|
let answer = select_from(prompter, LICENSE_LABEL, &options, &default, |answer| {
|
||||||
options.contains(&answer.to_string())
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
if answer == LICENSE_OTHER {
|
if answer == LICENSE_OTHER {
|
||||||
let validate = required_answer("the license identifier");
|
let validate = required_answer("the license identifier");
|
||||||
let license = ask_text("License (SPDX identifier)", &custom_default, validate)?;
|
let license = ask_text(
|
||||||
|
prompter,
|
||||||
|
"License (SPDX identifier)",
|
||||||
|
&custom_default,
|
||||||
|
validate,
|
||||||
|
)?;
|
||||||
cli.license = Some(license);
|
cli.license = Some(license);
|
||||||
} else {
|
} else {
|
||||||
cli.license = Some(answer);
|
cli.license = Some(answer);
|
||||||
@@ -403,12 +395,17 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// same `validate_command` bar as `resolve` applies (which also
|
// same `validate_command` bar as `resolve` applies (which also
|
||||||
// requires a non-empty answer), so an unusable probe is withheld and
|
// requires a non-empty answer), so an unusable probe is withheld and
|
||||||
// invalid input re-asks here instead of failing late in `resolve`.
|
// invalid input re-asks here instead of failing late in `resolve`.
|
||||||
if cli.command.is_none() && template != TemplateId::Empty {
|
if cli.command.is_none() && template != TemplateId::EMPTY {
|
||||||
let default = probe
|
let default = probe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.command.clone())
|
.and_then(|p| p.command.clone())
|
||||||
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
||||||
let command = ask_text("Command name", &default, options::validate_command)?;
|
let command = ask_text(
|
||||||
|
prompter,
|
||||||
|
"Command name",
|
||||||
|
&default,
|
||||||
|
options::validate_command,
|
||||||
|
)?;
|
||||||
cli.command = Some(command);
|
cli.command = Some(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,20 +431,29 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
|
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
|
||||||
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?);
|
cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Target distribution.
|
// 11. Target distribution. The menu derives from the distro data pkh
|
||||||
|
// ships (sorted); ubuntu is moved to the front when present so it
|
||||||
|
// stays the menu's first entry and fallback default as it has always
|
||||||
|
// been — the selector positions on a default value, not an index.
|
||||||
if cli.dist.is_none() {
|
if cli.dist.is_none() {
|
||||||
let vendor = crate::build::env::current_vendor().to_lowercase();
|
let vendor = crate::build::env::current_vendor().to_lowercase();
|
||||||
let options = vec!["ubuntu".to_string(), "debian".to_string()];
|
let mut options = crate::distro_info::supported_dists();
|
||||||
|
if let Some(pos) = options.iter().position(|d| d == "ubuntu")
|
||||||
|
&& pos > 0
|
||||||
|
{
|
||||||
|
let ubuntu = options.remove(pos);
|
||||||
|
options.insert(0, ubuntu);
|
||||||
|
}
|
||||||
let default = if options.contains(&vendor) {
|
let default = if options.contains(&vendor) {
|
||||||
vendor
|
vendor
|
||||||
} else {
|
} else {
|
||||||
"ubuntu".to_string()
|
"ubuntu".to_string()
|
||||||
};
|
};
|
||||||
let answer = select_from(DIST_LABEL, &options, &default, |answer| {
|
let answer = select_from(prompter, DIST_LABEL, &options, &default, |answer| {
|
||||||
answer == "ubuntu" || answer == "debian"
|
options.contains(&answer.to_string())
|
||||||
})?;
|
})?;
|
||||||
cli.dist = Some(answer);
|
cli.dist = Some(answer);
|
||||||
}
|
}
|
||||||
@@ -460,7 +466,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
if cli.series.is_none() {
|
if cli.series.is_none() {
|
||||||
match crate::distro_info::get_ordered_series_name(&dist).await {
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
Ok(series) if !series.is_empty() => {
|
Ok(series) if !series.is_empty() => {
|
||||||
let answer = prompt::select(SERIES_LABEL, &series, &series[0])?;
|
let answer = prompter.select(SERIES_LABEL, &series, &series[0])?;
|
||||||
cli.series = Some(answer);
|
cli.series = Some(answer);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -473,9 +479,10 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 13. Metapackage Depends (empty template only).
|
// 13. Metapackage Depends (empty template only).
|
||||||
if template == TemplateId::Empty && cli.depends.is_empty() {
|
if template == TemplateId::EMPTY && cli.depends.is_empty() {
|
||||||
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
|
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
|
||||||
let answer = ask_text(
|
let answer = ask_text(
|
||||||
|
prompter,
|
||||||
"Depends (metapackage, comma-separated, blank for an empty base)",
|
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||||
"",
|
"",
|
||||||
validate,
|
validate,
|
||||||
@@ -498,7 +505,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// `--no-git` already declined, or there is nothing to initialize.
|
// `--no-git` already declined, or there is nothing to initialize.
|
||||||
cli.git = false;
|
cli.git = false;
|
||||||
} else {
|
} else {
|
||||||
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?;
|
cli.git = prompter.confirm("Initialize a git repository?", cli.git)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same pipeline as the non-interactive path: one
|
// Resolve through the same pipeline as the non-interactive path: one
|
||||||
@@ -508,8 +515,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
// The meson/cmake opt-in question of the spec's template table: does the
|
// The meson/cmake opt-in question of the spec's template table: does the
|
||||||
// build resolve libraries through pkg-config? The project files prefill
|
// build resolve libraries through pkg-config? The project files prefill
|
||||||
// the default (dependency() / pkg_check_modules calls found).
|
// the default (dependency() / pkg_check_modules calls found).
|
||||||
if matches!(template, TemplateId::Meson | TemplateId::Cmake)
|
if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
|
||||||
&& prompt::confirm(
|
&& prompter.confirm(
|
||||||
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
||||||
pkg_config_hint(&detect_dir, template),
|
pkg_config_hint(&detect_dir, template),
|
||||||
)?
|
)?
|
||||||
@@ -518,8 +525,8 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wizard-only extras (default off).
|
// Wizard-only extras (default off).
|
||||||
if template != TemplateId::Empty
|
if template != TemplateId::EMPTY
|
||||||
&& prompt::confirm(
|
&& prompter.confirm(
|
||||||
"Add an autopkgtest smoke test (debian/tests/control)?",
|
"Add an autopkgtest smoke test (debian/tests/control)?",
|
||||||
false,
|
false,
|
||||||
)?
|
)?
|
||||||
@@ -527,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
|||||||
opts.autopkgtest = true;
|
opts.autopkgtest = true;
|
||||||
}
|
}
|
||||||
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
||||||
&& prompt::confirm("Add a debian/watch release watcher?", false)?
|
&& prompter.confirm("Add a debian/watch release watcher?", false)?
|
||||||
{
|
{
|
||||||
opts.watch = Some(watch);
|
opts.watch = Some(watch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
||||||
// nothing written (generation is all-or-nothing later anyway).
|
// nothing written (generation is all-or-nothing later anyway).
|
||||||
println!("{}", summary_text(&opts, toolchain_pin.as_deref()));
|
prompter.present(&summary_text(&opts, toolchain_pin.as_deref()));
|
||||||
if !prompt::confirm("Generate?", true)? {
|
if !prompter.confirm("Generate?", true)? {
|
||||||
return Err("Aborted: nothing was written to disk.".into());
|
return Err("Aborted: nothing was written to disk.".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,9 +564,10 @@ pub async fn offer_verification(
|
|||||||
outcome: &ScaffoldOutcome,
|
outcome: &ScaffoldOutcome,
|
||||||
multi: &MultiProgress,
|
multi: &MultiProgress,
|
||||||
no_verify: bool,
|
no_verify: bool,
|
||||||
|
prompter: &dyn Prompter,
|
||||||
) {
|
) {
|
||||||
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
||||||
let display = crate::ui::display_path(&tree);
|
let display = crate::report::display_path(&tree);
|
||||||
let display = if display.is_empty() {
|
let display = if display.is_empty() {
|
||||||
".".to_string()
|
".".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -569,7 +577,7 @@ pub async fn offer_verification(
|
|||||||
if outcome.vendoring_failed {
|
if outcome.vendoring_failed {
|
||||||
// Set apart from the surrounding success output by blank lines: a
|
// Set apart from the surrounding success output by blank lines: a
|
||||||
// single warning between two success lines is easy to miss.
|
// single warning between two success lines is easy to miss.
|
||||||
println!();
|
prompter.present("");
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"The Cargo dependencies could NOT be vendored: this package will \
|
"The Cargo dependencies could NOT be vendored: this package will \
|
||||||
not build until the vendoring is completed by hand:\n\
|
not build until the vendoring is completed by hand:\n\
|
||||||
@@ -577,10 +585,10 @@ pub async fn offer_verification(
|
|||||||
\x20 2. add the printed source replacement to .cargo/config.toml, \
|
\x20 2. add the printed source replacement to .cargo/config.toml, \
|
||||||
plus `[net] offline = true`"
|
plus `[net] offline = true`"
|
||||||
);
|
);
|
||||||
println!();
|
prompter.present("");
|
||||||
}
|
}
|
||||||
|
|
||||||
if no_verify || !is_interactive() {
|
if no_verify || !prompter.interactive() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +597,7 @@ pub async fn offer_verification(
|
|||||||
} else {
|
} else {
|
||||||
"Verify with `pkh build` now?"
|
"Verify with `pkh build` now?"
|
||||||
};
|
};
|
||||||
let verify_source = match prompt::confirm(build_offer, !outcome.vendoring_failed) {
|
let verify_source = match prompter.confirm(build_offer, !outcome.vendoring_failed) {
|
||||||
Ok(answer) => answer,
|
Ok(answer) => answer,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
@@ -597,8 +605,13 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi));
|
||||||
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
|
if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions {
|
||||||
|
source: Some(tree.clone()),
|
||||||
|
options: crate::build::SourceBuildOptions::default(),
|
||||||
|
view: &*ui,
|
||||||
|
prompter,
|
||||||
|
}) {
|
||||||
log::error!("Verification source build failed: {e}");
|
log::error!("Verification source build failed: {e}");
|
||||||
log::info!(
|
log::info!(
|
||||||
"The scaffolded tree is intact. Inspect it, then retry with \
|
"The scaffolded tree is intact. Inspect it, then retry with \
|
||||||
@@ -612,7 +625,7 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let verify_deb = match prompt::confirm(
|
let verify_deb = match prompter.confirm(
|
||||||
"Verify with `pkh deb` now? (needs network + build deps)",
|
"Verify with `pkh deb` now? (needs network + build deps)",
|
||||||
false,
|
false,
|
||||||
) {
|
) {
|
||||||
@@ -623,20 +636,13 @@ pub async fn offer_verification(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
let view = crate::ui::deb::DebUi::new(multi);
|
||||||
if let Err(e) = crate::deb::build_binary_package(
|
if let Err(e) = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
|
||||||
None,
|
series: Some(opts.series.clone()),
|
||||||
Some(&opts.series),
|
cwd: Some(tree.clone()),
|
||||||
None,
|
view: &view,
|
||||||
Some(&tree),
|
..Default::default()
|
||||||
false,
|
})
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
ui,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::error!("Verification binary build failed: {e}");
|
log::error!("Verification binary build failed: {e}");
|
||||||
@@ -686,8 +692,8 @@ fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool {
|
|||||||
/// CMakeLists.txt).
|
/// CMakeLists.txt).
|
||||||
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
|
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
|
||||||
let (file, needles): (&str, &[&str]) = match template {
|
let (file, needles): (&str, &[&str]) = match template {
|
||||||
TemplateId::Meson => ("meson.build", &["dependency("]),
|
TemplateId::MESON => ("meson.build", &["dependency("]),
|
||||||
TemplateId::Cmake => (
|
TemplateId::CMAKE => (
|
||||||
"CMakeLists.txt",
|
"CMakeLists.txt",
|
||||||
&[
|
&[
|
||||||
"pkg_check_modules",
|
"pkg_check_modules",
|
||||||
@@ -727,7 +733,8 @@ fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
|
|||||||
.copied()
|
.copied()
|
||||||
.chain(
|
.chain(
|
||||||
TemplateId::all()
|
TemplateId::all()
|
||||||
.into_iter()
|
.iter()
|
||||||
|
.copied()
|
||||||
.filter(|id| !candidates.contains(id)),
|
.filter(|id| !candidates.contains(id)),
|
||||||
)
|
)
|
||||||
.map(|id| id.display_name().to_string())
|
.map(|id| id.display_name().to_string())
|
||||||
@@ -770,38 +777,45 @@ fn language_choice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The license menu: the curated SPDX list plus the free-text entry.
|
/// The license menu: the `menu` labels of the bundled license table
|
||||||
|
/// (`data/licenses.yml`) plus the free-text entry.
|
||||||
fn license_menu() -> Vec<String> {
|
fn license_menu() -> Vec<String> {
|
||||||
KNOWN_LICENSES
|
licenses::entries()
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.map(|entry| entry.menu.clone())
|
||||||
.map(str::to_string)
|
|
||||||
.chain(std::iter::once(LICENSE_OTHER.to_string()))
|
.chain(std::iter::once(LICENSE_OTHER.to_string()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The defaults of the license question for a probed SPDX identifier: the
|
/// The defaults of the license question for a probed SPDX identifier: the
|
||||||
/// matching curated entry (case-insensitive) is preselected; anything else
|
/// matching curated entry (case-insensitive over the menu labels) is
|
||||||
/// preselects the free-text entry prefilled with the probe. Without a probe
|
/// preselected; anything else preselects the free-text entry prefilled with
|
||||||
/// the curated list defaults to MIT.
|
/// the probe. Without a probe the curated list defaults to MIT (the first
|
||||||
|
/// menu entry of the bundled table).
|
||||||
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
||||||
match probe_license {
|
match probe_license {
|
||||||
Some(license) => match KNOWN_LICENSES
|
Some(license) => {
|
||||||
.iter()
|
match licenses::entries()
|
||||||
.find(|k| k.eq_ignore_ascii_case(license))
|
.iter()
|
||||||
{
|
.find(|entry| entry.menu.eq_ignore_ascii_case(license))
|
||||||
Some(known) => ((*known).to_string(), String::new()),
|
{
|
||||||
None => (LICENSE_OTHER.to_string(), license.to_string()),
|
Some(known) => (known.menu.clone(), String::new()),
|
||||||
},
|
None => (LICENSE_OTHER.to_string(), license.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
None => ("MIT".to_string(), String::new()),
|
None => ("MIT".to_string(), String::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the language question until a known template label (or CLI
|
/// Ask the language question until a known template label (or CLI
|
||||||
/// identifier) is answered — the selector allows typing arbitrary text.
|
/// identifier) is answered — the selector allows typing arbitrary text.
|
||||||
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
|
fn select_template(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
|
options: &[String],
|
||||||
|
default: &str,
|
||||||
|
) -> Result<TemplateId, Box<dyn Error>> {
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::select(LANGUAGE_LABEL, options, default)?;
|
let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
|
||||||
match TemplateId::from_label(&answer) {
|
match TemplateId::from_label(&answer) {
|
||||||
Some(id) => return Ok(id),
|
Some(id) => return Ok(id),
|
||||||
None => log::warn!(
|
None => log::warn!(
|
||||||
@@ -815,13 +829,14 @@ fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<
|
|||||||
/// Ask a `select` question until `accept` holds for the answer (the
|
/// Ask a `select` question until `accept` holds for the answer (the
|
||||||
/// selector allows typing arbitrary text, which callers may need to reject).
|
/// selector allows typing arbitrary text, which callers may need to reject).
|
||||||
fn select_from(
|
fn select_from(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
label: &str,
|
label: &str,
|
||||||
options: &[String],
|
options: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
accept: impl Fn(&str) -> bool,
|
accept: impl Fn(&str) -> bool,
|
||||||
) -> Result<String, Box<dyn Error>> {
|
) -> Result<String, Box<dyn Error>> {
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::select(label, options, default)?;
|
let answer = prompter.select(label, options, default)?;
|
||||||
if accept(&answer) {
|
if accept(&answer) {
|
||||||
return Ok(answer);
|
return Ok(answer);
|
||||||
}
|
}
|
||||||
@@ -834,7 +849,7 @@ fn select_from(
|
|||||||
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
|
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
|
||||||
/// the question is asked without a default instead of offering one that
|
/// the question is asked without a default instead of offering one that
|
||||||
/// Enter would accept verbatim.
|
/// Enter would accept verbatim.
|
||||||
fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a str {
|
fn offered_default<'a>(default: &'a str, validate: &Validator) -> &'a str {
|
||||||
if default.is_empty() || validate(default).is_ok() {
|
if default.is_empty() || validate(default).is_ok() {
|
||||||
default
|
default
|
||||||
} else {
|
} else {
|
||||||
@@ -846,11 +861,7 @@ fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a st
|
|||||||
/// `default`, and whatever answer is finally proposed — typed or the
|
/// `default`, and whatever answer is finally proposed — typed or the
|
||||||
/// default — must pass `validate`. `Err` carries the validation error so the
|
/// default — must pass `validate`. `Err` carries the validation error so the
|
||||||
/// caller re-asks with it.
|
/// caller re-asks with it.
|
||||||
fn accept_answer(
|
fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
|
||||||
answer: &str,
|
|
||||||
default: &str,
|
|
||||||
validate: &prompt::Validator,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let answer = if answer.is_empty() { default } else { answer };
|
let answer = if answer.is_empty() { default } else { answer };
|
||||||
validate(answer).map(|_| answer.to_string())
|
validate(answer).map(|_| answer.to_string())
|
||||||
}
|
}
|
||||||
@@ -864,6 +875,7 @@ fn accept_answer(
|
|||||||
/// validation error) — probe data can never bypass validation and only blow
|
/// validation error) — probe data can never bypass validation and only blow
|
||||||
/// up later in [`options::resolve`].
|
/// up later in [`options::resolve`].
|
||||||
fn ask_text(
|
fn ask_text(
|
||||||
|
prompter: &dyn Prompter,
|
||||||
label: &str,
|
label: &str,
|
||||||
default: &str,
|
default: &str,
|
||||||
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||||
@@ -885,7 +897,7 @@ fn ask_text(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loop {
|
loop {
|
||||||
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
let answer = prompter.text(label, default, Some(&accept_empty))?;
|
||||||
// Typed non-empty answers were already validated by the prompt; only
|
// Typed non-empty answers were already validated by the prompt; only
|
||||||
// an empty one resolves to the default, pre-decided above.
|
// an empty one resolves to the default, pre-decided above.
|
||||||
let answer = if answer.is_empty() {
|
let answer = if answer.is_empty() {
|
||||||
@@ -1030,12 +1042,12 @@ pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
|
|||||||
" debian/control Source + 1 binary (Architecture: {})",
|
" debian/control Source + 1 binary (Architecture: {})",
|
||||||
template.architecture(opts)
|
template.architecture(opts)
|
||||||
));
|
));
|
||||||
if opts.template == TemplateId::Empty {
|
if opts.template == TemplateId::EMPTY {
|
||||||
// The Depends list is the payload of the metapackage flavor.
|
// The Depends list is the payload of the metapackage flavor.
|
||||||
if !opts.depends.is_empty() {
|
if !opts.depends.is_empty() {
|
||||||
lines.push(format!(" Depends {}", opts.depends.join(", ")));
|
lines.push(format!(" Depends {}", opts.depends.join(", ")));
|
||||||
}
|
}
|
||||||
} else if opts.template == TemplateId::Rust {
|
} else if opts.template == TemplateId::RUST {
|
||||||
// Nothing is vendored yet at this point: only announce that the
|
// Nothing is vendored yet at this point: only announce that the
|
||||||
// generation will attempt it.
|
// generation will attempt it.
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -1091,7 +1103,7 @@ pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
|
|||||||
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
|
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opts.template == TemplateId::Rust && templates::find_on_path("cargo").is_none() {
|
if opts.template == TemplateId::RUST && templates::find_on_path("cargo").is_none() {
|
||||||
lines.push(
|
lines.push(
|
||||||
" ! cargo not found on PATH: dependencies cannot be vendored at \
|
" ! cargo not found on PATH: dependencies cannot be vendored at \
|
||||||
scaffold time; the package will not build until you run \
|
scaffold time; the package will not build until you run \
|
||||||
@@ -1159,7 +1171,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn language_menu_lists_candidates_first() {
|
fn language_menu_lists_candidates_first() {
|
||||||
let menu = language_menu(&[Tid::Makefile, Tid::Rust]);
|
let menu = language_menu(&[Tid::MAKEFILE, Tid::RUST]);
|
||||||
assert_eq!(menu[0], "Generic (Makefile)");
|
assert_eq!(menu[0], "Generic (Makefile)");
|
||||||
assert_eq!(menu[1], "Rust (Cargo.toml)");
|
assert_eq!(menu[1], "Rust (Cargo.toml)");
|
||||||
// The remaining seven follow in registry order, no duplicates.
|
// The remaining seven follow in registry order, no duplicates.
|
||||||
@@ -1176,8 +1188,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn language_choice_flag_wins_over_detection() {
|
fn language_choice_flag_wins_over_detection() {
|
||||||
let detections = [
|
let detections = [
|
||||||
Detection::Single(Tid::Rust),
|
Detection::Single(Tid::RUST),
|
||||||
Detection::Ambiguous(vec![Tid::Rust, Tid::Python]),
|
Detection::Ambiguous(vec![Tid::RUST, Tid::PYTHON]),
|
||||||
Detection::Empty,
|
Detection::Empty,
|
||||||
];
|
];
|
||||||
for detection in &detections {
|
for detection in &detections {
|
||||||
@@ -1198,21 +1210,21 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn language_choice_without_flag_follows_detection() {
|
fn language_choice_without_flag_follows_detection() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
language_choice(None, &Detection::Single(Tid::Rust), true),
|
language_choice(None, &Detection::Single(Tid::RUST), true),
|
||||||
LanguageChoice::Detected(Tid::Rust)
|
LanguageChoice::Detected(Tid::RUST)
|
||||||
);
|
);
|
||||||
// Skeleton run: ask, preselecting the detected ecosystem.
|
// Skeleton run: ask, preselecting the detected ecosystem.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
language_choice(None, &Detection::Single(Tid::Rust), false),
|
language_choice(None, &Detection::Single(Tid::RUST), false),
|
||||||
LanguageChoice::Ask(Some(Tid::Rust))
|
LanguageChoice::Ask(Some(Tid::RUST))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
language_choice(
|
language_choice(
|
||||||
None,
|
None,
|
||||||
&Detection::Ambiguous(vec![Tid::Go, Tid::Python]),
|
&Detection::Ambiguous(vec![Tid::GO, Tid::PYTHON]),
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
LanguageChoice::Ambiguous(vec![Tid::Go, Tid::Python])
|
LanguageChoice::Ambiguous(vec![Tid::GO, Tid::PYTHON])
|
||||||
);
|
);
|
||||||
// Nothing detected: plain menu, the empty template preselected.
|
// Nothing detected: plain menu, the empty template preselected.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1224,7 +1236,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn license_menu_and_defaults() {
|
fn license_menu_and_defaults() {
|
||||||
let menu = license_menu();
|
let menu = license_menu();
|
||||||
assert_eq!(menu.len(), KNOWN_LICENSES.len() + 1);
|
assert_eq!(menu.len(), licenses::entries().len() + 1);
|
||||||
assert_eq!(menu[0], "MIT");
|
assert_eq!(menu[0], "MIT");
|
||||||
assert_eq!(menu.last().unwrap(), LICENSE_OTHER);
|
assert_eq!(menu.last().unwrap(), LICENSE_OTHER);
|
||||||
|
|
||||||
@@ -1282,10 +1294,7 @@ mod tests {
|
|||||||
fn orig_origin_choices_preorder_by_detection() {
|
fn orig_origin_choices_preorder_by_detection() {
|
||||||
use crate::new::origin::Forge;
|
use crate::new::origin::Forge;
|
||||||
let tagged_forge = GitOrigin {
|
let tagged_forge = GitOrigin {
|
||||||
forge: Some(Forge::GitHub {
|
forge: Some(Forge::parse("https://github.com/foo/bar").unwrap()),
|
||||||
owner: "foo".into(),
|
|
||||||
repo: "bar".into(),
|
|
||||||
}),
|
|
||||||
head_tag: Some("v1.4.0".into()),
|
head_tag: Some("v1.4.0".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1327,12 +1336,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_shows_format_and_orig_origin() {
|
fn summary_screen_shows_format_and_orig_origin() {
|
||||||
// Native skeleton: the format row, no orig row.
|
// Native skeleton: the format row, no orig row.
|
||||||
let text = summary_text(&opts(Tid::Shell), None);
|
let text = summary_text(&opts(Tid::SHELL), None);
|
||||||
assert!(text.contains("debian/source/format 3.0 (native)"), "{text}");
|
assert!(text.contains("debian/source/format 3.0 (native)"), "{text}");
|
||||||
assert!(!text.contains("orig tarball"), "{text}");
|
assert!(!text.contains("orig tarball"), "{text}");
|
||||||
|
|
||||||
// Quilt over an existing project: both rows.
|
// Quilt over an existing project: both rows.
|
||||||
let mut quilt = opts(Tid::Shell);
|
let mut quilt = opts(Tid::SHELL);
|
||||||
quilt.source_dir = options::SourceDir::Here;
|
quilt.source_dir = options::SourceDir::Here;
|
||||||
quilt.source_format = options::SourceFormat::Quilt;
|
quilt.source_format = options::SourceFormat::Quilt;
|
||||||
quilt.orig = Some(options::OrigOrigin::GitArchive {
|
quilt.orig = Some(options::OrigOrigin::GitArchive {
|
||||||
@@ -1349,10 +1358,7 @@ mod tests {
|
|||||||
let mut release = quilt.clone();
|
let mut release = quilt.clone();
|
||||||
release.orig = Some(options::OrigOrigin::Release {
|
release.orig = Some(options::OrigOrigin::Release {
|
||||||
tag: "v0.14.0".to_string(),
|
tag: "v0.14.0".to_string(),
|
||||||
forge: crate::new::origin::Forge::GitLab {
|
forge: crate::new::origin::Forge::parse("https://gitlab.com/foo/bar").unwrap(),
|
||||||
owner: "foo".into(),
|
|
||||||
repo: "bar".into(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
let text = summary_text(&release, None);
|
let text = summary_text(&release, None);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1363,7 +1369,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_skeleton() {
|
fn summary_screen_skeleton() {
|
||||||
let text = summary_text(&opts(Tid::Makefile), None);
|
let text = summary_text(&opts(Tid::MAKEFILE), None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
|
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
|
||||||
"{text}"
|
"{text}"
|
||||||
@@ -1393,7 +1399,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_metapackage_shows_depends() {
|
fn summary_screen_metapackage_shows_depends() {
|
||||||
let mut o = opts(Tid::Empty);
|
let mut o = opts(Tid::EMPTY);
|
||||||
o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()];
|
o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()];
|
||||||
o.source_dir = options::SourceDir::Here;
|
o.source_dir = options::SourceDir::Here;
|
||||||
let text = summary_text(&o, None);
|
let text = summary_text(&o, None);
|
||||||
@@ -1409,7 +1415,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_release_and_extras() {
|
fn summary_screen_release_and_extras() {
|
||||||
let mut o = opts(Tid::Shell);
|
let mut o = opts(Tid::SHELL);
|
||||||
o.release = true;
|
o.release = true;
|
||||||
o.autopkgtest = true;
|
o.autopkgtest = true;
|
||||||
o.watch = Some("version=4\n".to_string());
|
o.watch = Some("version=4\n".to_string());
|
||||||
@@ -1427,7 +1433,7 @@ mod tests {
|
|||||||
/// yet (regression: it claimed "(vendored)" before generating).
|
/// yet (regression: it claimed "(vendored)" before generating).
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_rust_does_not_presume_vendoring() {
|
fn summary_screen_rust_does_not_presume_vendoring() {
|
||||||
let text = summary_text(&opts(Tid::Rust), None);
|
let text = summary_text(&opts(Tid::RUST), None);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("cargo build --release --offline (vendored at generation)"),
|
text.contains("cargo build --release --offline (vendored at generation)"),
|
||||||
"{text}"
|
"{text}"
|
||||||
@@ -1439,17 +1445,17 @@ mod tests {
|
|||||||
/// (rust template only), flagged as ignored by the chroot build.
|
/// (rust template only), flagged as ignored by the chroot build.
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_screen_shows_the_toolchain_pin() {
|
fn summary_screen_shows_the_toolchain_pin() {
|
||||||
let text = summary_text(&opts(Tid::Rust), Some("1.98.0"));
|
let text = summary_text(&opts(Tid::RUST), Some("1.98.0"));
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("rust-toolchain 1.98.0 (ignored by the chroot build)"),
|
text.contains("rust-toolchain 1.98.0 (ignored by the chroot build)"),
|
||||||
"{text}"
|
"{text}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// No pin, no row.
|
// No pin, no row.
|
||||||
assert!(!summary_text(&opts(Tid::Rust), None).contains("rust-toolchain"));
|
assert!(!summary_text(&opts(Tid::RUST), None).contains("rust-toolchain"));
|
||||||
// A pin under a template other than rust is not shown either (the
|
// A pin under a template other than rust is not shown either (the
|
||||||
// pin only matters for a cargo build).
|
// pin only matters for a cargo build).
|
||||||
assert!(!summary_text(&opts(Tid::Go), Some("1.98.0")).contains("rust-toolchain"));
|
assert!(!summary_text(&opts(Tid::GO), Some("1.98.0")).contains("rust-toolchain"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The git-init question is only asked when a git init would actually
|
/// The git-init question is only asked when a git init would actually
|
||||||
@@ -1553,7 +1559,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn select_labels_carry_their_own_separator() {
|
fn select_labels_carry_their_own_separator() {
|
||||||
// prompt::select renders `> <label><answer>` verbatim; a label
|
// the selector renders `> <label><answer>` verbatim; a label
|
||||||
// without a trailing separator glues the answer to the prompt
|
// without a trailing separator glues the answer to the prompt
|
||||||
// (regression: the wizard once rendered "> LicenseMIT").
|
// (regression: the wizard once rendered "> LicenseMIT").
|
||||||
for label in SELECT_LABELS {
|
for label in SELECT_LABELS {
|
||||||
@@ -1598,7 +1604,7 @@ mod tests {
|
|||||||
|
|
||||||
// The initial pass over dir A.
|
// The initial pass over dir A.
|
||||||
let (detection_a, probe_a) = detect_and_probe(dir_a.path());
|
let (detection_a, probe_a) = detect_and_probe(dir_a.path());
|
||||||
assert_eq!(detection_a, Detection::Single(Tid::Rust));
|
assert_eq!(detection_a, Detection::Single(Tid::RUST));
|
||||||
let probe_a = probe_a.expect("dir A is a rust project");
|
let probe_a = probe_a.expect("dir A is a rust project");
|
||||||
assert_eq!(probe_a.name.as_deref(), Some("alpha"));
|
assert_eq!(probe_a.name.as_deref(), Some("alpha"));
|
||||||
assert_eq!(probe_a.version.as_deref(), Some("0.1.0"));
|
assert_eq!(probe_a.version.as_deref(), Some("0.1.0"));
|
||||||
@@ -1607,7 +1613,7 @@ mod tests {
|
|||||||
// The user chose dir B instead: the refreshed probe comes from B,
|
// The user chose dir B instead: the refreshed probe comes from B,
|
||||||
// never from A.
|
// never from A.
|
||||||
let (detection_b, probe_b) = detect_and_probe(dir_b.path());
|
let (detection_b, probe_b) = detect_and_probe(dir_b.path());
|
||||||
assert_eq!(detection_b, Detection::Single(Tid::Rust));
|
assert_eq!(detection_b, Detection::Single(Tid::RUST));
|
||||||
let probe_b = probe_b.expect("dir B is a rust project");
|
let probe_b = probe_b.expect("dir B is a rust project");
|
||||||
assert_eq!(probe_b.name.as_deref(), Some("beta"));
|
assert_eq!(probe_b.name.as_deref(), Some("beta"));
|
||||||
assert_eq!(probe_b.version.as_deref(), Some("2.9.9"));
|
assert_eq!(probe_b.version.as_deref(), Some("2.9.9"));
|
||||||
|
|||||||
@@ -1,71 +1,34 @@
|
|||||||
//! The `autotools` template: a C project with a `configure.ac` built through
|
//! The `autotools` template: a C project with a `configure.ac` built through
|
||||||
//! debhelper's auto-detection (dh runs `autoreconf` itself when it finds
|
//! debhelper's auto-detection (dh runs `autoreconf` itself when it finds
|
||||||
//! `configure.ac`, debhelper ≥ 10 — no override needed).
|
//! `configure.ac`, debhelper ≥ 10 — no override needed).
|
||||||
|
//!
|
||||||
|
//! The metadata and the `configure.ac`/`Makefile.am`/`hello.c` skeleton
|
||||||
|
//! bodies are manifest data (`data/templates/autotools/manifest.yml`); the
|
||||||
|
//! logic half here is the `AC_INIT` probe and the GNU-gettext detection
|
||||||
|
//! (appending `gettext` to the manifest's Build-Depends).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use super::meson::hello_c;
|
use super::{ProbeResult, TemplateHooks, source_dir_of};
|
||||||
use super::{OutputFile, ProbeResult, Template, source_dir_of};
|
use crate::new::options::NewOptions;
|
||||||
use crate::new::options::{NewOptions, TemplateId};
|
|
||||||
|
|
||||||
/// C/C++ with Autotools (`configure.ac`).
|
/// The logic half of the autotools template.
|
||||||
pub struct Autotools;
|
pub struct Hooks;
|
||||||
|
|
||||||
impl Template for Autotools {
|
/// The autotools template's hooks, registered in the template registry.
|
||||||
fn id(&self) -> TemplateId {
|
pub static HOOKS: Hooks = Hooks;
|
||||||
TemplateId::Autotools
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A minimal `configure.ac`, the matching `Makefile.am` and `hello.c`.
|
impl TemplateHooks for Hooks {
|
||||||
/// The first source build runs `autoreconf` (integrated in the dh
|
/// A packaged `configure.ac` that sets up GNU gettext (the
|
||||||
/// sequence), so no generated configure script is committed.
|
/// `AM_GNU_GETTEXT` macro, see [`uses_gettext`]) appends `gettext` to
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
/// the manifest's Build-Depends.
|
||||||
vec![
|
fn build_depends(&self, opts: &NewOptions, mut base: Vec<String>) -> Vec<String> {
|
||||||
OutputFile::new(
|
|
||||||
"configure.ac",
|
|
||||||
format!(
|
|
||||||
"AC_INIT([{name}], [{version}])\n\
|
|
||||||
AM_INIT_AUTOMAKE([foreign])\n\
|
|
||||||
AC_PROG_CC\n\
|
|
||||||
AC_CONFIG_FILES([Makefile])\n\
|
|
||||||
AC_OUTPUT\n",
|
|
||||||
name = opts.name,
|
|
||||||
version = opts.upstream_version,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OutputFile::new(
|
|
||||||
"Makefile.am",
|
|
||||||
format!(
|
|
||||||
"bin_PROGRAMS = {command}\n\
|
|
||||||
{command}_SOURCES = hello.c\n",
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
hello_c(opts),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No extra debian/ files: plain `dh $@` auto-detects `configure.ac`.
|
|
||||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
|
||||||
let mut deps = vec![
|
|
||||||
"autoconf".to_string(),
|
|
||||||
"automake".to_string(),
|
|
||||||
"libtool".to_string(),
|
|
||||||
];
|
|
||||||
if uses_gettext(opts) {
|
if uses_gettext(opts) {
|
||||||
deps.push("gettext".to_string());
|
base.push("gettext".to_string());
|
||||||
}
|
}
|
||||||
deps
|
base
|
||||||
}
|
|
||||||
|
|
||||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
|
||||||
"any"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Package name and version from the `AC_INIT` macro of `configure.ac`.
|
/// Package name and version from the `AC_INIT` macro of `configure.ac`.
|
||||||
@@ -109,13 +72,13 @@ fn uses_gettext(opts: &NewOptions) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::new::options::{License, SourceDir};
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn opts() -> NewOptions {
|
fn opts() -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Autotools,
|
template: TemplateId::AUTOTOOLS,
|
||||||
source_dir: SourceDir::Skeleton,
|
source_dir: SourceDir::Skeleton,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -141,7 +104,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn autotools_template_shape() {
|
fn autotools_template_shape() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let template = super::super::get(TemplateId::Autotools).unwrap();
|
let template = super::super::get(TemplateId::AUTOTOOLS).unwrap();
|
||||||
|
|
||||||
assert_eq!(template.architecture(&o), "any");
|
assert_eq!(template.architecture(&o), "any");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -156,28 +119,50 @@ mod tests {
|
|||||||
assert!(template.rules_extra(&o).is_empty());
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
assert!(template.debian(&o).is_empty());
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
// Skeleton (manifest data): configure.ac + Makefile.am + hello.c,
|
||||||
|
// byte-exact.
|
||||||
let skeleton = template.skeleton(&o);
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert_eq!(skeleton.len(), 3);
|
||||||
let configure = skeleton
|
let configure = skeleton
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.path == "configure.ac")
|
.find(|f| f.path == "configure.ac")
|
||||||
.expect("configure.ac skeleton");
|
.expect("configure.ac skeleton");
|
||||||
assert!(
|
assert_eq!(
|
||||||
configure
|
configure.contents,
|
||||||
.contents
|
"AC_INIT([mytool], [0.1.0])\n\
|
||||||
.starts_with("AC_INIT([mytool], [0.1.0])\n")
|
AM_INIT_AUTOMAKE([foreign])\n\
|
||||||
|
AC_PROG_CC\n\
|
||||||
|
AC_CONFIG_FILES([Makefile])\n\
|
||||||
|
AC_OUTPUT\n"
|
||||||
);
|
);
|
||||||
let makefile_am = skeleton
|
let makefile_am = skeleton
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.path == "Makefile.am")
|
.find(|f| f.path == "Makefile.am")
|
||||||
.expect("Makefile.am skeleton");
|
.expect("Makefile.am skeleton");
|
||||||
assert!(makefile_am.contents.contains("bin_PROGRAMS = mytool"));
|
assert_eq!(
|
||||||
assert!(makefile_am.contents.contains("mytool_SOURCES = hello.c"));
|
makefile_am.contents,
|
||||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
"bin_PROGRAMS = mytool\nmytool_SOURCES = hello.c\n"
|
||||||
|
);
|
||||||
|
let hello = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "hello.c")
|
||||||
|
.expect("hello.c skeleton");
|
||||||
|
assert_eq!(
|
||||||
|
hello.contents,
|
||||||
|
"#include <stdio.h>\n\
|
||||||
|
\n\
|
||||||
|
/* Placeholder for mytool, generated by `pkh new`. */\n\
|
||||||
|
int main(void)\n\
|
||||||
|
{\n\
|
||||||
|
\tprintf(\"Hello from mytool!\\n\");\n\
|
||||||
|
\treturn 0;\n\
|
||||||
|
}\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autotools_probe_reads_ac_init() {
|
fn autotools_probe_reads_ac_init() {
|
||||||
let template = super::super::get(TemplateId::Autotools).unwrap();
|
let template = super::super::get(TemplateId::AUTOTOOLS).unwrap();
|
||||||
|
|
||||||
// Bracketed form (the generated skeleton's own shape).
|
// Bracketed form (the generated skeleton's own shape).
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -222,7 +207,7 @@ mod tests {
|
|||||||
let o = opts();
|
let o = opts();
|
||||||
assert!(!uses_gettext(&o));
|
assert!(!uses_gettext(&o));
|
||||||
assert!(
|
assert!(
|
||||||
!super::super::get(TemplateId::Autotools)
|
!super::super::get(TemplateId::AUTOTOOLS)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.build_depends(&o)
|
.build_depends(&o)
|
||||||
.contains(&"gettext".to_string())
|
.contains(&"gettext".to_string())
|
||||||
@@ -241,7 +226,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(uses_gettext(&o));
|
assert!(uses_gettext(&o));
|
||||||
assert!(
|
assert!(
|
||||||
super::super::get(TemplateId::Autotools)
|
super::super::get(TemplateId::AUTOTOOLS)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.build_depends(&o)
|
.build_depends(&o)
|
||||||
.contains(&"gettext".to_string())
|
.contains(&"gettext".to_string())
|
||||||
|
|||||||
+45
-61
@@ -1,63 +1,33 @@
|
|||||||
//! The `cmake` template: a C/C++ project built with CMake through the
|
//! The `cmake` template: a C/C++ project built with CMake through the
|
||||||
//! debhelper cmake buildsystem.
|
//! debhelper cmake buildsystem.
|
||||||
|
//!
|
||||||
|
//! The metadata and the `CMakeLists.txt`/`hello.c` skeleton bodies are
|
||||||
|
//! manifest data (`data/templates/cmake/manifest.yml`); the logic half
|
||||||
|
//! here is the `project()` probe and the wizard's pkg-config opt-in
|
||||||
|
//! (appended to the manifest's Build-Depends).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use super::meson::hello_c;
|
use super::{ProbeResult, TemplateHooks};
|
||||||
use super::{OutputFile, ProbeResult, Template};
|
use crate::new::options::NewOptions;
|
||||||
use crate::new::options::{NewOptions, TemplateId};
|
|
||||||
|
|
||||||
/// C/C++ with CMake (`CMakeLists.txt`).
|
/// The logic half of the cmake template.
|
||||||
pub struct Cmake;
|
pub struct Hooks;
|
||||||
|
|
||||||
impl Template for Cmake {
|
/// The cmake template's hooks, registered in the template registry.
|
||||||
fn id(&self) -> TemplateId {
|
pub static HOOKS: Hooks = Hooks;
|
||||||
TemplateId::Cmake
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A minimal `CMakeLists.txt` (project declaration + one installed
|
impl TemplateHooks for Hooks {
|
||||||
/// executable) and the classic `hello.c`.
|
/// The wizard's pkg-config opt-in ([`NewOptions::pkg_config`], offered
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
/// when the project's build file hints at `pkg_check_modules` usage)
|
||||||
vec![
|
/// adds `pkg-config` to the manifest's Build-Depends.
|
||||||
OutputFile::new(
|
fn build_depends(&self, opts: &NewOptions, mut base: Vec<String>) -> Vec<String> {
|
||||||
"CMakeLists.txt",
|
|
||||||
format!(
|
|
||||||
"cmake_minimum_required(VERSION 3.16)\n\
|
|
||||||
project({name} VERSION {version})\n\
|
|
||||||
\n\
|
|
||||||
add_executable({command} hello.c)\n\
|
|
||||||
install(TARGETS {command} RUNTIME DESTINATION bin)\n",
|
|
||||||
name = opts.name,
|
|
||||||
version = opts.upstream_version,
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
hello_c(opts),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No extra debian/ files: debhelper's cmake buildsystem handles the
|
|
||||||
/// configure/build/install steps.
|
|
||||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
|
||||||
let mut deps = vec!["cmake".to_string()];
|
|
||||||
if opts.pkg_config {
|
if opts.pkg_config {
|
||||||
deps.push("pkg-config".to_string());
|
base.push("pkg-config".to_string());
|
||||||
}
|
}
|
||||||
deps
|
base
|
||||||
}
|
|
||||||
|
|
||||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
|
||||||
"any"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rules_dh_line(&self) -> String {
|
|
||||||
"dh $@ --buildsystem=cmake".to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Project name and version from the `project(<name> VERSION …)`
|
/// Project name and version from the `project(<name> VERSION …)`
|
||||||
@@ -84,13 +54,13 @@ impl Template for Cmake {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::new::options::{License, SourceDir};
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn opts() -> NewOptions {
|
fn opts() -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Cmake,
|
template: TemplateId::CMAKE,
|
||||||
source_dir: SourceDir::Skeleton,
|
source_dir: SourceDir::Skeleton,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -116,7 +86,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cmake_template_shape() {
|
fn cmake_template_shape() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let template = super::super::get(TemplateId::Cmake).unwrap();
|
let template = super::super::get(TemplateId::CMAKE).unwrap();
|
||||||
|
|
||||||
assert_eq!(template.architecture(&o), "any");
|
assert_eq!(template.architecture(&o), "any");
|
||||||
assert_eq!(template.build_depends(&o), vec!["cmake".to_string()]);
|
assert_eq!(template.build_depends(&o), vec!["cmake".to_string()]);
|
||||||
@@ -124,27 +94,41 @@ mod tests {
|
|||||||
assert!(template.rules_extra(&o).is_empty());
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
assert!(template.debian(&o).is_empty());
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
// Skeleton (manifest data): CMakeLists.txt + hello.c, byte-exact.
|
||||||
let skeleton = template.skeleton(&o);
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert_eq!(skeleton.len(), 2);
|
||||||
let cmakelists = skeleton
|
let cmakelists = skeleton
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.path == "CMakeLists.txt")
|
.find(|f| f.path == "CMakeLists.txt")
|
||||||
.expect("CMakeLists.txt skeleton");
|
.expect("CMakeLists.txt skeleton");
|
||||||
assert!(
|
assert_eq!(
|
||||||
cmakelists
|
cmakelists.contents,
|
||||||
.contents
|
"cmake_minimum_required(VERSION 3.16)\n\
|
||||||
.contains("project(mytool VERSION 0.1.0)")
|
project(mytool VERSION 0.1.0)\n\
|
||||||
|
\n\
|
||||||
|
add_executable(mytool hello.c)\n\
|
||||||
|
install(TARGETS mytool RUNTIME DESTINATION bin)\n"
|
||||||
);
|
);
|
||||||
assert!(
|
let hello = skeleton
|
||||||
cmakelists
|
.iter()
|
||||||
.contents
|
.find(|f| f.path == "hello.c")
|
||||||
.contains("add_executable(mytool hello.c)")
|
.expect("hello.c skeleton");
|
||||||
|
assert_eq!(
|
||||||
|
hello.contents,
|
||||||
|
"#include <stdio.h>\n\
|
||||||
|
\n\
|
||||||
|
/* Placeholder for mytool, generated by `pkh new`. */\n\
|
||||||
|
int main(void)\n\
|
||||||
|
{\n\
|
||||||
|
\tprintf(\"Hello from mytool!\\n\");\n\
|
||||||
|
\treturn 0;\n\
|
||||||
|
}\n"
|
||||||
);
|
);
|
||||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cmake_probe_reads_project_declaration() {
|
fn cmake_probe_reads_project_declaration() {
|
||||||
let template = super::super::get(TemplateId::Cmake).unwrap();
|
let template = super::super::get(TemplateId::CMAKE).unwrap();
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
//! The `empty` template: a metapackage or an empty base package with no
|
|
||||||
//! build system at all.
|
|
||||||
//!
|
|
||||||
//! One template with two flavors: a non-empty `Depends` list selects the
|
|
||||||
//! **metapackage** flavor (the canonical `Architecture: all`, nothing
|
|
||||||
//! compiled, the Depends list *is* the payload shape), while an empty list
|
|
||||||
//! selects the **empty base** — pure `dh $@` plumbing as a starting point
|
|
||||||
//! for hand-written rules.
|
|
||||||
|
|
||||||
use super::{OutputFile, Template};
|
|
||||||
use crate::new::options::NewOptions;
|
|
||||||
|
|
||||||
/// Metapackage / empty base (no build system).
|
|
||||||
pub struct Empty;
|
|
||||||
|
|
||||||
impl Template for Empty {
|
|
||||||
fn id(&self) -> crate::new::options::TemplateId {
|
|
||||||
crate::new::options::TemplateId::Empty
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No upstream files; just a stub `README` marking the tree as
|
|
||||||
/// intentionally empty.
|
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
vec![OutputFile::new(
|
|
||||||
"README",
|
|
||||||
format!(
|
|
||||||
"{} - empty base tree scaffolded by `pkh new`; there is \
|
|
||||||
intentionally no upstream build system here.\n",
|
|
||||||
opts.name
|
|
||||||
),
|
|
||||||
)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No extra debian/ files: the metapackage `Depends` list is carried by
|
|
||||||
/// [`NewOptions::depends`] into the common `debian/control` rendering.
|
|
||||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::new::options::{License, SourceDir, TemplateId};
|
|
||||||
|
|
||||||
fn opts(depends: Vec<String>) -> NewOptions {
|
|
||||||
NewOptions {
|
|
||||||
name: "metapkg".into(),
|
|
||||||
template: TemplateId::Empty,
|
|
||||||
source_dir: SourceDir::Skeleton,
|
|
||||||
upstream_version: "0.1.0".into(),
|
|
||||||
revision: 1,
|
|
||||||
summary: "A metapackage".into(),
|
|
||||||
long_description: "A metapackage".into(),
|
|
||||||
homepage: None,
|
|
||||||
license: License::Custom("unknown".into()),
|
|
||||||
command: "ignored".into(),
|
|
||||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
|
||||||
dist: "debian".into(),
|
|
||||||
series: "sid".into(),
|
|
||||||
release: false,
|
|
||||||
depends,
|
|
||||||
source_format: crate::new::options::SourceFormat::Quilt,
|
|
||||||
orig: Some(crate::new::options::OrigOrigin::Snapshot),
|
|
||||||
git: true,
|
|
||||||
autopkgtest: false,
|
|
||||||
pkg_config: false,
|
|
||||||
watch: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_template_shape() {
|
|
||||||
let template = super::super::get(TemplateId::Empty).unwrap();
|
|
||||||
|
|
||||||
// Metapackage flavor: the depends list travels in the options.
|
|
||||||
let o = opts(vec!["hello".into(), "hello-data (>= 1.0)".into()]);
|
|
||||||
assert!(template.debian(&o).is_empty());
|
|
||||||
assert_eq!(template.architecture(&o), "all");
|
|
||||||
assert!(template.build_depends(&o).is_empty());
|
|
||||||
assert!(template.rules_extra(&o).is_empty());
|
|
||||||
|
|
||||||
let skeleton = template.skeleton(&o);
|
|
||||||
assert_eq!(skeleton.len(), 1);
|
|
||||||
assert_eq!(skeleton[0].path, "README");
|
|
||||||
assert!(!skeleton[0].executable);
|
|
||||||
assert!(skeleton[0].contents.contains("metapkg"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-68
@@ -1,84 +1,40 @@
|
|||||||
//! The `go` template: a Go module built through dh-golang.
|
//! The `go` template: a Go module built through dh-golang.
|
||||||
//!
|
//!
|
||||||
//! The source stanza carries `XS-Go-Import-Path`, probed from the `module`
|
//! The skeleton bodies (`go.mod`, `main.go`) are manifest data
|
||||||
//! line of `go.mod` when the packaged tree has one, defaulting to the
|
//! (`data/templates/go/manifest.yml`); the source stanza carries
|
||||||
//! package name (fresh skeletons embed the package name in their own
|
//! `XS-Go-Import-Path`, declared as the `{go_import_path}` placeholder by
|
||||||
//! `go.mod`).
|
//! the manifest and filled here from the `module` line of `go.mod` when
|
||||||
|
//! the packaged tree has one, defaulting to the package name (fresh
|
||||||
|
//! skeletons embed the package name in their own `go.mod`). The module
|
||||||
|
//! line is also the probe of an existing project.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::{OutputFile, Template, source_dir_of};
|
use super::{ProbeResult, TemplateHooks, source_dir_of};
|
||||||
use crate::new::options::{NewOptions, TemplateId};
|
use crate::new::options::NewOptions;
|
||||||
|
|
||||||
/// Go module (`go.mod`).
|
/// The logic half of the go template.
|
||||||
pub struct Go;
|
pub struct Hooks;
|
||||||
|
|
||||||
impl Template for Go {
|
/// The go template's hooks, registered in the template registry.
|
||||||
fn id(&self) -> TemplateId {
|
pub static HOOKS: Hooks = Hooks;
|
||||||
TemplateId::Go
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stdlib-only `main.go` (no archive dependencies needed to build) and
|
impl TemplateHooks for Hooks {
|
||||||
/// the matching `go.mod` whose module path is the package name.
|
/// The `{go_import_path}` value of the manifest's `XS-Go-Import-Path`
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
/// source field (see [`import_path`]).
|
||||||
vec![
|
fn context(&self, opts: &NewOptions) -> Vec<(String, String)> {
|
||||||
OutputFile::new(
|
vec![("go_import_path".to_string(), import_path(opts))]
|
||||||
"go.mod",
|
|
||||||
format!(
|
|
||||||
"module {name}\n\
|
|
||||||
\n\
|
|
||||||
go 1.21\n",
|
|
||||||
name = opts.name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OutputFile::new(
|
|
||||||
"main.go",
|
|
||||||
format!(
|
|
||||||
"// Placeholder for {name}, generated by `pkh new`.\n\
|
|
||||||
package main\n\
|
|
||||||
\n\
|
|
||||||
import \"fmt\"\n\
|
|
||||||
\n\
|
|
||||||
func main() {{\n\
|
|
||||||
\tfmt.Println(\"Hello from {command}!\")\n\
|
|
||||||
}}\n",
|
|
||||||
name = opts.name,
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No extra debian/ files: dh-golang drives the build.
|
|
||||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
|
||||||
vec!["golang-any".to_string(), "dh-golang".to_string()]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
|
||||||
"any"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rules_dh_line(&self) -> String {
|
|
||||||
"dh $@ --buildsystem=golang".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_fields(&self, opts: &NewOptions) -> Vec<(String, String)> {
|
|
||||||
vec![("XS-Go-Import-Path".to_string(), import_path(opts))]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Name (and default command) from the `module` line of `go.mod`: the
|
/// Name (and default command) from the `module` line of `go.mod`: the
|
||||||
/// last path segment is the conventional binary/package name.
|
/// last path segment is the conventional binary/package name.
|
||||||
fn probe(&self, dir: &Path) -> Option<super::ProbeResult> {
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||||
let module = read_module_line(dir)?;
|
let module = read_module_line(dir)?;
|
||||||
let name = module.rsplit('/').next()?.to_string();
|
let name = module.rsplit('/').next()?.to_string();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(super::ProbeResult {
|
Some(ProbeResult {
|
||||||
name: Some(name.clone()),
|
name: Some(name.clone()),
|
||||||
command: Some(name),
|
command: Some(name),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -114,13 +70,13 @@ fn read_module_line(dir: &Path) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::new::options::{License, SourceDir};
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Go,
|
template: TemplateId::GO,
|
||||||
source_dir,
|
source_dir,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -146,7 +102,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn go_template_shape() {
|
fn go_template_shape() {
|
||||||
let o = opts(SourceDir::Skeleton);
|
let o = opts(SourceDir::Skeleton);
|
||||||
let template = super::super::get(TemplateId::Go).unwrap();
|
let template = super::super::get(TemplateId::GO).unwrap();
|
||||||
|
|
||||||
assert_eq!(template.architecture(&o), "any");
|
assert_eq!(template.architecture(&o), "any");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -156,6 +112,7 @@ mod tests {
|
|||||||
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=golang");
|
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=golang");
|
||||||
assert!(template.debian(&o).is_empty());
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
// The skeleton bodies are manifest data now.
|
||||||
let skeleton = template.skeleton(&o);
|
let skeleton = template.skeleton(&o);
|
||||||
assert!(
|
assert!(
|
||||||
skeleton
|
skeleton
|
||||||
@@ -180,7 +137,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let template = super::super::get(TemplateId::Go).unwrap();
|
let template = super::super::get(TemplateId::GO).unwrap();
|
||||||
let probe = template.probe(dir.path()).expect("probe result");
|
let probe = template.probe(dir.path()).expect("probe result");
|
||||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||||
assert_eq!(probe.command.as_deref(), Some("mytool"));
|
assert_eq!(probe.command.as_deref(), Some("mytool"));
|
||||||
|
|||||||
@@ -1,94 +1,54 @@
|
|||||||
//! The `makefile` template: a generic project driven by a plain `Makefile`.
|
//! The `makefile` template: a generic project driven by a plain Makefile.
|
||||||
//!
|
//!
|
||||||
//! debhelper's makefile buildsystem runs `make` for the build and
|
//! debhelper's makefile buildsystem runs `make` for the build and `make
|
||||||
//! `make install DESTDIR=...` when the Makefile carries an `install:` target
|
//! install DESTDIR=...` when the Makefile carries an `install:` target
|
||||||
//! (missing targets are skipped gracefully), so plain `dh $@` plumbing is
|
//! (missing targets are skipped gracefully), so plain `dh $@` plumbing is
|
||||||
//! enough here.
|
//! enough here. Everything is manifest data
|
||||||
|
//! (`data/templates/makefile/manifest.yml`: the `hello.c`/`Makefile`
|
||||||
|
//! skeleton and the skeleton-only `debian/install` mapping) except the
|
||||||
|
//! hint logged here for an existing tree, which probes its Makefile for a
|
||||||
|
//! phony `install:` target to tell whether `dh_auto_install` will run
|
||||||
|
//! `make install` (a skeleton's target is known by construction, so
|
||||||
|
//! skeletons need no code).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::{OutputFile, Template, source_dir_of};
|
use super::TemplateHooks;
|
||||||
use crate::new::options::{NewOptions, SourceDir, TemplateId};
|
use crate::new::options::{NewOptions, SourceDir};
|
||||||
|
|
||||||
/// Generic Makefile-based project.
|
/// The logic half of the makefile template.
|
||||||
pub struct Makefile;
|
pub struct Hooks;
|
||||||
|
|
||||||
impl Template for Makefile {
|
/// The makefile template's hooks, registered in the template registry.
|
||||||
fn id(&self) -> TemplateId {
|
pub static HOOKS: Hooks = Hooks;
|
||||||
TemplateId::Makefile
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `hello.c` plus a `Makefile` with `all`/`install`/`clean` targets;
|
impl TemplateHooks for Hooks {
|
||||||
/// `install` honors `DESTDIR` and copies the binary to
|
/// No files of its own: the manifest carries the skeleton-only
|
||||||
/// `$(DESTDIR)/usr/bin/`.
|
/// `debian/install` mapping. When packaging an existing tree, probe
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
/// its Makefile for a phony `install:` target and say which install
|
||||||
vec![
|
/// step `dh_auto_install` will take (no `debian/install` is emitted
|
||||||
super::meson::hello_c(opts),
|
/// there — the source-relative mapping of an unknown artifact is only
|
||||||
OutputFile::new(
|
/// the project's to write, and `make install` already ran).
|
||||||
"Makefile",
|
fn debian_files(&self, opts: &NewOptions) -> Vec<super::OutputFile> {
|
||||||
format!(
|
if matches!(opts.source_dir, SourceDir::Skeleton) {
|
||||||
"CC ?= cc\n\
|
return Vec::new();
|
||||||
CFLAGS ?= -O2 -Wall -Wextra\n\
|
|
||||||
PREFIX ?= /usr\n\
|
|
||||||
\n\
|
|
||||||
all: {command}\n\
|
|
||||||
\n\
|
|
||||||
{command}: hello.c\n\
|
|
||||||
\t$(CC) $(CFLAGS) -o $@ hello.c\n\
|
|
||||||
\n\
|
|
||||||
install: {command}\n\
|
|
||||||
\tinstall -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}\n\
|
|
||||||
\n\
|
|
||||||
clean:\n\
|
|
||||||
\trm -f {command}\n\
|
|
||||||
\n\
|
|
||||||
.PHONY: all install clean\n",
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `debian/install` mapping the built binary into `/usr/bin`, generated
|
|
||||||
/// only when the packaged Makefile carries a phony `install:` target:
|
|
||||||
/// for skeletons that is known by construction; when packaging an
|
|
||||||
/// existing tree the Makefile is probed instead (no `debian/install` is
|
|
||||||
/// emitted there — the source-relative mapping of an unknown artifact is
|
|
||||||
/// only the project's to write, and `make install` already ran).
|
|
||||||
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
match &opts.source_dir {
|
|
||||||
SourceDir::Skeleton => {
|
|
||||||
vec![OutputFile::new(
|
|
||||||
"debian/install",
|
|
||||||
format!("{} usr/bin/{}\n", opts.command, opts.command),
|
|
||||||
)]
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(dir) = source_dir_of(opts)
|
|
||||||
&& phony_install_target(&dir.join("Makefile")).is_some()
|
|
||||||
{
|
|
||||||
log::info!(
|
|
||||||
"Makefile carries a phony 'install:' target: \
|
|
||||||
dh_auto_install will run 'make install DESTDIR=...'"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"Makefile has no phony 'install:' target: \
|
|
||||||
dh_auto_install will skip the install step; write a \
|
|
||||||
debian/install file to map build artifacts manually"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
let dir = super::source_dir_of(opts);
|
||||||
|
if let Some(dir) = dir.as_deref()
|
||||||
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
&& phony_install_target(&dir.join("Makefile")).is_some()
|
||||||
vec!["build-essential".to_string()]
|
{
|
||||||
}
|
log::info!(
|
||||||
|
"Makefile carries a phony 'install:' target: \
|
||||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
dh_auto_install will run 'make install DESTDIR=...'"
|
||||||
"any"
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Makefile has no phony 'install:' target: \
|
||||||
|
dh_auto_install will skip the install step; write a \
|
||||||
|
debian/install file to map build artifacts manually"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +79,13 @@ pub fn phony_install_target(path: &Path) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::new::options::{License, SourceDir};
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Makefile,
|
template: TemplateId::MAKEFILE,
|
||||||
source_dir,
|
source_dir,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -151,7 +111,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn makefile_template_shape() {
|
fn makefile_template_shape() {
|
||||||
let o = opts(SourceDir::Skeleton);
|
let o = opts(SourceDir::Skeleton);
|
||||||
let template = super::super::get(TemplateId::Makefile).unwrap();
|
let template = super::super::get(TemplateId::MAKEFILE).unwrap();
|
||||||
|
|
||||||
assert_eq!(template.architecture(&o), "any");
|
assert_eq!(template.architecture(&o), "any");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -161,8 +121,8 @@ mod tests {
|
|||||||
assert_eq!(template.rules_dh_line(), "dh $@");
|
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||||
assert!(template.rules_extra(&o).is_empty());
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
|
|
||||||
// Skeleton: hello.c + Makefile with all/install/clean, and the
|
// Skeleton (manifest data): hello.c + Makefile with all/install/clean
|
||||||
// phony install target maps to debian/install.
|
// targets, and the phony install target maps to debian/install.
|
||||||
let skeleton = template.skeleton(&o);
|
let skeleton = template.skeleton(&o);
|
||||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||||
let makefile = skeleton
|
let makefile = skeleton
|
||||||
@@ -187,7 +147,7 @@ mod tests {
|
|||||||
assert_eq!(debian[0].path, "debian/install");
|
assert_eq!(debian[0].path, "debian/install");
|
||||||
assert_eq!(debian[0].contents, "mytool usr/bin/mytool\n");
|
assert_eq!(debian[0].contents, "mytool usr/bin/mytool\n");
|
||||||
|
|
||||||
// Existing tree: nothing is emitted (probe log only).
|
// Existing tree: nothing is emitted (the probe log only).
|
||||||
let o = opts(SourceDir::Here);
|
let o = opts(SourceDir::Here);
|
||||||
assert!(template.debian(&o).is_empty());
|
assert!(template.debian(&o).is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-78
@@ -1,62 +1,33 @@
|
|||||||
//! The `meson` template: a C/C++ project built with Meson through the
|
//! The `meson` template: a C/C++ project built with Meson through the
|
||||||
//! debhelper meson buildsystem.
|
//! debhelper meson buildsystem.
|
||||||
|
//!
|
||||||
|
//! The metadata and the `meson.build`/`hello.c` skeleton bodies are
|
||||||
|
//! manifest data (`data/templates/meson/manifest.yml`); the logic half
|
||||||
|
//! here is the `project()` probe and the wizard's pkg-config opt-in
|
||||||
|
//! (appended to the manifest's Build-Depends).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use super::{OutputFile, ProbeResult, Template};
|
use super::{ProbeResult, TemplateHooks};
|
||||||
use crate::new::options::{NewOptions, TemplateId};
|
use crate::new::options::NewOptions;
|
||||||
|
|
||||||
/// C/C++ with Meson (`meson.build`).
|
/// The logic half of the meson template.
|
||||||
pub struct Meson;
|
pub struct Hooks;
|
||||||
|
|
||||||
impl Template for Meson {
|
/// The meson template's hooks, registered in the template registry.
|
||||||
fn id(&self) -> TemplateId {
|
pub static HOOKS: Hooks = Hooks;
|
||||||
TemplateId::Meson
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A minimal `meson.build` (project declaration + one installed
|
impl TemplateHooks for Hooks {
|
||||||
/// executable) and the classic `hello.c`.
|
/// The wizard's pkg-config opt-in ([`NewOptions::pkg_config`], offered
|
||||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
/// when the project's build file hints at `dependency(` usage) adds
|
||||||
vec![
|
/// `pkg-config` to the manifest's Build-Depends.
|
||||||
OutputFile::new(
|
fn build_depends(&self, opts: &NewOptions, mut base: Vec<String>) -> Vec<String> {
|
||||||
"meson.build",
|
|
||||||
format!(
|
|
||||||
"project('{name}', version: '{version}', license: '{license}', \
|
|
||||||
default_options: ['c_std=c11'])\n\
|
|
||||||
\n\
|
|
||||||
executable('{command}', 'hello.c', install: true)\n",
|
|
||||||
name = opts.name,
|
|
||||||
version = opts.upstream_version,
|
|
||||||
license = opts.license.spdx(),
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
hello_c(opts),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No extra debian/ files: debhelper's meson buildsystem handles the
|
|
||||||
/// configure/build/install steps.
|
|
||||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
|
||||||
let mut deps = vec!["meson".to_string()];
|
|
||||||
if opts.pkg_config {
|
if opts.pkg_config {
|
||||||
deps.push("pkg-config".to_string());
|
base.push("pkg-config".to_string());
|
||||||
}
|
}
|
||||||
deps
|
base
|
||||||
}
|
|
||||||
|
|
||||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
|
||||||
"any"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rules_dh_line(&self) -> String {
|
|
||||||
"dh $@ --buildsystem=meson".to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Project name and version from the `project('name', version: …)`
|
/// Project name and version from the `project('name', version: …)`
|
||||||
@@ -76,35 +47,16 @@ impl Template for Meson {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shared `hello.c` placeholder of the C/C++ skeletons.
|
|
||||||
pub(super) fn hello_c(opts: &NewOptions) -> OutputFile {
|
|
||||||
OutputFile::new(
|
|
||||||
"hello.c",
|
|
||||||
format!(
|
|
||||||
"#include <stdio.h>\n\
|
|
||||||
\n\
|
|
||||||
/* Placeholder for {name}, generated by `pkh new`. */\n\
|
|
||||||
int main(void)\n\
|
|
||||||
{{\n\
|
|
||||||
\tprintf(\"Hello from {command}!\\n\");\n\
|
|
||||||
\treturn 0;\n\
|
|
||||||
}}\n",
|
|
||||||
name = opts.name,
|
|
||||||
command = opts.command,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::new::options::{License, SourceDir};
|
use crate::new::options::{License, SourceDir, TemplateId};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn opts() -> NewOptions {
|
fn opts() -> NewOptions {
|
||||||
NewOptions {
|
NewOptions {
|
||||||
name: "mytool".into(),
|
name: "mytool".into(),
|
||||||
template: TemplateId::Meson,
|
template: TemplateId::MESON,
|
||||||
source_dir: SourceDir::Skeleton,
|
source_dir: SourceDir::Skeleton,
|
||||||
upstream_version: "0.1.0".into(),
|
upstream_version: "0.1.0".into(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
@@ -130,7 +82,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn meson_template_shape() {
|
fn meson_template_shape() {
|
||||||
let o = opts();
|
let o = opts();
|
||||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
let template = super::super::get(TemplateId::MESON).unwrap();
|
||||||
|
|
||||||
assert_eq!(template.architecture(&o), "any");
|
assert_eq!(template.architecture(&o), "any");
|
||||||
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||||
@@ -138,24 +90,40 @@ mod tests {
|
|||||||
assert!(template.rules_extra(&o).is_empty());
|
assert!(template.rules_extra(&o).is_empty());
|
||||||
assert!(template.debian(&o).is_empty());
|
assert!(template.debian(&o).is_empty());
|
||||||
|
|
||||||
|
// Skeleton (manifest data): meson.build + hello.c, byte-exact.
|
||||||
let skeleton = template.skeleton(&o);
|
let skeleton = template.skeleton(&o);
|
||||||
|
assert_eq!(skeleton.len(), 2);
|
||||||
let meson_build = skeleton
|
let meson_build = skeleton
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.path == "meson.build")
|
.find(|f| f.path == "meson.build")
|
||||||
.expect("meson.build skeleton");
|
.expect("meson.build skeleton");
|
||||||
assert!(meson_build.contents.contains("project('mytool'"));
|
assert_eq!(
|
||||||
assert!(meson_build.contents.contains("version: '0.1.0'"));
|
meson_build.contents,
|
||||||
assert!(
|
"project('mytool', version: '0.1.0', license: 'MIT', \
|
||||||
meson_build
|
default_options: ['c_std=c11'])\n\
|
||||||
.contents
|
\n\
|
||||||
.contains("executable('mytool', 'hello.c', install: true)")
|
executable('mytool', 'hello.c', install: true)\n"
|
||||||
|
);
|
||||||
|
let hello = skeleton
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "hello.c")
|
||||||
|
.expect("hello.c skeleton");
|
||||||
|
assert_eq!(
|
||||||
|
hello.contents,
|
||||||
|
"#include <stdio.h>\n\
|
||||||
|
\n\
|
||||||
|
/* Placeholder for mytool, generated by `pkh new`. */\n\
|
||||||
|
int main(void)\n\
|
||||||
|
{\n\
|
||||||
|
\tprintf(\"Hello from mytool!\\n\");\n\
|
||||||
|
\treturn 0;\n\
|
||||||
|
}\n"
|
||||||
);
|
);
|
||||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn meson_probe_reads_project_declaration() {
|
fn meson_probe_reads_project_declaration() {
|
||||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
let template = super::super::get(TemplateId::MESON).unwrap();
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -181,7 +149,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pkg_config_opt_in_extends_build_depends() {
|
fn pkg_config_opt_in_extends_build_depends() {
|
||||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
let template = super::super::get(TemplateId::MESON).unwrap();
|
||||||
let mut o = opts();
|
let mut o = opts();
|
||||||
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||||
o.pkg_config = true;
|
o.pkg_config = true;
|
||||||
|
|||||||
+1048
-217
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
Reference in New Issue
Block a user