Compare commits
25
Commits
ac83a939e3
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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" ]
|
||||||
|
|
||||||
@@ -121,3 +122,41 @@ jobs:
|
|||||||
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 }}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
*.lock
|
|
||||||
target
|
target
|
||||||
|
|
||||||
# Local snapcraft builds
|
# Local snapcraft builds
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ Rules:
|
|||||||
- `prune`, `package_info` — remaining subcommand modules
|
- `prune`, `package_info` — remaining subcommand modules
|
||||||
- `context` — build contexts: local, ssh, chroot/schroot, unshare
|
- `context` — build contexts: local, ssh, chroot/schroot, unshare
|
||||||
(`src/context/`)
|
(`src/context/`)
|
||||||
|
- `interrupt` — Ctrl+C interception and interrupt-time cleanup
|
||||||
|
(`src/interrupt.rs`)
|
||||||
- `debian` — Debian format primitives: control, versions, checksums,
|
- `debian` — Debian format primitives: control, versions, checksums,
|
||||||
arch (`src/debian/`)
|
arch (`src/debian/`)
|
||||||
- `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro
|
- `apt`, `launchpad`, `distro_info`, `quirks` — archive/distro
|
||||||
|
|||||||
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"] }
|
||||||
@@ -35,6 +39,8 @@ 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"
|
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,24 @@
|
|||||||
|
|
||||||
`pkh` is a packaging helper for Debian/Ubuntu packages.
|
`pkh` is a packaging helper for Debian/Ubuntu packages.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
No distribution channel is published yet; build from source:
|
||||||
|
|
||||||
|
```
|
||||||
|
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 +43,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,59 +91,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
|
|
||||||
- [x] 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
|
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
## series (`<series>-updates`, ...). Deliberately not the
|
## series (`<series>-updates`, ...). Deliberately not the
|
||||||
## `pockets` key: that one is the *search order* of pull,
|
## `pockets` key: that one is the *search order* of pull,
|
||||||
## where backports must not fold in.
|
## 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
|
## build_profiles: the vendor's default DEB_BUILD_PROFILES (Ubuntu
|
||||||
## activates derivative.ubuntu noudeb, Debian none),
|
## activates derivative.ubuntu noudeb, Debian none),
|
||||||
## mirroring what Dpkg::BuildProfiles resolves when the
|
## mirroring what Dpkg::BuildProfiles resolves when the
|
||||||
@@ -47,6 +53,10 @@ dist:
|
|||||||
- updates
|
- updates
|
||||||
- security
|
- security
|
||||||
- proposed-updates
|
- proposed-updates
|
||||||
|
# Debian changelogs conventionally target 'unstable'; the series data
|
||||||
|
# knows the same series as 'sid'.
|
||||||
|
suite_aliases:
|
||||||
|
unstable: sid
|
||||||
sections:
|
sections:
|
||||||
# Valid Section values for debian/control: the Debian policy section
|
# Valid Section values for debian/control: the Debian policy section
|
||||||
# list unioned with the sections observed in the live Ubuntu archive.
|
# list unioned with the sections observed in the live Ubuntu archive.
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
## ssh_*: the PPA upload queue, as expanded by dput-ng's
|
## ssh_*: the PPA upload queue, as expanded by dput-ng's
|
||||||
## ppa:user/ppa profile (ppa.launchpad.net:22, incoming
|
## ppa:user/ppa profile (ppa.launchpad.net:22, incoming
|
||||||
## ~<user>/<ppa>)
|
## ~<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
|
## content_host_template: ppa.launchpadcontent.net serves PPA apt
|
||||||
## repositories since the 2022 move off ppa.launchpad.net
|
## repositories since the 2022 move off ppa.launchpad.net
|
||||||
## git_web_template: Launchpad's CGit mirrors of Ubuntu source packages
|
## git_web_template: Launchpad's CGit mirrors of Ubuntu source packages
|
||||||
@@ -21,6 +24,10 @@
|
|||||||
api_base: https://api.launchpad.net/1.0
|
api_base: https://api.launchpad.net/1.0
|
||||||
ssh_host: ppa.launchpad.net
|
ssh_host: ppa.launchpad.net
|
||||||
ssh_port: 22
|
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}"
|
incoming_template: "~{owner}/{ppa}"
|
||||||
content_host_template: https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu
|
content_host_template: https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu
|
||||||
git_web_template: https://git.launchpad.net/ubuntu/+source/{package}
|
git_web_template: https://git.launchpad.net/ubuntu/+source/{package}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -9,6 +9,7 @@ description: |
|
|||||||
This snap uses classic confinement and carries the packaging
|
This snap uses classic confinement and carries the packaging
|
||||||
toolchain it drives (dpkg-dev, git, mmdebstrap, lintian, quilt, ...)
|
toolchain it drives (dpkg-dev, git, mmdebstrap, lintian, quilt, ...)
|
||||||
so it behaves the same on any Debian/Ubuntu host.
|
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: classic
|
confinement: classic
|
||||||
@@ -83,6 +84,12 @@ parts:
|
|||||||
override-prime: |
|
override-prime: |
|
||||||
craftctl default
|
craftctl default
|
||||||
ln -sfn fakeroot-sysv "${CRAFT_PRIME}/usr/bin/fakeroot"
|
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
|
# Classic-confined ELFs default to the host loader, which pins the
|
||||||
# snap to hosts shipping at least the build environment's glibc,
|
# snap to hosts shipping at least the build environment's glibc,
|
||||||
# and cannot see the libraries deduplicated against the base.
|
# and cannot see the libraries deduplicated against the base.
|
||||||
|
|||||||
+251
-22
@@ -137,7 +137,9 @@ pub async fn generate_entry(
|
|||||||
enum Bump {
|
enum Bump {
|
||||||
/// Regular upload: increment the trailing number
|
/// Regular upload: increment the trailing number
|
||||||
Normal,
|
Normal,
|
||||||
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`
|
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`; a trailing
|
||||||
|
/// no-change-rebuild marker is dropped first (`1.0-9build1` becomes
|
||||||
|
/// `1.0-9ubuntu1`)
|
||||||
Ubuntu,
|
Ubuntu,
|
||||||
/// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0`
|
/// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0`
|
||||||
/// becomes `1.0+nmu1`
|
/// becomes `1.0+nmu1`
|
||||||
@@ -158,7 +160,7 @@ fn compute_new_version(
|
|||||||
bump: Bump,
|
bump: Bump,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
match bump {
|
match bump {
|
||||||
Bump::Ubuntu => increment_suffix(old_version, "ubuntu"),
|
Bump::Ubuntu => increment_suffix(strip_build_suffix(old_version), "ubuntu"),
|
||||||
Bump::Rebuild => increment_suffix(old_version, "build"),
|
Bump::Rebuild => increment_suffix(old_version, "build"),
|
||||||
Bump::Nmu => {
|
Bump::Nmu => {
|
||||||
if old_version.contains('-') {
|
if old_version.contains('-') {
|
||||||
@@ -177,6 +179,19 @@ fn compute_new_version(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The version an Ubuntu upload is numbered from: a trailing
|
||||||
|
/// no-change-rebuild marker is dropped, because a real change on top of a
|
||||||
|
/// rebuild replaces the marker rather than appending to it — `X-2build1`
|
||||||
|
/// becomes `X-2ubuntu1`, where an appended `X-2build1ubuntu1` would
|
||||||
|
/// misrepresent the lineage and sort below `X-2ubuntu1`
|
||||||
|
fn strip_build_suffix(version: &str) -> &str {
|
||||||
|
let stem = version.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||||
|
match stem.strip_suffix("build") {
|
||||||
|
Some(base) if stem.len() < version.len() => &version[..base.len()],
|
||||||
|
_ => version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
|
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
|
||||||
/// from the vendor of the target series: Ubuntu series number their uploads
|
/// from the vendor of the target series: Ubuntu series number their uploads
|
||||||
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
|
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
|
||||||
@@ -294,9 +309,16 @@ pub enum SeriesCandidates {
|
|||||||
/// cannot be made (cancelled, no interactive user) `fallback` — the
|
/// cannot be made (cancelled, no interactive user) `fallback` — the
|
||||||
/// changelog's current series — is used instead.
|
/// changelog's current series — is used instead.
|
||||||
Choose {
|
Choose {
|
||||||
/// Series names to offer.
|
/// Selector labels: each series name, or `<suite> (<series>)`
|
||||||
|
/// for a series aliased by a changelog suite name (Debian's
|
||||||
|
/// 'unstable (sid)').
|
||||||
options: Vec<String>,
|
options: Vec<String>,
|
||||||
/// Preselected series.
|
/// Changelog distribution each label of `options` maps to,
|
||||||
|
/// parallel to it: an aliased label selects its suite name —
|
||||||
|
/// what a changelog distribution field expects — while every
|
||||||
|
/// other label selects itself.
|
||||||
|
values: Vec<String>,
|
||||||
|
/// Preselected label.
|
||||||
default: String,
|
default: String,
|
||||||
/// Series to fall back to when nothing can be selected.
|
/// Series to fall back to when nothing can be selected.
|
||||||
fallback: String,
|
fallback: String,
|
||||||
@@ -312,8 +334,10 @@ pub enum SeriesCandidates {
|
|||||||
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
|
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
|
||||||
/// keeps the changelog unreleased) on top of the current vendor's series
|
/// keeps the changelog unreleased) on top of the current vendor's series
|
||||||
/// list, defaulting to the development series; any other series resolves
|
/// list, defaulting to the development series; any other series resolves
|
||||||
/// through the series list of its own distribution. `None` when the
|
/// through the series list of its own distribution. A changelog suite name
|
||||||
/// changelog cannot be parsed (no default to derive at all).
|
/// (Debian's 'unstable') identifies the same series as its alias's codename
|
||||||
|
/// ('sid') and resolves to that dist's list. `None` when the changelog
|
||||||
|
/// cannot be parsed (no default to derive at all).
|
||||||
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
|
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
|
||||||
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
|
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
|
||||||
|
|
||||||
@@ -321,10 +345,13 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
|
|||||||
// Vendors keep original casing ("Ubuntu"), while the series data
|
// Vendors keep original casing ("Ubuntu"), while the series data
|
||||||
// keys are lowercase
|
// keys are lowercase
|
||||||
let dist = crate::build::env::current_vendor().to_lowercase();
|
let dist = crate::build::env::current_vendor().to_lowercase();
|
||||||
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
|
|
||||||
match crate::distro_info::get_ordered_series_name(&dist).await {
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
Ok(series_list) => {
|
Ok(series_list) => {
|
||||||
options.extend(series_list);
|
let (labels, series_values, _) = selector_options(&dist, &series_list, "");
|
||||||
|
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
|
||||||
|
let mut values = vec![crate::distro_info::UNRELEASED.to_string()];
|
||||||
|
options.extend(labels);
|
||||||
|
values.extend(series_values);
|
||||||
// Default to the development series (the first real entry),
|
// Default to the development series (the first real entry),
|
||||||
// not to the pinned UNRELEASED entry itself
|
// not to the pinned UNRELEASED entry itself
|
||||||
let default = if options.len() > 1 {
|
let default = if options.len() > 1 {
|
||||||
@@ -334,6 +361,7 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
|
|||||||
};
|
};
|
||||||
Some(SeriesCandidates::Choose {
|
Some(SeriesCandidates::Choose {
|
||||||
options,
|
options,
|
||||||
|
values,
|
||||||
default,
|
default,
|
||||||
fallback: current,
|
fallback: current,
|
||||||
})
|
})
|
||||||
@@ -341,22 +369,81 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
|
|||||||
Err(_) => Some(SeriesCandidates::Keep(current)),
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match crate::distro_info::get_dist_from_series(¤t).await {
|
// The changelog may target a suite name instead of a series
|
||||||
Ok(dist) => match crate::distro_info::get_ordered_series_name(&dist).await {
|
// codename: Debian conventionally writes 'unstable' where the
|
||||||
// Even an empty list goes through the selector: its
|
// series data carries 'sid'. The two identify the same series:
|
||||||
// fallback prints and takes the default, like it always has
|
// the alias resolves to its codename's dist for the lookup.
|
||||||
Ok(options) => Some(SeriesCandidates::Choose {
|
let resolved = match crate::distro_info::get_dist_from_series(¤t).await {
|
||||||
options,
|
Ok(dist) => Some((dist, current.clone())),
|
||||||
default: current.clone(),
|
Err(_) => crate::distro_info::resolve_suite_alias(¤t),
|
||||||
fallback: current,
|
};
|
||||||
}),
|
match resolved {
|
||||||
Err(_) => Some(SeriesCandidates::Keep(current)),
|
Some((dist, canonical)) => {
|
||||||
},
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||||
Err(_) => Some(SeriesCandidates::Keep(current)),
|
// Even an empty list goes through the selector: its
|
||||||
|
// fallback prints and takes the default, like it always has
|
||||||
|
Ok(series_list) => {
|
||||||
|
let (options, values, default) =
|
||||||
|
selector_options(&dist, &series_list, &canonical);
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
values,
|
||||||
|
// A stale alias whose codename left the series
|
||||||
|
// list offers the raw name instead
|
||||||
|
default: default.unwrap_or_else(|| canonical.clone()),
|
||||||
|
fallback: current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Some(SeriesCandidates::Keep(current)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The selector entries for a dist's series list: (labels, changelog
|
||||||
|
/// targets, label of `current`'s entry). A series aliased by a
|
||||||
|
/// changelog suite name (Debian's 'unstable' for 'sid') is offered as
|
||||||
|
/// `<suite> (<series>)` but targets the suite name — what a changelog
|
||||||
|
/// distribution field expects — while every other series is offered,
|
||||||
|
/// and targeted, under its own name. `current` may be a name that is
|
||||||
|
/// not in the list at all (e.g. UNRELEASED), in which case no default
|
||||||
|
/// is returned.
|
||||||
|
fn selector_options(
|
||||||
|
dist: &str,
|
||||||
|
series: &[String],
|
||||||
|
current: &str,
|
||||||
|
) -> (Vec<String>, Vec<String>, Option<String>) {
|
||||||
|
let mut labels = Vec::with_capacity(series.len());
|
||||||
|
let mut values = Vec::with_capacity(series.len());
|
||||||
|
let mut default = None;
|
||||||
|
for s in series {
|
||||||
|
let (label, value) = match crate::distro_info::series_suite_alias(dist, s) {
|
||||||
|
Some(suite) => (format!("{suite} ({s})"), suite),
|
||||||
|
None => (s.clone(), s.clone()),
|
||||||
|
};
|
||||||
|
if s == current {
|
||||||
|
default = Some(label.clone());
|
||||||
|
}
|
||||||
|
labels.push(label);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
(labels, values, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changelog distribution a selected series-selector label maps to
|
||||||
|
/// ([`SeriesCandidates::Choose`]): an aliased entry ('unstable (sid)')
|
||||||
|
/// targets its suite name, any other label targets itself, and a
|
||||||
|
/// free-typed series the selector does not offer is its own target.
|
||||||
|
pub fn selected_series(options: &[String], values: &[String], selected: String) -> String {
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.position(|o| *o == selected)
|
||||||
|
.and_then(|idx| values.get(idx).cloned())
|
||||||
|
.unwrap_or(selected)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a changelog file footer to extract maintainer information
|
/// Parse a changelog file footer to extract maintainer information
|
||||||
/// Returns (name, email) tuple from the last modification entry
|
/// Returns (name, email) tuple from the last modification entry
|
||||||
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||||
@@ -639,12 +726,18 @@ mod tests {
|
|||||||
match series_candidates(&path).await {
|
match series_candidates(&path).await {
|
||||||
Some(SeriesCandidates::Choose {
|
Some(SeriesCandidates::Choose {
|
||||||
options,
|
options,
|
||||||
|
values,
|
||||||
default,
|
default,
|
||||||
fallback,
|
fallback,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(options[0], "UNRELEASED");
|
assert_eq!(options[0], "UNRELEASED");
|
||||||
|
assert_eq!(values[0], "UNRELEASED");
|
||||||
assert!(options.len() > 1, "the vendor series list is offered");
|
assert!(options.len() > 1, "the vendor series list is offered");
|
||||||
assert_eq!(default, options[1]);
|
assert_eq!(default, options[1]);
|
||||||
|
assert_eq!(
|
||||||
|
selected_series(&options, &values, default.clone()),
|
||||||
|
values[1]
|
||||||
|
);
|
||||||
assert_eq!(fallback, "UNRELEASED");
|
assert_eq!(fallback, "UNRELEASED");
|
||||||
}
|
}
|
||||||
other => panic!("expected Choose, got {other:?}"),
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
@@ -677,17 +770,105 @@ mod tests {
|
|||||||
match series_candidates(&path).await {
|
match series_candidates(&path).await {
|
||||||
Some(SeriesCandidates::Choose {
|
Some(SeriesCandidates::Choose {
|
||||||
options,
|
options,
|
||||||
|
values,
|
||||||
default,
|
default,
|
||||||
fallback,
|
fallback,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(options, vendor_series);
|
// The current series is preselected through its label, and
|
||||||
assert_eq!(default, *current);
|
// selecting it targets the name the changelog already carries
|
||||||
|
let idx = values
|
||||||
|
.iter()
|
||||||
|
.position(|v| v == current)
|
||||||
|
.expect("the current series is offered");
|
||||||
|
assert_eq!(default, options[idx]);
|
||||||
|
assert_eq!(
|
||||||
|
selected_series(&options, &values, options[idx].clone()),
|
||||||
|
*current
|
||||||
|
);
|
||||||
assert_eq!(fallback, *current);
|
assert_eq!(fallback, *current);
|
||||||
}
|
}
|
||||||
other => panic!("expected Choose, got {other:?}"),
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A changelog targeting Debian's 'unstable' suite — the conventional
|
||||||
|
/// Debian development distribution, which the series data knows as the
|
||||||
|
/// codename 'sid' — resolves to the Debian series list: the selector
|
||||||
|
/// offers the aliased entry as 'unstable (sid)', preselected, and
|
||||||
|
/// selecting it targets 'unstable' itself.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn series_candidates_suite_alias_matches_unstable_and_sid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("changelog");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"hello (1.0-1) unstable; urgency=medium\n\n * Something.\n\n \
|
||||||
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match series_candidates(&path).await {
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
values,
|
||||||
|
default,
|
||||||
|
fallback,
|
||||||
|
}) => {
|
||||||
|
let idx = options
|
||||||
|
.iter()
|
||||||
|
.position(|o| o == "unstable (sid)")
|
||||||
|
.expect("sid is offered as its suite alias");
|
||||||
|
assert_eq!(values[idx], "unstable");
|
||||||
|
assert_eq!(default, "unstable (sid)");
|
||||||
|
assert_eq!(fallback, "unstable");
|
||||||
|
// Selecting the aliased entry targets the suite name
|
||||||
|
assert_eq!(
|
||||||
|
selected_series(&options, &values, options[idx].clone()),
|
||||||
|
"unstable"
|
||||||
|
);
|
||||||
|
// A free-typed series the selector does not offer is its own
|
||||||
|
// target
|
||||||
|
assert_eq!(
|
||||||
|
selected_series(&options, &values, "trixie".to_string()),
|
||||||
|
"trixie"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A changelog naming the codename ('sid') resolves to the same
|
||||||
|
/// selector entry as the suite alias ('unstable'): the two identify
|
||||||
|
/// the same series.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn series_candidates_sid_defaults_to_the_suite_alias_label() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("changelog");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"hello (1.0-1) sid; urgency=medium\n\n * Something.\n\n \
|
||||||
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match series_candidates(&path).await {
|
||||||
|
Some(SeriesCandidates::Choose {
|
||||||
|
options,
|
||||||
|
values,
|
||||||
|
default,
|
||||||
|
fallback,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(default, "unstable (sid)");
|
||||||
|
assert_eq!(
|
||||||
|
selected_series(&options, &values, default.clone()),
|
||||||
|
"unstable"
|
||||||
|
);
|
||||||
|
assert_eq!(fallback, "sid");
|
||||||
|
}
|
||||||
|
other => panic!("expected Choose, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Without a parsable changelog there is no candidate at all.
|
/// Without a parsable changelog there is no candidate at all.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn series_candidates_none_without_changelog() {
|
async fn series_candidates_none_without_changelog() {
|
||||||
@@ -973,6 +1154,22 @@ mod tests {
|
|||||||
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
|
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
|
||||||
"15.2.0-9ubuntu2"
|
"15.2.0-9ubuntu2"
|
||||||
);
|
);
|
||||||
|
// Ubuntu upload on top of a rebuild drops the buildN marker:
|
||||||
|
// appending would give 15.2.0-9build1ubuntu1, which sorts below
|
||||||
|
// the proper 15.2.0-9ubuntu1
|
||||||
|
assert_eq!(
|
||||||
|
compute_new_version("15.2.0-9build1", Bump::Ubuntu).unwrap(),
|
||||||
|
"15.2.0-9ubuntu1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compute_new_version("15.2.0-9ubuntu1build1", Bump::Ubuntu).unwrap(),
|
||||||
|
"15.2.0-9ubuntu2"
|
||||||
|
);
|
||||||
|
// Native packages
|
||||||
|
assert_eq!(
|
||||||
|
compute_new_version("15.2.0build1", Bump::Ubuntu).unwrap(),
|
||||||
|
"15.2.0ubuntu1"
|
||||||
|
);
|
||||||
|
|
||||||
// No change rebuild
|
// No change rebuild
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1229,6 +1426,38 @@ mod tests {
|
|||||||
assert_eq!(entry.new_version, "1.0-1ubuntu2");
|
assert_eq!(entry.new_version, "1.0-1ubuntu2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An Ubuntu upload of a package whose changelog carries a rebuild
|
||||||
|
/// version drops the buildN marker: 1.0-1build1 numbers the next entry
|
||||||
|
/// 1.0-1ubuntu1 (1.0-1build1ubuntu1 would sort below 1.0-1ubuntu1).
|
||||||
|
/// Relies on the host distro-info data listing noble (see the
|
||||||
|
/// distro_info tests). The git repo provides the maintainer identity:
|
||||||
|
/// DEBFULLNAME/DEBEMAIL are process-global and other tests mutate them
|
||||||
|
/// in parallel.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_entry_ubuntu_series_numbering_after_rebuild() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let repo_dir = temp_dir.path();
|
||||||
|
setup_repo(repo_dir);
|
||||||
|
let changelog_path = repo_dir.join("debian/changelog");
|
||||||
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&changelog_path,
|
||||||
|
"mypackage (1.0-1build1) noble; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let entry = generate_entry(
|
||||||
|
"debian/changelog",
|
||||||
|
Some(repo_dir),
|
||||||
|
None,
|
||||||
|
Some("noble"),
|
||||||
|
EntryKind::Normal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entry.new_version, "1.0-1ubuntu1");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_maintainer_info() {
|
fn test_get_maintainer_info() {
|
||||||
let _identity = IDENTITY_LOCK.blocking_lock();
|
let _identity = IDENTITY_LOCK.blocking_lock();
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -313,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<()> {
|
||||||
|
|||||||
@@ -155,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<()> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -356,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 {
|
||||||
|
|||||||
+80
-271
@@ -1,139 +1,24 @@
|
|||||||
use crate::context::{self, Context, ContextConfig};
|
use crate::context::{self, Context, ContextConfig};
|
||||||
use crate::deb::{Phase, enter_phase};
|
use crate::deb::{Phase, enter_phase};
|
||||||
|
use crate::interrupt::CleanupHookGuard;
|
||||||
use crate::report::BuildView;
|
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
|
||||||
@@ -143,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
|
||||||
@@ -175,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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,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)
|
||||||
})))
|
})))
|
||||||
@@ -359,10 +260,18 @@ impl EphemeralContextGuard {
|
|||||||
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,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. \
|
||||||
@@ -585,6 +499,11 @@ 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;
|
||||||
@@ -702,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
|
||||||
@@ -799,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.
|
||||||
|
|||||||
+14
-6
@@ -34,6 +34,7 @@ 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: &[String],
|
ppa: &[String],
|
||||||
inject_packages: &[String],
|
inject_packages: &[String],
|
||||||
@@ -231,10 +232,8 @@ pub async fn build(
|
|||||||
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
|
||||||
// Find the actual package directory
|
// the tree the user pointed at, or the name-pattern search fallback)
|
||||||
let package_dir =
|
|
||||||
crate::deb::find_package_directory(Path::new(build_root), package, version, series, &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")?;
|
||||||
@@ -725,8 +724,17 @@ fn install_build_dependencies(
|
|||||||
let status = cap(&mut cmd, sink).status()?;
|
let status = cap(&mut cmd, sink).status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
view.suspend();
|
view.suspend();
|
||||||
if let Err(e) =
|
// Diagnosing a dependency failure the user interrupted themselves
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())
|
// 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}");
|
log::debug!("dose-builddebcheck diagnosis failed: {e}");
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-5
@@ -269,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
|
||||||
@@ -282,6 +295,7 @@ async fn build_binary_package_impl(
|
|||||||
series,
|
series,
|
||||||
pocket.as_deref(),
|
pocket.as_deref(),
|
||||||
&build_root,
|
&build_root,
|
||||||
|
&package_dir,
|
||||||
cross,
|
cross,
|
||||||
ppa,
|
ppa,
|
||||||
inject,
|
inject,
|
||||||
@@ -329,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
|
||||||
@@ -413,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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +549,101 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
@@ -837,4 +980,80 @@ mod tests {
|
|||||||
"error should name the unsatisfied dependency: {err}"
|
"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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ struct DistData {
|
|||||||
cross_pockets: Vec<String>,
|
cross_pockets: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
build_profiles: Vec<String>,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,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
|
||||||
@@ -1035,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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ struct LaunchpadData {
|
|||||||
ssh_host: String,
|
ssh_host: String,
|
||||||
/// Port of the PPA SFTP upload server
|
/// Port of the PPA SFTP upload server
|
||||||
ssh_port: u16,
|
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}`)
|
/// Upload queue incoming directory template (`{owner}`/`{ppa}`)
|
||||||
incoming_template: String,
|
incoming_template: String,
|
||||||
/// PPA package-content (apt repository) URL template
|
/// PPA package-content (apt repository) URL template
|
||||||
@@ -53,6 +58,13 @@ embed_data! {
|
|||||||
static ref LAUNCHPAD_DATA: LaunchpadData = "../data/launchpad.yml"
|
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
|
||||||
fn api_base() -> &'static str {
|
fn api_base() -> &'static str {
|
||||||
&LAUNCHPAD_DATA.api_base
|
&LAUNCHPAD_DATA.api_base
|
||||||
@@ -422,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());
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ 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`)
|
/// Lint a source tree: lintian wrapper for full parity plus pkh-native checks (`pkh lint`)
|
||||||
|
|||||||
+230
-2
@@ -20,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 =
|
||||||
@@ -31,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)")
|
||||||
@@ -446,10 +594,13 @@ fn main() {
|
|||||||
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
|
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
|
||||||
Some(pkh::changelog::SeriesCandidates::Choose {
|
Some(pkh::changelog::SeriesCandidates::Choose {
|
||||||
options,
|
options,
|
||||||
|
values,
|
||||||
default,
|
default,
|
||||||
fallback,
|
fallback,
|
||||||
}) => match pkh::ui::select_series(&options, &default) {
|
}) => match pkh::ui::select_series(&options, &default) {
|
||||||
Ok(selected) => Some(selected),
|
Ok(selected) => {
|
||||||
|
Some(pkh::changelog::selected_series(&options, &values, selected))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
"Series selection failed: {}. Using current series '{}' instead.",
|
"Series selection failed: {}. Using current series '{}' instead.",
|
||||||
@@ -511,6 +662,7 @@ 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()
|
||||||
@@ -561,6 +713,11 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
// The unmet-dependency diagnostics first, then the
|
||||||
// summary: the exact rendering the flow used to do.
|
// summary: the exact rendering the flow used to do.
|
||||||
if let Some(unmet) =
|
if let Some(unmet) =
|
||||||
@@ -582,6 +739,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
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")
|
||||||
@@ -611,12 +769,22 @@ fn main() {
|
|||||||
prompter: &prompter,
|
prompter: &prompter,
|
||||||
};
|
};
|
||||||
if let Err(e) = rt.block_on(async { pkh::put::put(&options).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();
|
||||||
|
// Ctrl+C during the build must say what happened and release the
|
||||||
|
// ephemeral chroot instead of dying on the default disposition.
|
||||||
|
// The live view (when enabled) registers its own reporter on top
|
||||||
|
// of this to clear the widget first.
|
||||||
|
interrupt::install();
|
||||||
let series = sub_matches.get_one::<String>("series").cloned();
|
let series = sub_matches.get_one::<String>("series").cloned();
|
||||||
let pocket = sub_matches.get_one::<String>("pocket").cloned();
|
let pocket = sub_matches.get_one::<String>("pocket").cloned();
|
||||||
let arch = sub_matches.get_one::<String>("arch").cloned();
|
let arch = sub_matches.get_one::<String>("arch").cloned();
|
||||||
@@ -683,6 +851,13 @@ fn main() {
|
|||||||
match result {
|
match result {
|
||||||
Ok(_) => info!("Done."),
|
Ok(_) => 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);
|
||||||
}
|
}
|
||||||
@@ -814,3 +989,56 @@ fn main() {
|
|||||||
_ => 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+66
-42
@@ -452,50 +452,57 @@ async fn fetch_orig_tarball(
|
|||||||
Path::new(&info.stanza.package).to_path_buf()
|
Path::new(&info.stanza.package).to_path_buf()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the orig tarball in the file list
|
// Upstream tarballs in the file list: the main orig tarball plus, for
|
||||||
// Usually ends with .orig.tar.gz or .orig.tar.xz
|
// multi-orig ("3.0 (quilt)" extra-component) sources, one component
|
||||||
let orig_file = info
|
// tarball per bundled module (`*.orig-<component>.tar.<ext>`). dpkg-source
|
||||||
|
// unpacks all of them side by side, so a git pull must fetch them all.
|
||||||
|
let orig_files: Vec<_> = info
|
||||||
.stanza
|
.stanza
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.name.contains(".orig.tar."))
|
.filter(|f| crate::build::changes::is_orig_tarball(&f.name))
|
||||||
.ok_or_else(|| {
|
.collect();
|
||||||
format!(
|
if orig_files.is_empty() {
|
||||||
"Could not find orig tarball in file list for package '{}'. \
|
return Err(format!(
|
||||||
Available files: {:?}",
|
"Could not find orig tarball in file list for package '{}'. \
|
||||||
info.stanza.package,
|
Available files: {:?}",
|
||||||
info.stanza
|
info.stanza.package,
|
||||||
.files
|
info.stanza
|
||||||
.iter()
|
.files
|
||||||
.map(|f| &f.name)
|
.iter()
|
||||||
.collect::<Vec<_>>()
|
.map(|f| &f.name)
|
||||||
)
|
.collect::<Vec<_>>()
|
||||||
})?;
|
)
|
||||||
let filename = &orig_file.name;
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Try executing pristine-tar
|
// 1. Try executing pristine-tar
|
||||||
|
|
||||||
// Setup pristine-tar branch if needed (by tracking remote branch)
|
// Setup pristine-tar branch if needed (by tracking remote branch)
|
||||||
let _ = setup_pristine_tar_branch(&package_dir, info.dist.as_str());
|
let _ = setup_pristine_tar_branch(&package_dir, info.dist.as_str());
|
||||||
|
|
||||||
if let Err(e) = checkout_pristine_tar(&package_dir, filename.as_str()) {
|
for orig_file in orig_files {
|
||||||
debug!(
|
let filename = &orig_file.name;
|
||||||
"pristine-tar failed: {}. Falling back to archive download.",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Fallback to archive download
|
if let Err(e) = checkout_pristine_tar(&package_dir, filename.as_str()) {
|
||||||
// We download to the parent directory of the package repo (which is standard for build tools)
|
debug!(
|
||||||
// or the current directory if cwd is None (which effectively is the parent of the package dir)
|
"pristine-tar failed: {}. Falling back to archive download.",
|
||||||
let target_dir = cwd.unwrap_or_else(|| Path::new("."));
|
e
|
||||||
download_file_checksum(
|
);
|
||||||
format!("{}/{}", info.archive_url, filename).as_str(),
|
|
||||||
&orig_file.checksum,
|
// 2. Fallback to archive download
|
||||||
orig_file.checksum_algo,
|
// We download to the parent directory of the package repo (which is standard for build tools)
|
||||||
target_dir,
|
// or the current directory if cwd is None (which effectively is the parent of the package dir)
|
||||||
progress,
|
let target_dir = cwd.unwrap_or_else(|| Path::new("."));
|
||||||
)
|
download_file_checksum(
|
||||||
.await?;
|
format!("{}/{}", info.archive_url, filename).as_str(),
|
||||||
|
&orig_file.checksum,
|
||||||
|
orig_file.checksum_algo,
|
||||||
|
target_dir,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -844,23 +851,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for orig tarball in package dir (only for non-native packages)
|
// Check for the orig tarballs in the package dir (only for non-native
|
||||||
let mut found_tarball = false;
|
// packages): every orig listed in the stanza must be present, including
|
||||||
|
// the component tarballs of multi-orig packages (dpkg-source needs them
|
||||||
|
// all to unpack the merged upstream tree)
|
||||||
let mut found_dsc = false;
|
let mut found_dsc = false;
|
||||||
for entry in std::fs::read_dir(package_dir).unwrap() {
|
for entry in std::fs::read_dir(&package_dir).unwrap() {
|
||||||
let entry = entry.unwrap();
|
let entry = entry.unwrap();
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if name.contains(".orig.tar.") {
|
|
||||||
found_tarball = true;
|
|
||||||
}
|
|
||||||
if name.ends_with(".dsc") {
|
if name.ends_with(".dsc") {
|
||||||
found_dsc = true;
|
found_dsc = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check for orig tarball if the package is not native
|
|
||||||
if !info.is_native() {
|
if !info.is_native() {
|
||||||
assert!(found_tarball, "Orig tarball not found in package dir");
|
for file in &info.stanza.files {
|
||||||
|
if crate::build::changes::is_orig_tarball(&file.name) {
|
||||||
|
assert!(
|
||||||
|
package_dir.join(&file.name).exists(),
|
||||||
|
"Orig tarball '{}' not found in package dir",
|
||||||
|
file.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert!(found_dsc, "DSC file not found in package dir");
|
assert!(found_dsc, "DSC file not found in package dir");
|
||||||
}
|
}
|
||||||
@@ -914,6 +927,17 @@ mod tests {
|
|||||||
test_pull_package_end_to_end("paraview", Some("noble"), None, None).await;
|
test_pull_package_end_to_end("paraview", Some("noble"), None, None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-orig ("3.0 (quilt)" extra component) regression test: node-jest
|
||||||
|
/// ships its bundled modules as separate `*.orig-<component>.tar.xz`
|
||||||
|
/// tarballs next to the main orig. The git pull path must fetch every
|
||||||
|
/// component, or the later `dpkg-source -b` quilt verification fails
|
||||||
|
/// with "can't find file to patch" on the first patch touching a
|
||||||
|
/// component directory.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_pull_node_jest_debian_end_to_end() {
|
||||||
|
test_pull_package_end_to_end("node-jest", Some("trixie"), None, None).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a minimal uncompressed ustar archive from (name, data) entries.
|
/// Build a minimal uncompressed ustar archive from (name, data) entries.
|
||||||
///
|
///
|
||||||
/// Raw header blocks are crafted instead of using `tar::Builder` because
|
/// Raw header blocks are crafted instead of using `tar::Builder` because
|
||||||
|
|||||||
+456
@@ -0,0 +1,456 @@
|
|||||||
|
//! Anonymous FTP transport for the Launchpad PPA upload queue: the graceful
|
||||||
|
//! degradation of the SFTP transport when the SSH connection itself never
|
||||||
|
//! comes up (name resolution, TCP, banner or key exchange). dput-ng's plain
|
||||||
|
//! `ppa:user/ppa` profile pushes over this same queue — ppa.launchpad.net
|
||||||
|
//! over FTP, anonymous login, incoming `~user/ppa` — so it is the
|
||||||
|
//! interoperability-tested path.
|
||||||
|
//!
|
||||||
|
//! The client is [`suppaftp`]'s (plain-FTP, no TLS) blocking stream on the
|
||||||
|
//! same time-bounded sockets as the ssh2 transport: the TCP connect and
|
||||||
|
//! the control/data channel reads and writes all carry timeouts, so a
|
||||||
|
//! black-holed or stalled server fails the upload instead of hanging it
|
||||||
|
//! (suppaftp's defaults do not bound them). The upload order (payload
|
||||||
|
//! first, `.changes` last — the caller passes the files in that order) and
|
||||||
|
//! the best-effort cleanup of a failed upload (`DELE` of what was already
|
||||||
|
//! pushed, in reverse upload order) mirror the SFTP path exactly.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use suppaftp::types::FileType;
|
||||||
|
use suppaftp::{FtpError, FtpStream};
|
||||||
|
|
||||||
|
use super::ssh;
|
||||||
|
|
||||||
|
/// Bounds one TCP connection attempt, to the control channel or a passive
|
||||||
|
/// data port: `connect(2)` would otherwise block for minutes (or forever,
|
||||||
|
/// behind a silent firewall). Generous enough for slow links to Launchpad,
|
||||||
|
/// short enough that a dead target fails in seconds — the same value and
|
||||||
|
/// rationale as the SSH path's bound.
|
||||||
|
const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Read/write timeout on the control channel: every reply is a few bytes,
|
||||||
|
/// so a stalled server has had its say by the time this expires.
|
||||||
|
const CONTROL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Write timeout of one data-transfer chunk: the clock restarts at every
|
||||||
|
/// write, so the wall-clock duration of a large upload is not bounded by
|
||||||
|
/// design — but a black-holed data connection fails one chunk after this
|
||||||
|
/// budget.
|
||||||
|
const DATA_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Data transfers stream in chunks of this size.
|
||||||
|
const CHUNK_SIZE: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// Upload `files` to the `incoming` queue of `host:port` over anonymous FTP,
|
||||||
|
/// in the order given (payload first, `.changes` last — the queue processor
|
||||||
|
/// must never observe a `.changes` without its payload).
|
||||||
|
/// `on_progress(name, uploaded_bytes, total_bytes)` reports progress.
|
||||||
|
///
|
||||||
|
/// A failure mid-upload best-effort removes what was already pushed (`DELE`,
|
||||||
|
/// reverse upload order — a lingering payload in the write-only queue area
|
||||||
|
/// is only hygiene) before returning the original error, like the SFTP
|
||||||
|
/// path's [`super::cleanup_partial_upload`].
|
||||||
|
pub fn upload_queue(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
incoming: &str,
|
||||||
|
files: &[(PathBuf, String)],
|
||||||
|
on_progress: &dyn Fn(&str, u64, u64),
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut ftp = connect(host, port)?;
|
||||||
|
let who = std::env::var("USER").unwrap_or_else(|_| "anonymous".to_string());
|
||||||
|
ftp.login("anonymous".to_string(), format!("{who}@pkh.invalid"))
|
||||||
|
.map_err(|e| format!("the FTP queue rejected the anonymous login: {e}"))?;
|
||||||
|
ftp.cwd(incoming)
|
||||||
|
.map_err(|e| format!("cannot enter the upload queue '{incoming}': {e}"))?;
|
||||||
|
ftp.transfer_type(FileType::Binary)
|
||||||
|
.map_err(|e| format!("the FTP queue refused binary transfers: {e}"))?;
|
||||||
|
|
||||||
|
// Remote names pushed so far, in upload order, for the cleanup
|
||||||
|
let mut uploaded: Vec<String> = Vec::new();
|
||||||
|
for (path, name) in files {
|
||||||
|
if let Err(e) = store(&mut ftp, path, name, on_progress) {
|
||||||
|
// The failed file itself joins the cleanup: its `STOR` was
|
||||||
|
// accepted before the transfer failure, so a partial may be
|
||||||
|
// sitting in the queue
|
||||||
|
for leftover in super::cleanup_list(&uploaded, Some(name)) {
|
||||||
|
match ftp.rm(leftover.as_str()) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("Removed leftover {leftover} from the failed upload")
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!(
|
||||||
|
"Could not remove the leftover {leftover} of the \
|
||||||
|
failed upload: {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
uploaded.push(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best effort: the queue keeps what it accepted, so a failure here must
|
||||||
|
// not fail a completed upload
|
||||||
|
if let Err(e) = ftp.quit() {
|
||||||
|
log::debug!("closing the FTP session: {e}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to the queue, read its banner and set every time bound and
|
||||||
|
/// workaround the plain suppaftp stream does not carry by itself.
|
||||||
|
fn connect(host: &str, port: u16) -> Result<FtpStream, Box<dyn std::error::Error>> {
|
||||||
|
let tcp = ssh::tcp_connect(host, port)?;
|
||||||
|
let mut ftp = FtpStream::connect_with_stream(tcp)?.passive_stream_builder(data_connect);
|
||||||
|
{
|
||||||
|
let control = ftp.get_ref();
|
||||||
|
control.set_read_timeout(Some(CONTROL_TIMEOUT))?;
|
||||||
|
control.set_write_timeout(Some(CONTROL_TIMEOUT))?;
|
||||||
|
}
|
||||||
|
// A `PASV` reply announcing an unroutable address (a server behind NAT
|
||||||
|
// that does not know its public IP) means the control connection's peer
|
||||||
|
ftp.set_passive_nat_workaround(true);
|
||||||
|
Ok(ftp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The passive data-channel connect: bounded like every other network call,
|
||||||
|
/// where suppaftp's default builder is a plain, unbounded
|
||||||
|
/// `TcpStream::connect`.
|
||||||
|
fn data_connect(addr: SocketAddr) -> Result<TcpStream, FtpError> {
|
||||||
|
TcpStream::connect_timeout(&addr, TCP_CONNECT_TIMEOUT).map_err(FtpError::ConnectionError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload `path` as `name` over a passive data connection, reporting
|
||||||
|
/// progress through `on_progress` per chunk. `finish` reads the transfer
|
||||||
|
/// completion reply — the only way to learn the server accepted the file.
|
||||||
|
fn store(
|
||||||
|
ftp: &mut FtpStream,
|
||||||
|
path: &Path,
|
||||||
|
name: &str,
|
||||||
|
on_progress: &dyn Fn(&str, u64, u64),
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut upload = ftp
|
||||||
|
.put_with_stream(name)
|
||||||
|
.map_err(|e| format!("the FTP queue rejected '{name}': {e}"))?;
|
||||||
|
if let suppaftp::DataStream::Tcp(socket) = upload.get_mut() {
|
||||||
|
socket.set_write_timeout(Some(DATA_TIMEOUT))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file =
|
||||||
|
File::open(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
|
||||||
|
let total = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||||
|
let mut buffer = vec![0u8; CHUNK_SIZE];
|
||||||
|
let mut uploaded: u64 = 0;
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
upload.write_all(&buffer[..read])?;
|
||||||
|
uploaded += read as u64;
|
||||||
|
on_progress(name, uploaded, total);
|
||||||
|
}
|
||||||
|
upload
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("upload of '{name}' failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
/// In-process fake of the Launchpad FTP upload queue: one control
|
||||||
|
/// session speaking the protocol subset the client uses (banner,
|
||||||
|
/// USER/PASS, CWD, TYPE, PASV, STOR, DELE, QUIT). Records every
|
||||||
|
/// command, stores received bytes under the `STOR` name, and can be
|
||||||
|
/// told to reject one `STOR` (by index) to exercise the cleanup. Its
|
||||||
|
/// `PASV` replies announce `0.0.0.0`, the NAT form, so the
|
||||||
|
/// happy-path test proves the control-peer fallback too.
|
||||||
|
struct FakeQueue {
|
||||||
|
addr: SocketAddr,
|
||||||
|
commands: Arc<Mutex<Vec<String>>>,
|
||||||
|
files: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeQueue {
|
||||||
|
/// Serve one session on a background thread, greeting it with
|
||||||
|
/// `banner` and rejecting the `STOR` number `reject_stor`, when
|
||||||
|
/// set.
|
||||||
|
fn start(banner: &str, reject_stor: Option<usize>) -> FakeQueue {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let commands = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let files = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let (c, f) = (Arc::clone(&commands), Arc::clone(&files));
|
||||||
|
let banner = banner.to_string();
|
||||||
|
std::thread::spawn(move || serve(listener, &banner, c, f, reject_stor));
|
||||||
|
FakeQueue {
|
||||||
|
addr,
|
||||||
|
commands,
|
||||||
|
files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve(
|
||||||
|
listener: TcpListener,
|
||||||
|
banner: &str,
|
||||||
|
commands: Arc<Mutex<Vec<String>>>,
|
||||||
|
files: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||||
|
reject_stor: Option<usize>,
|
||||||
|
) {
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
let (stream, _) = listener.accept().unwrap();
|
||||||
|
let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
|
||||||
|
let mut writer = stream;
|
||||||
|
writer
|
||||||
|
.write_all(format!("{banner}\r\n").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut pending_data: Option<TcpListener> = None;
|
||||||
|
let stor_index = AtomicUsize::new(0);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let cmd = line.trim_end().to_string();
|
||||||
|
commands.lock().push(cmd.clone());
|
||||||
|
let (verb, arg) = cmd.split_once(' ').unwrap_or((cmd.as_str(), ""));
|
||||||
|
match verb {
|
||||||
|
"USER" | "PASS" => {
|
||||||
|
writer
|
||||||
|
.write_all(b"230 Anonymous login ok, access restrictions apply.\r\n")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"CWD" => {
|
||||||
|
writer.write_all(b"250 Command successful\r\n").unwrap();
|
||||||
|
}
|
||||||
|
"TYPE" => {
|
||||||
|
writer.write_all(b"200 Type set to I\r\n").unwrap();
|
||||||
|
}
|
||||||
|
"PASV" => {
|
||||||
|
let data = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = data.local_addr().unwrap().port();
|
||||||
|
writer
|
||||||
|
.write_all(
|
||||||
|
format!(
|
||||||
|
"227 Entering Passive Mode (0,0,0,0,{},{})\r\n",
|
||||||
|
port / 256,
|
||||||
|
port % 256
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
pending_data = Some(data);
|
||||||
|
}
|
||||||
|
"STOR" => {
|
||||||
|
let index = stor_index.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if Some(index) == reject_stor {
|
||||||
|
writer.write_all(b"550 Permission denied\r\n").unwrap();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writer.write_all(b"150 Ok to send data\r\n").unwrap();
|
||||||
|
let (mut data, _) = pending_data.take().unwrap().accept().unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
data.read_to_end(&mut bytes).unwrap();
|
||||||
|
files.lock().insert(arg.to_string(), bytes);
|
||||||
|
writer.write_all(b"226 Transfer complete\r\n").unwrap();
|
||||||
|
}
|
||||||
|
"DELE" => {
|
||||||
|
if files.lock().remove(arg).is_some() {
|
||||||
|
writer.write_all(b"250 File deleted\r\n").unwrap();
|
||||||
|
} else {
|
||||||
|
writer.write_all(b"550 No such file\r\n").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"QUIT" => {
|
||||||
|
writer.write_all(b"221 Bye\r\n").unwrap();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
writer
|
||||||
|
.write_all(format!("502 Command '{other}' not implemented\r\n").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A temp file with `content`, to upload.
|
||||||
|
fn upload_file(dir: &Path, name: &str, content: &[u8]) -> (PathBuf, String) {
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, content).unwrap();
|
||||||
|
(path, name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full push: anonymous login, the queue directory entered,
|
||||||
|
/// payload files uploaded in order before the `.changes`, their bytes
|
||||||
|
/// intact, and progress reported up to each file's size.
|
||||||
|
#[test]
|
||||||
|
fn upload_queue_pushes_payload_before_changes() {
|
||||||
|
let queue = FakeQueue::start("220 Launchpad upload server", None);
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let files = vec![
|
||||||
|
upload_file(dir.path(), "pkg_1.0.orig.tar.xz", b"orig bytes"),
|
||||||
|
upload_file(dir.path(), "pkg_1.0-1.dsc", b"dsc bytes"),
|
||||||
|
upload_file(dir.path(), "pkg_1.0-1_source.changes", b"changes bytes"),
|
||||||
|
];
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let progress_log = Arc::clone(&seen);
|
||||||
|
|
||||||
|
upload_queue(
|
||||||
|
"127.0.0.1",
|
||||||
|
queue.addr.port(),
|
||||||
|
"~vhaudiquet/lp2167827",
|
||||||
|
&files,
|
||||||
|
&|name, uploaded, total| {
|
||||||
|
progress_log
|
||||||
|
.lock()
|
||||||
|
.push((name.to_string(), uploaded, total))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let commands = queue.commands.lock().clone();
|
||||||
|
assert_eq!(
|
||||||
|
commands[0], "USER anonymous",
|
||||||
|
"the anonymous login comes first"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c == &"CWD ~vhaudiquet/lp2167827")
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let cwd = commands.iter().position(|c| c.starts_with("CWD")).unwrap();
|
||||||
|
let first_stor = commands.iter().position(|c| c.starts_with("STOR")).unwrap();
|
||||||
|
assert!(cwd < first_stor, "the queue directory is entered first");
|
||||||
|
assert!(
|
||||||
|
commands.iter().any(|c| c == "TYPE I"),
|
||||||
|
"binary transfers are requested"
|
||||||
|
);
|
||||||
|
// Upload order: the payload first, the .changes last
|
||||||
|
let stors: Vec<&String> = commands.iter().filter(|c| c.starts_with("STOR")).collect();
|
||||||
|
assert_eq!(
|
||||||
|
stors.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"STOR pkg_1.0.orig.tar.xz",
|
||||||
|
"STOR pkg_1.0-1.dsc",
|
||||||
|
"STOR pkg_1.0-1_source.changes",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(*commands.last().unwrap(), "QUIT");
|
||||||
|
|
||||||
|
// The received bytes are intact
|
||||||
|
let stored = queue.files.lock();
|
||||||
|
assert_eq!(stored.get("pkg_1.0.orig.tar.xz").unwrap(), b"orig bytes");
|
||||||
|
assert_eq!(stored.get("pkg_1.0-1.dsc").unwrap(), b"dsc bytes");
|
||||||
|
assert_eq!(
|
||||||
|
stored.get("pkg_1.0-1_source.changes").unwrap(),
|
||||||
|
b"changes bytes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress reached each file's size
|
||||||
|
let progress = seen.lock();
|
||||||
|
for (path, name) in &files {
|
||||||
|
let size = path.metadata().unwrap().len();
|
||||||
|
let reached = progress
|
||||||
|
.iter()
|
||||||
|
.any(|(n, uploaded, total)| n == name && *uploaded == size && *total == size);
|
||||||
|
assert!(reached, "no progress report completed for '{name}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `STOR` the queue rejects fails the upload, and the client removes
|
||||||
|
/// what it already pushed — the failed file first, then the earlier
|
||||||
|
/// payloads, in reverse upload order — before returning the error.
|
||||||
|
#[test]
|
||||||
|
fn upload_queue_cleans_up_after_a_rejected_stor() {
|
||||||
|
let queue = FakeQueue::start("220 Launchpad upload server", Some(1));
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let files = vec![
|
||||||
|
upload_file(dir.path(), "one.dsc", b"one"),
|
||||||
|
upload_file(dir.path(), "two.tar.xz", b"two"),
|
||||||
|
upload_file(dir.path(), "three.changes", b"three"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let err = upload_queue(
|
||||||
|
"127.0.0.1",
|
||||||
|
queue.addr.port(),
|
||||||
|
"~user/ppa",
|
||||||
|
&files,
|
||||||
|
&|_, _, _| {},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("rejected 'two.tar.xz'"),
|
||||||
|
"the error names the rejected file, got: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let commands = queue.commands.lock();
|
||||||
|
let deles: Vec<&String> = commands.iter().filter(|c| c.starts_with("DELE")).collect();
|
||||||
|
assert_eq!(
|
||||||
|
deles.iter().map(|d| d.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["DELE two.tar.xz", "DELE one.dsc"],
|
||||||
|
"the failed file is removed first, then the earlier payloads"
|
||||||
|
);
|
||||||
|
let files = queue.files.lock();
|
||||||
|
assert!(
|
||||||
|
!files.contains_key("one.dsc"),
|
||||||
|
"the pushed payload is removed"
|
||||||
|
);
|
||||||
|
assert!(!files.contains_key("three.changes"), "never reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-line banner (`220-first` closed by `220 last`) parses like
|
||||||
|
/// the single-line form.
|
||||||
|
#[test]
|
||||||
|
fn upload_queue_reads_multiline_replies() {
|
||||||
|
let queue = FakeQueue::start("220-Launchpad\r\n220 upload server", None);
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let files = vec![upload_file(dir.path(), "pkg.dsc", b"bytes")];
|
||||||
|
|
||||||
|
upload_queue(
|
||||||
|
"127.0.0.1",
|
||||||
|
queue.addr.port(),
|
||||||
|
"~user/ppa",
|
||||||
|
&files,
|
||||||
|
&|_, _, _| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stored = queue.files.lock();
|
||||||
|
assert_eq!(stored.get("pkg.dsc").unwrap(), b"bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live control-channel handshake with the real Launchpad FTP queue:
|
||||||
|
/// banner, anonymous login and a `CWD` — nothing is uploaded, the
|
||||||
|
/// queue is left untouched. For deliberate ad-hoc runs
|
||||||
|
/// (`cargo test -- --ignored`), not the pre-commit pass: it hits the
|
||||||
|
/// network.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "hits the network: the real Launchpad FTP queue"]
|
||||||
|
fn live_launchpad_control_channel() {
|
||||||
|
let (host, port) = crate::launchpad::ppa_ftp_queue();
|
||||||
|
assert_eq!((host.as_str(), port), ("ppa.launchpad.net", 21));
|
||||||
|
|
||||||
|
let mut ftp = connect(&host, port).unwrap();
|
||||||
|
ftp.login("anonymous", "pkh@invalid").unwrap();
|
||||||
|
ftp.cwd("/").unwrap();
|
||||||
|
ftp.quit().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-54
@@ -1,17 +1,12 @@
|
|||||||
//! Native upload of built source packages (`pkh put`): the dput
|
//! PPA upload: the dput replacement. Resolves the upload target, discovers
|
||||||
//! replacement. Resolves the upload target, discovers and validates the
|
//! and validates the `.changes` file and its artifacts, then pushes them
|
||||||
//! `.changes` file and its artifacts, then pushes them over SFTP with
|
//! over SFTP with host-key verification and an upload record preventing
|
||||||
//! host-key verification and an upload record preventing accidental
|
//! accidental duplicate uploads. When the SSH connection itself never
|
||||||
//! duplicate uploads.
|
//! comes up, the upload degrades to the anonymous FTP queue — the
|
||||||
//!
|
//! transport dput's plain `ppa:` profiles use — through [`ftp`].
|
||||||
//! Payload files are uploaded first and the `.changes` file last, like
|
|
||||||
//! dput does, so a partially uploaded set cannot be picked up by the
|
|
||||||
//! server-side queue processors. A run that fails mid-upload removes its
|
|
||||||
//! already-uploaded files from the incoming queue (best effort), so a
|
|
||||||
//! retried upload starts from a clean queue; a failed upload is never
|
|
||||||
//! recorded in the upload log, so a re-run replays every file.
|
|
||||||
|
|
||||||
pub mod changes;
|
pub mod changes;
|
||||||
|
pub mod ftp;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
|
|
||||||
@@ -139,11 +134,6 @@ async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.view
|
|
||||||
.message(&format!("Connecting to {login}@{host}:{port}..."));
|
|
||||||
let session = ssh::connect(&host, port, &login, &ssh_config, opts.prompter)?;
|
|
||||||
let sftp = ssh::sftp(&session)?;
|
|
||||||
|
|
||||||
// Payload first, the .changes file last (like dput), so the server-side
|
// Payload first, the .changes file last (like dput), so the server-side
|
||||||
// queue processor can never pick up an incomplete upload
|
// queue processor can never pick up an incomplete upload
|
||||||
let dir = changes_path.parent().unwrap_or_else(|| Path::new("."));
|
let dir = changes_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
@@ -157,48 +147,50 @@ async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error
|
|||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.ok_or_else(|| format!("invalid .changes path: {}", changes_path.display()))?
|
.ok_or_else(|| format!("invalid .changes path: {}", changes_path.display()))?
|
||||||
.to_string();
|
.to_string();
|
||||||
uploads.push((changes_path.clone(), changes_name.clone()));
|
uploads.push((changes_path.clone(), changes_name));
|
||||||
|
|
||||||
let incoming = target.incoming.trim_end_matches('/');
|
let incoming = target.incoming.trim_end_matches('/');
|
||||||
|
|
||||||
// Remote names of the files uploaded so far, in upload order (the
|
// The upload queue is SFTP first, degrading to the anonymous FTP
|
||||||
// .changes last). A run failing mid-upload removes these from the
|
// queue when the SSH connection itself never comes up (name
|
||||||
// write-only incoming queue before returning: the uploaded payloads
|
// resolution, TCP, banner or key exchange): dput pushes PPAs over
|
||||||
// would otherwise linger in the queue area forever, and a .changes
|
// that FTP queue by default, so it is the interoperability-tested
|
||||||
// truncated by a failed close could even be picked up by the scanner.
|
// fallback. A server that answers but refuses the upload (host key
|
||||||
let mut uploaded: Vec<String> = Vec::new();
|
// not accepted, no matching key) stays an error: silently switching
|
||||||
|
// transport would bypass the refusal.
|
||||||
for (path, name) in &uploads {
|
opts.view
|
||||||
let size = match path.metadata() {
|
.message(&format!("Connecting to {login}@{host}:{port}..."));
|
||||||
Ok(metadata) => metadata.len(),
|
let transfer = match ssh::connect(&host, port, &login, &ssh_config, opts.prompter) {
|
||||||
Err(e) => {
|
Ok(session) => sftp_transfer(&session, &uploads, incoming, &host, opts.view),
|
||||||
// Nothing was attempted for this file: only what earlier
|
Err(ssh::ConnectFailure::Transport(e)) => {
|
||||||
// iterations uploaded needs removing
|
log::warn!("SSH transport to {host}:{port} failed: {e}");
|
||||||
let error: Box<dyn std::error::Error> =
|
let (ftp_host, ftp_port) = launchpad::ppa_ftp_queue();
|
||||||
format!("cannot stat '{}': {}", path.display(), e).into();
|
opts.view.message(&format!(
|
||||||
cleanup_partial_upload(&sftp, incoming, &uploaded, None, &host);
|
"Falling back to the anonymous FTP queue on {ftp_host}:{ftp_port} \
|
||||||
return Err(error);
|
(dput's upload method)..."
|
||||||
}
|
));
|
||||||
};
|
ftp::upload_queue(
|
||||||
let remote = format!("{incoming}/{name}");
|
&ftp_host,
|
||||||
let label = format!("Uploading {name}");
|
ftp_port,
|
||||||
let view = opts.view;
|
incoming,
|
||||||
let on_progress = |uploaded: u64| view.progress(&label, uploaded as usize, size as usize);
|
&uploads,
|
||||||
let result = ssh::upload_file(&sftp, path, &remote, &host, &on_progress);
|
&|name, uploaded, total| {
|
||||||
if let Err(e) = result {
|
opts.view.progress(
|
||||||
// The failed file itself joins the cleanup: its remote `create`
|
&format!("Uploading {name}"),
|
||||||
// may have succeeded before the failure, leaving a partial — or,
|
uploaded as usize,
|
||||||
// on a failed close, a truncated — file behind
|
total as usize,
|
||||||
cleanup_partial_upload(&sftp, incoming, &uploaded, Some(name), &host);
|
);
|
||||||
return Err(e);
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
uploaded.push(name.clone());
|
Err(ssh::ConnectFailure::Refused(e)) => return Err(e),
|
||||||
}
|
};
|
||||||
|
transfer?;
|
||||||
|
|
||||||
// Recorded only once the whole upload succeeded: the log backs the
|
// Recorded only once the whole upload succeeded: the log backs the
|
||||||
// duplicate-upload guard, and a failed upload must not count as
|
// duplicate-upload guard, and a failed upload must not count as
|
||||||
// uploaded (a re-run replays every file — `sftp.create` truncates, so
|
// uploaded (a re-run replays every file — `sftp.create` truncates and
|
||||||
// replaying is safe).
|
// the FTP `STOR` overwrites, so replaying is safe).
|
||||||
record_upload(&upload_log_path()?, &record)?;
|
record_upload(&upload_log_path()?, &record)?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -212,6 +204,54 @@ async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push `uploads` over SFTP: payload files first, the `.changes` last
|
||||||
|
/// (like dput), so the server-side queue processor can never pick up an
|
||||||
|
/// incomplete upload. Best-effort removal of a partial upload, mirroring
|
||||||
|
/// the FTP transport's `DELE` cleanup ([`ftp::upload_queue`]).
|
||||||
|
fn sftp_transfer(
|
||||||
|
session: &ssh2::Session,
|
||||||
|
uploads: &[(PathBuf, String)],
|
||||||
|
incoming: &str,
|
||||||
|
host: &str,
|
||||||
|
view: &dyn BuildView,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let sftp = ssh::sftp(session)?;
|
||||||
|
|
||||||
|
// Remote names of the files uploaded so far, in upload order (the
|
||||||
|
// .changes last). A run failing mid-upload removes these from the
|
||||||
|
// write-only incoming queue before returning: the uploaded payloads
|
||||||
|
// would otherwise linger in the queue area forever, and a .changes
|
||||||
|
// truncated by a failed close could even be picked up by the scanner.
|
||||||
|
let mut uploaded: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for (path, name) in uploads {
|
||||||
|
let size = match path.metadata() {
|
||||||
|
Ok(metadata) => metadata.len(),
|
||||||
|
Err(e) => {
|
||||||
|
// Nothing was attempted for this file: only what earlier
|
||||||
|
// iterations uploaded needs removing
|
||||||
|
let error: Box<dyn std::error::Error> =
|
||||||
|
format!("cannot stat '{}': {}", path.display(), e).into();
|
||||||
|
cleanup_partial_upload(&sftp, incoming, &uploaded, None, host);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let remote = format!("{incoming}/{name}");
|
||||||
|
let label = format!("Uploading {name}");
|
||||||
|
let on_progress = |uploaded: u64| view.progress(&label, uploaded as usize, size as usize);
|
||||||
|
let result = ssh::upload_file(&sftp, path, &remote, host, &on_progress);
|
||||||
|
if let Err(e) = result {
|
||||||
|
// The failed file itself joins the cleanup: its remote `create`
|
||||||
|
// may have succeeded before the failure, leaving a partial — or,
|
||||||
|
// on a failed close, a truncated — file behind
|
||||||
|
cleanup_partial_upload(&sftp, incoming, &uploaded, Some(name), host);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
uploaded.push(name.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The remote names to attempt removing after a failed upload: everything
|
/// The remote names to attempt removing after a failed upload: everything
|
||||||
/// already uploaded plus, when set, `failed` (the file whose upload just
|
/// already uploaded plus, when set, `failed` (the file whose upload just
|
||||||
/// failed — its remote `create` may have succeeded before the failure,
|
/// failed — its remote `create` may have succeeded before the failure,
|
||||||
@@ -219,8 +259,9 @@ async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error
|
|||||||
/// so a `.changes` is removed before the payloads it references and the
|
/// so a `.changes` is removed before the payloads it references and the
|
||||||
/// queue scanner never observes the payload set shrinking under a
|
/// queue scanner never observes the payload set shrinking under a
|
||||||
/// still-present `.changes`. Pure so the ordering decision is testable
|
/// still-present `.changes`. Pure so the ordering decision is testable
|
||||||
/// without a server; the network side is [`cleanup_partial_upload`].
|
/// without a server; the network sides are [`cleanup_partial_upload`] and
|
||||||
fn cleanup_list(uploaded: &[String], failed: Option<&str>) -> Vec<String> {
|
/// the FTP transport's `DELE` loop ([`ftp`]).
|
||||||
|
pub(crate) fn cleanup_list(uploaded: &[String], failed: Option<&str>) -> Vec<String> {
|
||||||
let mut names: Vec<String> = uploaded.to_vec();
|
let mut names: Vec<String> = uploaded.to_vec();
|
||||||
if let Some(failed) = failed {
|
if let Some(failed) = failed {
|
||||||
names.push(failed.to_string());
|
names.push(failed.to_string());
|
||||||
|
|||||||
+46
-8
@@ -253,7 +253,9 @@ fn duration_ms(timeout: Duration) -> u32 {
|
|||||||
/// address in order (like `TcpStream::connect` does) with
|
/// address in order (like `TcpStream::connect` does) with
|
||||||
/// [`TCP_CONNECT_TIMEOUT`] per attempt instead of blocking indefinitely.
|
/// [`TCP_CONNECT_TIMEOUT`] per attempt instead of blocking indefinitely.
|
||||||
/// Fails with a message naming the target and every per-address error.
|
/// Fails with a message naming the target and every per-address error.
|
||||||
fn tcp_connect(host: &str, port: u16) -> Result<TcpStream, String> {
|
/// Also the TCP layer of the anonymous FTP fallback transport
|
||||||
|
/// ([`super::ftp`]), whose connection semantics are identical.
|
||||||
|
pub(crate) fn tcp_connect(host: &str, port: u16) -> Result<TcpStream, String> {
|
||||||
let addrs: Vec<SocketAddr> = (host, port)
|
let addrs: Vec<SocketAddr> = (host, port)
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
.map_err(|e| format!("cannot resolve {host}:{port}: {e}"))?
|
.map_err(|e| format!("cannot resolve {host}:{port}: {e}"))?
|
||||||
@@ -292,6 +294,39 @@ fn connect_failed_message(
|
|||||||
attempts.len()
|
attempts.len()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
/// Why establishing the SSH session failed.
|
||||||
|
///
|
||||||
|
/// [`Transport`] failures mean the connection never came up (name
|
||||||
|
/// resolution, TCP, banner or key exchange): the target may still be
|
||||||
|
/// reachable over another transport, so `pkh put` degrades to the
|
||||||
|
/// anonymous FTP queue — dput's default for PPAs ([`super::ftp`]).
|
||||||
|
/// [`Refused`] failures mean the server answered but rejected the
|
||||||
|
/// upload (host key not accepted, no matching authentication): silently
|
||||||
|
/// switching to anonymous FTP would bypass a refusal, so they stay
|
||||||
|
/// errors.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConnectFailure {
|
||||||
|
/// The connection itself never came up.
|
||||||
|
Transport(Box<dyn std::error::Error>),
|
||||||
|
/// The server answered but rejected the upload.
|
||||||
|
Refused(Box<dyn std::error::Error>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConnectFailure {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ConnectFailure::Transport(e) | ConnectFailure::Refused(e) => write!(f, "{e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConnectFailure {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
ConnectFailure::Transport(e) | ConnectFailure::Refused(e) => Some(e.as_ref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to `host:port`, verify the server host key and authenticate as
|
/// Connect to `host:port`, verify the server host key and authenticate as
|
||||||
/// `login`: every ssh-agent identity first, then the configured and default
|
/// `login`: every ssh-agent identity first, then the configured and default
|
||||||
@@ -303,10 +338,13 @@ pub fn connect(
|
|||||||
login: &str,
|
login: &str,
|
||||||
config: &SshConfig,
|
config: &SshConfig,
|
||||||
prompter: &dyn Prompter,
|
prompter: &dyn Prompter,
|
||||||
) -> Result<Session, Box<dyn std::error::Error>> {
|
) -> Result<Session, ConnectFailure> {
|
||||||
let tcp = tcp_connect(host, port)?;
|
use ConnectFailure::*;
|
||||||
|
|
||||||
let mut session = Session::new()?;
|
let tcp = tcp_connect(host, port).map_err(|e| Transport(e.into()))?;
|
||||||
|
|
||||||
|
let mut session = Session::new()
|
||||||
|
.map_err(|e| Transport(format!("cannot initialize the SSH session: {e}").into()))?;
|
||||||
// In blocking mode (the libssh2 default), a call that would block loops
|
// In blocking mode (the libssh2 default), a call that would block loops
|
||||||
// in `_libssh2_wait_socket` (via the `BLOCK_ADJUST` macros of
|
// in `_libssh2_wait_socket` (via the `BLOCK_ADJUST` macros of
|
||||||
// session.h in the vendored libssh2-sys sources), which bounds the
|
// session.h in the vendored libssh2-sys sources), which bounds the
|
||||||
@@ -321,14 +359,14 @@ pub fn connect(
|
|||||||
session.set_tcp_stream(tcp);
|
session.set_tcp_stream(tcp);
|
||||||
session
|
session
|
||||||
.handshake()
|
.handshake()
|
||||||
.map_err(|e| format!("SSH handshake with {host} failed: {e}"))?;
|
.map_err(|e| Transport(format!("SSH handshake with {host} failed: {e}").into()))?;
|
||||||
|
|
||||||
let (key, key_type) = session
|
let (key, key_type) = session
|
||||||
.host_key()
|
.host_key()
|
||||||
.ok_or_else(|| format!("{host} offered no host key"))?;
|
.ok_or_else(|| Transport(format!("{host} offered no host key").into()))?;
|
||||||
verify_host_key(host, port, key, key_type, prompter)?;
|
verify_host_key(host, port, key, key_type, prompter).map_err(Refused)?;
|
||||||
|
|
||||||
authenticate(&session, host, login, config)?;
|
authenticate(&session, host, login, config).map_err(Refused)?;
|
||||||
|
|
||||||
// Only SFTP open/data calls remain on this session: switch from the
|
// Only SFTP open/data calls remain on this session: switch from the
|
||||||
// connection-phase budget to the generous per-call transfer one
|
// connection-phase budget to the generous per-call transfer one
|
||||||
|
|||||||
@@ -445,6 +445,10 @@ mod imp {
|
|||||||
self.inner.exists(path)
|
self.inner.exists(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_dir(&self, path: &Path) -> io::Result<bool> {
|
||||||
|
self.inner.is_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn cleanup(&self) -> io::Result<()> {
|
fn cleanup(&self) -> io::Result<()> {
|
||||||
self.inner.cleanup()
|
self.inner.cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-90
@@ -11,14 +11,14 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
|
use crossterm::style::Stylize;
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use crate::context::{LineSink, Stream};
|
use crate::context::{LineSink, Stream};
|
||||||
@@ -50,8 +50,11 @@ struct Pipeline {
|
|||||||
|
|
||||||
/// State shared between [`DebUi`] and its sinks
|
/// State shared between [`DebUi`] and its sinks
|
||||||
struct Shared {
|
struct Shared {
|
||||||
|
multi: MultiProgress,
|
||||||
top: ProgressBar,
|
top: ProgressBar,
|
||||||
pane: ProgressBar,
|
/// The rolling pane bar, created on demand: flows without subprocess
|
||||||
|
/// output (e.g. `pkh put`) never show it at all
|
||||||
|
pane: Mutex<Option<ProgressBar>>,
|
||||||
state: Mutex<Pipeline>,
|
state: Mutex<Pipeline>,
|
||||||
tee: Mutex<Option<File>>,
|
tee: Mutex<Option<File>>,
|
||||||
log_path: Mutex<PathBuf>,
|
log_path: Mutex<PathBuf>,
|
||||||
@@ -81,38 +84,33 @@ impl DebUi {
|
|||||||
let enabled = is_stdout_tty();
|
let enabled = is_stdout_tty();
|
||||||
|
|
||||||
let top = if enabled {
|
let top = if enabled {
|
||||||
|
// The tty renders a ^C keypress as the two visible characters
|
||||||
|
// "^C"; on a terminal where the cursor sits near the right edge
|
||||||
|
// that wraps to the next row, and the erase at teardown —
|
||||||
|
// anchored to where indicatif last drew — ends up one row off,
|
||||||
|
// leaving the first widget line on screen. Rendering control
|
||||||
|
// characters raw instead (ECHOCTL off) makes the echo an
|
||||||
|
// invisible byte that moves nothing; ECHO itself stays on, so
|
||||||
|
// terminals showing a padlock while input is hidden are not
|
||||||
|
// triggered. Restored by `suspend_shared`.
|
||||||
|
suppress_control_char_echo();
|
||||||
|
multi.set_draw_target(ProgressDrawTarget::stderr());
|
||||||
let pb = multi.add(ProgressBar::new(0));
|
let pb = multi.add(ProgressBar::new(0));
|
||||||
pb.enable_steady_tick(Duration::from_millis(80));
|
pb.enable_steady_tick(Duration::from_millis(80));
|
||||||
pb.set_style(spinner_style());
|
pb.set_style(spinner_style());
|
||||||
pb.set_prefix("Building package");
|
pb.set_prefix("Building package");
|
||||||
pb.set_message("(starting…)");
|
|
||||||
pb
|
|
||||||
} else {
|
|
||||||
ProgressBar::hidden()
|
|
||||||
};
|
|
||||||
|
|
||||||
let pane = if enabled {
|
|
||||||
let pb = multi.add(ProgressBar::new(0));
|
|
||||||
pb.enable_steady_tick(Duration::from_millis(150));
|
|
||||||
// No template margin: multi-line messages are only prefixed by
|
|
||||||
// the template on their first line, which would misalign the
|
|
||||||
// pane; each rendered line carries its own indent instead.
|
|
||||||
pb.set_style(
|
|
||||||
ProgressStyle::default_bar()
|
|
||||||
.template("{msg}")
|
|
||||||
.expect("valid template"),
|
|
||||||
);
|
|
||||||
pb.set_message(" │ (starting…)");
|
|
||||||
pb
|
pb
|
||||||
} else {
|
} else {
|
||||||
ProgressBar::hidden()
|
ProgressBar::hidden()
|
||||||
};
|
};
|
||||||
|
let pane = Mutex::new(None);
|
||||||
|
|
||||||
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
|
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
|
||||||
let log_path = default_log_path(×tamp);
|
let log_path = default_log_path(×tamp);
|
||||||
|
|
||||||
let ui = Self {
|
let ui = Self {
|
||||||
shared: Arc::new(Shared {
|
shared: Arc::new(Shared {
|
||||||
|
multi: multi.clone(),
|
||||||
top,
|
top,
|
||||||
pane,
|
pane,
|
||||||
state: Mutex::new(Pipeline {
|
state: Mutex::new(Pipeline {
|
||||||
@@ -123,7 +121,7 @@ impl DebUi {
|
|||||||
bar_total: 0,
|
bar_total: 0,
|
||||||
}),
|
}),
|
||||||
tee: Mutex::new(None),
|
tee: Mutex::new(None),
|
||||||
log_path: Mutex::new(log_path.clone()),
|
log_path: Mutex::new(log_path),
|
||||||
timestamp,
|
timestamp,
|
||||||
enabled,
|
enabled,
|
||||||
suspended: AtomicBool::new(false),
|
suspended: AtomicBool::new(false),
|
||||||
@@ -131,8 +129,13 @@ impl DebUi {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ctrl+C: the signal wiring lives in the CLI; this view only
|
||||||
|
// registers with `crate::interrupt` how to clear itself and where
|
||||||
|
// the full log lives. The log path is read from the shared state at
|
||||||
|
// interrupt time, so the rename in `open_log` stays visible to the
|
||||||
|
// reporter.
|
||||||
if ui.shared.enabled {
|
if ui.shared.enabled {
|
||||||
install_sigint_hook(&log_path);
|
set_interrupt_reporter(ui.shared.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
ui
|
ui
|
||||||
@@ -141,7 +144,7 @@ impl DebUi {
|
|||||||
/// Identify the binary package being built; names the log file and the
|
/// Identify the binary package being built; names the log file and the
|
||||||
/// status bar
|
/// status bar
|
||||||
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) {
|
||||||
if self.shared.enabled {
|
if self.active() {
|
||||||
self.shared.top.set_prefix(format!(
|
self.shared.top.set_prefix(format!(
|
||||||
"Building {package} ({version}) for {series}/{arch}"
|
"Building {package} ({version}) for {series}/{arch}"
|
||||||
));
|
));
|
||||||
@@ -162,7 +165,6 @@ impl DebUi {
|
|||||||
};
|
};
|
||||||
let _ = fs::rename(&old_path, &log_path);
|
let _ = fs::rename(&old_path, &log_path);
|
||||||
*self.shared.log_path.lock().unwrap() = log_path.clone();
|
*self.shared.log_path.lock().unwrap() = log_path.clone();
|
||||||
update_sigint_log_path(&log_path);
|
|
||||||
|
|
||||||
if let Some(dir) = log_path.parent() {
|
if let Some(dir) = log_path.parent() {
|
||||||
let _ = fs::create_dir_all(dir);
|
let _ = fs::create_dir_all(dir);
|
||||||
@@ -195,10 +197,10 @@ impl DebUi {
|
|||||||
st.bar_total = 0;
|
st.bar_total = 0;
|
||||||
st.last_draw = Instant::now();
|
st.last_draw = Instant::now();
|
||||||
}
|
}
|
||||||
if self.shared.enabled {
|
if self.active() {
|
||||||
self.shared.top.set_style(spinner_style());
|
self.shared.top.set_style(spinner_style());
|
||||||
self.shared.top.set_message(label.to_string());
|
self.shared.top.set_message(label.to_string());
|
||||||
self.shared.pane.set_message("");
|
drop_pane(&self.shared);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,20 +212,8 @@ impl DebUi {
|
|||||||
/// Release the widget from the terminal (e.g. before printing
|
/// Release the widget from the terminal (e.g. before printing
|
||||||
/// passthrough diagnostics or letting child cleanup commands write to
|
/// passthrough diagnostics or letting child cleanup commands write to
|
||||||
/// the terminal); idempotent
|
/// the terminal); idempotent
|
||||||
///
|
|
||||||
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
|
||||||
/// right after the clear, leaving stale copies of the widget on screen.
|
|
||||||
fn suspend(&self) {
|
fn suspend(&self) {
|
||||||
if !self.shared.enabled {
|
suspend_shared(&self.shared);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.shared.suspended.swap(true, Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.shared.top.disable_steady_tick();
|
|
||||||
self.shared.pane.disable_steady_tick();
|
|
||||||
self.shared.top.finish_and_clear();
|
|
||||||
self.shared.pane.finish_and_clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Success outcome body: clear the widget and print the artifacts,
|
/// Success outcome body: clear the widget and print the artifacts,
|
||||||
@@ -240,9 +230,17 @@ impl DebUi {
|
|||||||
|
|
||||||
/// Failure outcome body: clear the widget and print a summary (recent
|
/// Failure outcome body: clear the widget and print a summary (recent
|
||||||
/// captured errors and the path to the full log)
|
/// captured errors and the path to the full log)
|
||||||
|
///
|
||||||
|
/// On a Ctrl+C the interrupt watchdog owns the reporting — its captured
|
||||||
|
/// errors are just the killed children's death throes, and the watchdog
|
||||||
|
/// already points at the full log — so this prints nothing.
|
||||||
fn failure_summary(&self) {
|
fn failure_summary(&self) {
|
||||||
self.suspend();
|
self.suspend();
|
||||||
|
|
||||||
|
if crate::interrupt::interrupted() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let st = self.shared.state.lock().unwrap();
|
let st = self.shared.state.lock().unwrap();
|
||||||
if self.shared.enabled && !st.errors.is_empty() {
|
if self.shared.enabled && !st.errors.is_empty() {
|
||||||
eprintln!("Last captured errors:");
|
eprintln!("Last captured errors:");
|
||||||
@@ -270,7 +268,7 @@ impl DebUi {
|
|||||||
/// widget.
|
/// widget.
|
||||||
impl crate::report::BuildView for DebUi {
|
impl crate::report::BuildView for DebUi {
|
||||||
fn target(&self, target: BuildTarget<'_>) {
|
fn target(&self, target: BuildTarget<'_>) {
|
||||||
if self.shared.enabled {
|
if self.active() {
|
||||||
self.shared.top.set_prefix(target.display.clone());
|
self.shared.top.set_prefix(target.display.clone());
|
||||||
}
|
}
|
||||||
if target.tee_log {
|
if target.tee_log {
|
||||||
@@ -408,7 +406,44 @@ fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now.duration_since(st.last_draw) >= REDRAW_INTERVAL {
|
if now.duration_since(st.last_draw) >= REDRAW_INTERVAL {
|
||||||
st.last_draw = now;
|
st.last_draw = now;
|
||||||
shared.pane.set_message(render_pane(&st.lines));
|
if let Some(pane) = ensure_pane(shared) {
|
||||||
|
pane.set_message(render_pane(&st.lines));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pane bar, added to the terminal on the first call and reused after
|
||||||
|
///
|
||||||
|
/// Returns `None` once the widget is suspended: a line racing the suspend
|
||||||
|
/// must not re-add a bar the cleanup just cleared.
|
||||||
|
fn ensure_pane(shared: &Shared) -> Option<ProgressBar> {
|
||||||
|
let mut pane = shared.pane.lock().unwrap();
|
||||||
|
if let Some(pb) = pane.as_ref() {
|
||||||
|
return Some(pb.clone());
|
||||||
|
}
|
||||||
|
if shared.suspended.load(Ordering::SeqCst) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pb = shared.multi.add(ProgressBar::new(0));
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(150));
|
||||||
|
// No template margin: multi-line messages are only prefixed by the
|
||||||
|
// template on their first line, which would misalign the pane; each
|
||||||
|
// rendered line carries its own indent instead.
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{msg}")
|
||||||
|
.expect("valid template"),
|
||||||
|
);
|
||||||
|
*pane = Some(pb.clone());
|
||||||
|
Some(pb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the pane bar off the terminal; the next pushed line re-creates it
|
||||||
|
fn drop_pane(shared: &Shared) {
|
||||||
|
if let Some(pb) = shared.pane.lock().unwrap().take() {
|
||||||
|
pb.disable_steady_tick();
|
||||||
|
pb.finish_and_clear();
|
||||||
|
shared.multi.remove(&pb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,61 +541,90 @@ fn default_log_path(timestamp: &str) -> PathBuf {
|
|||||||
dir.join(format!("pkh-{timestamp}.log"))
|
dir.join(format!("pkh-{timestamp}.log"))
|
||||||
}
|
}
|
||||||
|
|
||||||
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
/// Register the interrupt reporter: release the widget from the terminal
|
||||||
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
|
/// and return the log-file hint to print below the notice
|
||||||
|
///
|
||||||
|
/// The CLI watchdog runs this as the first step of the interrupt shutdown,
|
||||||
|
/// so the widget disappears the moment Ctrl+C is hit. Registered only for
|
||||||
|
/// enabled views: in `--verbose` mode or with piped output there is no
|
||||||
|
/// widget and nothing to report.
|
||||||
|
fn set_interrupt_reporter(shared: Arc<Shared>) {
|
||||||
|
crate::interrupt::set_reporter(Box::new(move || interrupt_report(&shared)));
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
|
/// [`suspend_shared`] plus the log-file hint, in teardown order
|
||||||
/// the log file before exiting
|
///
|
||||||
fn install_sigint_hook(log_path: &Path) {
|
/// Testable end to end: the reporter closure is private to the interrupt
|
||||||
update_sigint_log_path(log_path);
|
/// watchdog, but the drawing behavior is not.
|
||||||
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
|
fn interrupt_report(shared: &Shared) -> Option<String> {
|
||||||
|
suspend_shared(shared);
|
||||||
|
let log_path = shared.log_path.lock().unwrap().clone();
|
||||||
|
if log_path.exists() {
|
||||||
|
Some(format!("Full log: {}", log_path.display()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`DebUi::suspend`] body, shared with the interrupt reporter
|
||||||
|
///
|
||||||
|
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
|
||||||
|
/// right after the clear, leaving stale copies of the widget on screen.
|
||||||
|
/// The tty echo suppressed at view start is restored here, before the
|
||||||
|
/// erases: an echo from a keypress landing mid-teardown could not shift the
|
||||||
|
/// cursor anymore. The draw target is finally killed: the interrupted flow
|
||||||
|
/// keeps emitting log records while the cleanup hooks run, and every one of
|
||||||
|
/// them would otherwise make the log bridge repaint the cleared bars from
|
||||||
|
/// their cached frames.
|
||||||
|
fn suspend_shared(shared: &Shared) {
|
||||||
|
if !shared.enabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if shared.suspended.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restore_tty_echo();
|
||||||
|
shared.top.disable_steady_tick();
|
||||||
|
drop_pane(shared);
|
||||||
|
shared.top.finish_and_clear();
|
||||||
|
shared.multi.set_draw_target(ProgressDrawTarget::hidden());
|
||||||
|
}
|
||||||
|
|
||||||
// SAFETY: installing a signal handler; the handler itself is best-effort
|
/// Termios snapshot taken when the echo is suppressed; `Some` only while the
|
||||||
// (it performs non async-signal-safe operations, acceptable here because
|
/// live view is on screen
|
||||||
// it immediately exits afterwards).
|
static SAVED_TTY_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Stop the tty from rendering control-character input (^C would show as a
|
||||||
|
/// visible two-character "^C") while the live view is up: rendered echoes
|
||||||
|
/// move the cursor without indicatif knowing, and the teardown erase ends
|
||||||
|
/// up aimed past the widget
|
||||||
|
///
|
||||||
|
/// ECHO itself stays on — turning it off would trigger terminals'
|
||||||
|
/// hidden-input padlock — so the only visible difference is that a ^C
|
||||||
|
/// keypress echoes as a raw, cursor-invisible control byte. No-op without a
|
||||||
|
/// tty on stdin.
|
||||||
|
fn suppress_control_char_echo() {
|
||||||
|
// SAFETY: tcgetattr on stdin with a valid, zero-initialized buffer
|
||||||
|
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
|
||||||
|
// SAFETY: reading the current attributes of stdin
|
||||||
|
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*SAVED_TTY_TERMIOS.lock().unwrap() = Some(termios);
|
||||||
|
termios.c_lflag &= !libc::ECHOCTL;
|
||||||
|
// SAFETY: applying the modified attributes to stdin
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point the sigint handler at the current log file location
|
/// Restore the tty attributes saved by [`suppress_tty_echo`]
|
||||||
fn update_sigint_log_path(log_path: &Path) {
|
fn restore_tty_echo() {
|
||||||
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
|
if let Some(termios) = SAVED_TTY_TERMIOS.lock().unwrap().take() {
|
||||||
}
|
// SAFETY: re-applying the snapshot taken at view start
|
||||||
|
unsafe {
|
||||||
extern "C" fn on_sigint(_sig: libc::c_int) {
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
|
||||||
// Best-effort cleanup: clear leftover widget lines and show the cursor
|
}
|
||||||
let _ = execute!(
|
|
||||||
std::io::stdout(),
|
|
||||||
Clear(ClearType::FromCursorDown),
|
|
||||||
cursor::Show
|
|
||||||
);
|
|
||||||
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
|
|
||||||
&& let Some(path) = guard.as_ref()
|
|
||||||
{
|
|
||||||
eprintln!("\nInterrupted — full log: {}", path.display());
|
|
||||||
}
|
|
||||||
// Run the registered cleanup hooks (currently: unmount and remove the
|
|
||||||
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
|
|
||||||
// then exit with the conventional 130 status.
|
|
||||||
//
|
|
||||||
// Like the terminal restoration above, this is NOT strictly
|
|
||||||
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
|
|
||||||
// That is a deliberate tradeoff, no worse than the rest of this handler:
|
|
||||||
// exiting immediately would skip all destructors and leak the chroot
|
|
||||||
// together with its bind-mounted /proc and overlay mounts. The hooks are
|
|
||||||
// self-contained (they only touch stored paths and spawn umount/rm
|
|
||||||
// directly), so they cannot deadlock on a lock the interrupted thread
|
|
||||||
// might have held; the hook registry itself is only ever taken with
|
|
||||||
// try_lock plus a bounded retry for the same reason. Note that SIGINT
|
|
||||||
// stays blocked for the duration of the handler, so a second Ctrl-C will
|
|
||||||
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
|
|
||||||
crate::deb::ephemeral::run_cleanup_hooks();
|
|
||||||
// SAFETY: raw exit bypassing destructors, intended in a signal handler
|
|
||||||
unsafe {
|
|
||||||
libc::_exit(130);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +702,79 @@ mod tests {
|
|||||||
assert!(rendered.contains("a line that would overflow a narrow pane"));
|
assert!(rendered.contains("a line that would overflow a narrow pane"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh view has no pane at all: flows without subprocess output
|
||||||
|
/// (`pkh put`) must not render anything until a line arrives, and a
|
||||||
|
/// dropped or suspended pane stays gone
|
||||||
|
#[test]
|
||||||
|
fn pane_is_created_lazily_and_dropped_cleanly() {
|
||||||
|
let multi = MultiProgress::new();
|
||||||
|
let shared = Shared {
|
||||||
|
multi: multi.clone(),
|
||||||
|
top: multi.add(ProgressBar::new(0)),
|
||||||
|
pane: Mutex::new(None),
|
||||||
|
state: Mutex::new(Pipeline {
|
||||||
|
classifier: Box::new(GenericClassifier::new()),
|
||||||
|
lines: VecDeque::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
last_draw: Instant::now(),
|
||||||
|
bar_total: 0,
|
||||||
|
}),
|
||||||
|
tee: Mutex::new(None),
|
||||||
|
log_path: Mutex::new(std::env::temp_dir().join("pkh-pane-test.log")),
|
||||||
|
timestamp: String::new(),
|
||||||
|
enabled: true,
|
||||||
|
suspended: AtomicBool::new(false),
|
||||||
|
started: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(shared.pane.lock().unwrap().is_none());
|
||||||
|
|
||||||
|
ensure_pane(&shared).unwrap();
|
||||||
|
assert!(shared.pane.lock().unwrap().is_some());
|
||||||
|
// Later lines hit the stored bar instead of stacking another one
|
||||||
|
ensure_pane(&shared).unwrap();
|
||||||
|
assert!(shared.pane.lock().unwrap().is_some());
|
||||||
|
|
||||||
|
drop_pane(&shared);
|
||||||
|
assert!(shared.pane.lock().unwrap().is_none());
|
||||||
|
|
||||||
|
// A line racing the suspend must not re-add the cleared bar
|
||||||
|
shared.suspended.store(true, Ordering::SeqCst);
|
||||||
|
assert!(ensure_pane(&shared).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interrupt report kills the shared draw target: during the cleanup
|
||||||
|
/// hooks the interrupted flow keeps emitting log records, and every one
|
||||||
|
/// of them would otherwise make the log bridge repaint the bars from
|
||||||
|
/// their cached frames — resurrecting the widget that was just cleared.
|
||||||
|
#[test]
|
||||||
|
fn interrupt_report_disables_further_redraws() {
|
||||||
|
let multi = MultiProgress::new();
|
||||||
|
let shared = Shared {
|
||||||
|
multi: multi.clone(),
|
||||||
|
top: multi.add(ProgressBar::new(0)),
|
||||||
|
pane: Mutex::new(None),
|
||||||
|
state: Mutex::new(Pipeline {
|
||||||
|
classifier: Box::new(GenericClassifier::new()),
|
||||||
|
lines: VecDeque::new(),
|
||||||
|
errors: Vec::new(),
|
||||||
|
last_draw: Instant::now(),
|
||||||
|
bar_total: 0,
|
||||||
|
}),
|
||||||
|
tee: Mutex::new(None),
|
||||||
|
log_path: Mutex::new(std::env::temp_dir().join("pkh-interrupt-report-test.log")),
|
||||||
|
timestamp: String::new(),
|
||||||
|
enabled: true,
|
||||||
|
suspended: AtomicBool::new(false),
|
||||||
|
started: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
interrupt_report(&shared);
|
||||||
|
|
||||||
|
assert!(shared.suspended.load(Ordering::SeqCst));
|
||||||
|
assert!(shared.multi.is_hidden());
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort ANSI escape stripper, enough for the assertions above
|
/// Best-effort ANSI escape stripper, enough for the assertions above
|
||||||
fn strip_ansi(line: &str) -> String {
|
fn strip_ansi(line: &str) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user