mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
0.4.0
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
acc1771bc2 |
tests: exercise the PAM stack against known permit and deny services (#281)
The only PAM coverage was the deployment image authenticating a real account. That cannot exercise a *refusal* without relying on a wrong password being wrong, and it cannot tell a refusal apart from a conversation that never reached the stack -- which is exactly the failure that broke password changes earlier this week and took three rounds to find. The glibc images now define two throwaway services, one backed by pam_permit and one by pam_deny, and shadow-core tests the wrapper against both: the permit stack authenticates and passes account management, the deny stack refuses with the stack's own answer rather than a conversation error, and a service with no configuration at all does not authenticate. The tests skip where the services are absent, which is any machine but the containers. CI also gains an all-features clippy pass. The completions feature and the shadow-core feature gates were compiled by no job, so a lint or a compile error in them could reach main unnoticed. make check runs it too. |
||
|
|
02fefda42c |
ci: compare against GNU for real, pin the actions by digest, one make check (#275)
* ci: pin the actions by digest A tag is a moving pointer. Whoever controls the action's repository can repoint v7 at any commit, and every workflow in the project runs it on the next push with write access to the checkout. A digest cannot be repointed. The tag stays in a trailing comment so the version is still readable, and Renovate's pinGitHubActionDigests preset is enabled so the digests are kept current the same way every other dependency is -- as a pull request that CI has run, rather than silently. * tests: make the GNU comparison real, and run it in CI The suite compared full output for exactly two commands and, for the other twelve, only that both tools exit 0 on --help. It ran nowhere. It now compares what is actually a contract. 'passwd -S' and 'chage -l' are parsed by monitoring and provisioning scripts, so their output is compared byte for byte across eleven aging configurations -- including the cases this project got wrong until recently: a last change of 0, the threshold above which a maximum age reads never, and cleared fields. Exit codes are compared on ten error paths. --help output is deliberately not compared: ours comes from clap and is meant to differ. One difference is deliberate and now asserted as such, so it cannot be mistaken for a regression the day it changes by accident. GNU parses dates leniently and rolls them over -- 'chage -d 2025-02-29' stores 1 March and '-d 2025-13-01' stores 1 January 2026. An administrator who typed one of those made a mistake, and silently storing a different date than the one they wrote is a poor answer for a field that decides when an account stops working. We refuse it; chage(1) says so. 38 comparisons, all as expected, and a CI job runs them. 'make check' is now the single definition of what CI gates on -- fmt, clippy with and without pam, and the tests in both configurations. The pre-push hook and CONTRIBUTING refer to it instead of each carrying their own copy of the command list, and the two hook systems are documented as the non-overlapping things they are. |
||
|
|
cf7ecb4a0f |
tests: one binary, spawn the real tool, and assert on what it prints (#273)
* tests: one binary, spawn the real tool, assert on what it prints
Fourteen [[test]] targets each linked all fourteen tool crates and
carried its own copy of the helpers through a #[path] include, so the
suite was compiled and linked fourteen times over. It is one target now,
the layout the rest of uutils uses.
More importantly the tests called uumain in-process and asserted on an
exit code and nothing else. Not one assertion in the suite looked at a
byte of output, so 'bit-for-bit identical to GNU' was never checked. A
shared process also let harden_process, setuid, Landlock and umask
changes leak from one test into the next.
common now offers a spawned-binary helper with assertions on the exit
code and both streams, and the tests use it. Three findings came out of
that within the hour:
- chage exited 3 for an invalid date where GNU exits 2; 3 is for an
unexpected failure, not for an argument the caller got wrong.
- passwd -l was not idempotent. Eight concurrent locks left eight
markers, and since passwd -u refuses to unlock an account that would
stay locked, an account locked twice could not be unlocked with the
tool at all. GNU leaves a single marker however many times it is
asked; two unit tests that encoded the old behaviour are rewritten
to the verified one.
- pwck's duplicate-UID test accepted either 0 or 2, so it asserted
nothing. Two accounts may deliberately share a UID (useradd -o), and
GNU reports nothing; a duplicate *name* is the fault, and that is now
what is checked, message included.
test_chage and test_chpasswd never invoked their tool at all -- they set
a field through shadow-core and read it back, testing the parser twice.
Both now drive the binary through --prefix, which those tools have since
gained, and cover the aging-field writes, the -l wording and thresholds,
the batch rules, ENCRYPT_METHOD and the flag pairs the man page forbids.
The concurrency test raced four threads through uumain against a lock
keyed on the process id, which same-process threads never contend for,
and discarded all eight return codes. It spawns eight processes now and
checks every one.
New: the multicall binary is tested for the property one table now
guarantees -- every applet listed is reachable, and the help text is
identical through the argument and the symlink.
* passwd: make -l idempotent
Locking prepended '!' unconditionally, so locking an account twice left
'!!'. One unlock then removed one marker and left the account locked --
and passwd -u refuses to unlock an account that would stay locked, so
the tool could not undo its own second lock.
GNU shadow 4.17 leaves a single marker however many times it is asked:
three locks give one '!', which one unlock removes. Unlocking a field
that already carries two markers still removes exactly one, which is
also what GNU does; that case is reachable only from a file some other
tool wrote.
Found by running eight concurrent passwd -l processes against one
account and looking at the result, which is a thing the suite could not
do while it called uumain in-process. Two unit tests asserted the old
behaviour and now assert the verified one, and the deployment suite
locks twice and checks the marker did not double.
* ci: make root-only skips visible, and test root together with pam
Roughly three quarters of the integration suite is root-only, and a
skipped test is reported ok, so a run that exercised almost nothing
looked exactly like a run that exercised everything.
SHADOW_TEST_REQUIRE_ROOT turns a skip into a failure. The Docker images
set it, since they run as root: 27 useradd tests that reported ok off-root
fail loudly there now if the suite ever stops running as root.
The matrix also runs with pam. The feature changes which code paths
exist -- chfn and chsh refuse every non-root call without it, and passwd
loses its interactive path -- so root and PAM together had never been
exercised by any job.
* fuzz: assert a real invariant, add a subid target, build in CI
The targets asserted only that parsing does not panic. For a record file
the property that matters is that a parsed entry survives being written
back: if Display can emit a line that parses differently, a tool
rewriting the file silently changes an account. Each target now checks
that the rendered line carries exactly the separators its format has --
one more would shift every following field on the next read, which is
how an injected account appears -- and that parsing it again renders
identically. /etc/subuid had no target at all.
The crate is a separate workspace nothing else builds, so it had rotted:
its manifest asked shadow-core for features that no longer exist. A
stable 'cargo check --bins' in CI catches that without nightly.
The corpus was both gitignored and tracked. It is kept: 667 accumulated
inputs are worth carrying between runs, and the main suite now replays
them through the same two properties on every pull request, so a
regression a fuzzer once found is caught on stable without a fuzzing
session.
* chage: an invalid date is a bad argument, not an unexpected failure
'chage -E 2025-02-29' exited 3, which chage(1) uses for something going
wrong that the caller did not ask for. A date the caller typed wrongly is
exit 2, which is what GNU shadow 4.17 answers -- 'chage -d -5 u' prints
"invalid date '-5'" and exits 2.
Found by the integration tests once they ran the real binary and looked
at its exit code.
* tests: guard the chage tests that need root, and let an empty flag mean off
The chage tests drive the tool against a --prefix tree, but 'chage -l' on
an account other than the caller's, and every modification, are refused
for a non-root caller before any file is opened. The host runner is not
root, so those tests failed there.
SHADOW_TEST_REQUIRE_ROOT also treated an empty value as set, so
'-e SHADOW_TEST_REQUIRE_ROOT=' turned the requirement mysteriously on
rather than off. An empty value now counts as unset, which is the
conventional reading.
* ci: do not ask Alpine for a pam build
Alpine's Rust image targets musl with crt-static, where shadow-core
refuses the pam feature at compile time on purpose: Linux-PAM loads its
modules with dlopen(3), which a static binary cannot do, so a build that
compiled would produce an authentication path that never works.
The new 'with pam' matrix step ran on all three distros and so hit that
guard. It runs on the glibc images now. The static musl job already
asserts that requesting pam there is rejected, and Alpine is in the
matrix for musl's libc behaviour, not for PAM.
|
||
|
|
9cd5eef4ac |
Install layout, aarch64 archives, pinned images, and a pre-push hook worth keeping (#272)
* hooks: stop reproducing CI in pre-push The hook ran fmt, clippy and the test suite on Debian, Alpine and Fedora in series, on every push. That is several minutes for a push that may carry nothing: pushing the 0.2.2 tag moved one object and still blocked for over two minutes. The predictable result was that every push in the last batch of work used --no-verify, and a hook that is always skipped protects nothing while still costing time whenever someone forgets to skip it. It now runs the workspace tests once, on Debian, and returns immediately for a push that carries no new commits -- a tag, a branch deletion, or a branch the remote already has. git hands the hook one line per ref on stdin; the ranges it names are asked for a single commit, and if there are none there is nothing to test. Everything the hook dropped is already a required check on the pull request, running in parallel with retries: fmt, clippy and the tests with and without pam, MSRV, cargo-deny, the static musl archive, the three-distro matrix as root, and the end-to-end deployment image. Closes #231. * install: put the user-facing tools in bin, not sbin All fourteen binaries went to $(PREFIX)/sbin, which is not on a normal user's PATH: passwd was 'command not found' for exactly the people it is setuid for. GNU shadow splits them, verified against the Debian package -- passwd, chage, chfn, chsh and newgrp in /usr/bin, the other nine in /usr/sbin. Both install targets do the same now, and the multicall symlinks in bin point at the absolute path of the binary in sbin. chage is in bin for 'chage -l', the one mode a user may run on their own account. The deployment suite follows each tool to the directory it belongs in, and asserts that all five are reachable by name for an unprivileged user, which is the property the split exists for. make test also runs the suite with pam now. install ships binaries built with it, so a run without it covered a build nobody ships. * docker: pin the base images, drop the unused nextest download rust:latest, rust:alpine and fedora:latest meant a new toolchain or distro release landed on main with no pull request and no signal, and clippy gains lints between Rust releases. They are pinned to what the images resolve to today -- rust 1.98 on Trixie and Alpine 3.23, Fedora 44 -- and Renovate's Dockerfile manager proposes the bumps, so a toolchain change arrives as a reviewable PR that CI has run. Each image also downloaded cargo-nextest from get.nexte.st/latest on every build: unpinned, unverified, and never invoked -- nothing in the repository runs 'cargo nextest'. cargo-deny, which is used, keeps its pinned version and checksum. * deny: allow exactly the licences the graph uses The allow list carried ISC, BSD-2-Clause, CC0-1.0 and Zlib, none of which appears anywhere in the dependency graph. A list wider than the graph means a crate arriving under a new licence passes silently; the point of the check is that such an arrival is a decision someone records. It is now MIT, Apache-2.0, BSD-3-Clause (subtle, for the constant-time comparison in shadow-core::crypt) and Unicode-3.0 (tinystr and unicode-ident, via uucore's fluent stack), taken from 'cargo deny --all-features list'. Two crates carry a copyleft option in a dual licence and are accepted under the other half; that is now written down rather than left for the next reader to work out. All four checks pass with the narrower list. * ci: run the advisory workflow on a schedule only It also ran on every push touching a manifest or the lockfile, where the Cargo Deny job in ci.yml had already checked the same advisory database alongside licences and bans. Two tools reporting the same finding on the same commit is noise. What the pull request job cannot do is notice an advisory published against code nobody has touched, which is the reason to keep a scheduled run. That is now all this workflow does, plus manual dispatch. * packaging: remove the RPM spec and debian directory Neither can build. debian/ has no changelog at all, so dpkg-buildpackage stops before it starts: dpkg-buildpackage: error: cannot open file debian/changelog and shadow-rs.spec pins version 0.2.0 against a 0.2.2 workspace, points at the pre-transfer repository URL, and lists a %files layout that 'make install' does not produce -- it names the multicall binary, which only install-multicall builds, and puts every tool in sbin, which is no longer where half of them go. Shipping packaging that claims to work and does not is worse than shipping none, and this would have produced a package declaring 'Provides: passwd, login' on a system whose real passwd it replaced. Releases are the archives dist publishes; a distribution packager writes these against their own policy anyway. * release: publish an aarch64 archive The pipeline shipped x86_64 only. arm64 servers and Apple-silicon containers are common enough that a missing archive is a real gap. It builds on a native arm64 runner rather than cross-compiling, which is what makes it straightforward: shadow-core::crypt links crypt(3) and the pam feature links libpam, so a cross build would need an arm64 sysroot carrying both, while a native runner installs them from apt like any other build. dist has picked a native runner for aarch64 since 0.30. Both runners are pinned to 24.04. dist's defaults are ubuntu-22.04 and ubuntu-22.04-arm, and 22.04 enters deprecation on 2026-09-17 with the image unsupported from 2027-04-17; the pin keeps the two archives on the same generation and off an image that is about to start failing. The higher runner raises the glibc floor for the dynamically linked archives, which is what the static musl archive is there for. release.yml is regenerated by 'dist generate'; 'dist plan' now lists three archives and resolves ubuntu-24.04-arm for the arm64 job. Closes #222. |
||
|
|
4e539357a1 |
shadow-core: remove duplicated lookups, fix login.defs radices, allocate around NSS (#271)
* shadow-core: delete the duplicated NSS entry type and the unused surface process::PwEntry was a field-by-field twin of passwd::PasswdEntry, copied across at every NSS lookup. getpwuid now returns the parser's own type: an NSS entry and a file entry describe the same account, and a second struct for it only bought a copy per call. lookup_username goes with it, and lookup_username_by_uid becomes one line. The tty module had its own signal-mask guard beside hardening's SignalBlocker, and the PAM conversation had its own controlling-terminal lookup. There is now one of each. error::ShadowResult and ShadowError::Permission had no users at all, and display_message carried an _is_error parameter it never read. harden_process returned a sanitized environment that every one of its thirteen callers discarded, which read as though the process environment had been dealt with. It has not been and is not: the in-process PAM and NSS modules still see what the caller set. The function no longer claims otherwise, and the callers no longer bind a value to throw away. Adds gid_exists, the group-side companion to getpwuid, for the allocator. * shadow-core: read login.defs numbers in the radices the man page documents login.defs(5) writes numbers 'in decimal, or in octal with a leading 0, or in hexadecimal with a leading 0x'. get_i64 parsed decimal only, which is not a rejection but a silent misreading: UID_MIN 01000 means 512 and came back as 1000, so useradd would allocate from a range the administrator had reserved. UMASK 022 and HOME_MODE 0750 have the same shape and will be read the day they are honoured. LoginDefs also gains Default, an empty configuration, which is what a caller wants when the file is missing: every lookup returns None and the tool falls back to its own default. * useradd, groupadd: never allocate an ID the name service already knows /etc/passwd is not the only source of accounts. An ID that is free in the file may belong to an LDAP, SSSD or systemd-homed user, and handing it out gives two accounts one ID -- which the kernel cannot tell apart, so the new local user ends up owning the directory user's files. Allocation now asks NSS as well as the file. Which sources count is an explicit Scope rather than a hidden default, because a --prefix or --root run manages another system's files: the name service running here describes this one, so asking it would skip IDs that are free on the target and claim none that are taken there. SysRoot::is_prefixed decides. uid_range and gid_range were the same function twice over different keys and are now one, the system ranges being the same keys with a SYS_ prefix. The allocation tests moved from next_uid to next_free. Once next_uid consults NSS its answer depends on which accounts exist on the machine running the test, and on a typical container 1000 and 1001 do. The policy is tested where it lives, and two tests assert the property that actually matters against the live system: an allocated ID is in range, not in the entries given, and unknown to NSS. * audit: write to /dev/log instead of forking logger for every event Each account event spawned /usr/bin/logger and waited for it, to write a few dozen bytes to a socket. A setuid-root tool should spawn as little as it can: the fork inherited the process state, depended on a binary being installed at a fixed path, and blocked the caller. The record now goes straight to /dev/log as an RFC 3164 datagram, and carries the terminal it came from instead of a literal '?'. Failure stays silent in every direction. A machine with no syslog daemon is a normal machine, and a record that cannot be written must not fail the operation that produced it. The deployment suite runs rsyslogd and greps the log for the creation and deletion records, so this is covered rather than assumed. Also corrects atomic's claim that its temp-file names are unique 'across threads' in a single-threaded crate; what they separate is successive writes within one process, which usermod does four of in a row. * docs: record the hardening changes and what --root really does sysroot's module documentation said every tool offering --root performs a real chroot. Five of the thirteen do; the other eight fold it into the same resolver --prefix uses, so an absolute path read out of a record resolves against / rather than the target. That is a real divergence worth its own change and is filed as #270; the documentation now says which tools do which and points there. * cargo: only gate what changes linkage; stop forcing applets in with pam Five of shadow-core's features -- shadow, group, gshadow, login-defs, subid -- gated modules that pull in no dependency. Gating them bought nothing and cost real confusion: cargo test -p shadow-core compiled a fraction of the crate and reported a pass for it. It runs 181 tests now where it ran 20, without a single one being written. uid_alloc loses its cfg(all(group, login-defs)) with them. What remains behind a feature is what actually changes the build: pam (links libpam), crypt (links libcrypt on glibc, and now pulls subtle, which nothing else uses) and landlock. In the root manifest, pam = ["passwd/pam", ...] force-enabled the three applets that can use PAM, so asking for PAM in a build that had deliberately left them out dragged them back in. The optional-dependency form pam = ["passwd?/pam", ...] applies the feature if the applet is there and does nothing if it is not. The feat_* aliases and the feat_phase* groups are gone. Every one was a rename of a feature cargo already provides -- feat_passwd = ["passwd"] -- and nothing outside the manifest had referred to them since the phases they were named for finished. Verified against all four delivery paths: make build and make build-multicall still link libpam, the static musl archive still builds and reports all fourteen applets, and a --no-default-features --features pam build now produces a binary with no applets at all and no warnings, which is the case the ? was added for. |
||
|
|
3a56b6107e |
ci: run the root suite on pull requests, fix the trace guard's false positives (#259)
* ci: run the root test suite on pull requests, restrict token permissions About three quarters of the integration suite is root-only and returns silently when not root, reported as ok. The only job that runs as root was the Docker matrix, gated on push, so a pull request effectively tested a quarter of the suite and regressions surfaced only after merge. The matrix now runs on pull requests too; each distro takes a couple of minutes. The MSRV job checks --all-targets, so the test code is covered by the minimum supported version too, and both workflows declare least-privilege token permissions instead of inheriting the default scope. * ci: word-boundary the tooling-artifact patterns and cover two more forms The script's own comment claimed the patterns were word-boundaried; they were not, so several of them matched as substrings inside ordinary identifiers and English words, failing any change that happened to touch one. The alternation is now anchored with \b. Two forms that slipped through are added: a model name carrying a version suffix, and the co-author trailer a bot appends to a commit. CONTRIBUTING.md becomes exempt from the added-lines scan, because it states the project's policy on such tools and therefore has to be able to name them; the CI workflow no longer needs an exemption, since nothing in it matches. * deps: drop the unused clap derive feature, tighten the license policy Nothing in the tree derives clap's Parser/Args/Subcommand/ValueEnum, so the derive feature only pulled clap_derive and heck into the build graph. It is not the cause of the syn 2/3 duplicate: syn 3 arrives through uucore -> fluent -> displaydoc and syn 2 through landlock -> enumflags2, neither of which is ours to move. The deny skip now records that, and matches any 2.x rather than one stale patch version that no longer exists in the lockfile. MPL-2.0 leaves the license allowlist -- it is file-level copyleft and this project ships MIT only, so a dependency needing it should be a decision rather than a default -- and a crate from outside crates.io is now denied instead of warned about. |
||
|
|
382edd4dc2 |
passwd: make the interactive password change work; repair and CI the e2e suite (#251)
* passwd: make the interactive password change work Three faults kept passwd from changing a password, all found by running a real change as an unprivileged user under su -c and read from pam_unix's own debug log: - the PAM conversation only ever opened /dev/tty, which fails with ENXIO when there is no controlling terminal (su -c, a service, cron); pam_unix then reported "conversation failed ... (old) token not obtained". It now falls back to stderr for the prompt and stdin for the answer, the way getpass(3) and PAM's misc_conv do, and still prefers /dev/tty when there is one; - pam_authenticate was called against the passwd service, which defines no auth stack, so it failed before any prompt. The current password is verified by pam_unix inside pam_chauthtok, keyed on the real uid; the call is removed; - the privilege drop spanned pam_chauthtok, so pam_unix could neither run its setgid helper nor rewrite /etc/shadow. euid 0 is what the setuid bit is for; the drop is gone. Landlock is no longer applied to the PAM path: restrict_self sets no_new_privs, which strips the setgid bit from unix_chkpwd. It now wraps only the direct-file paths, with rules that let NSS, nscd and logger run. --prefix becomes root-only, like --root. * e2e: repair the deployment image and run it in CI Dockerfile.e2e ran `make install`, which since #138 installs per-tool binaries, then chmod 4755'd a shadow-rs multicall that was never installed, so the image had not built for months. It now uses install-multicall, and an `e2e` CI job builds and runs the suite on every pull request. The suite now changes a password through PAM as an unprivileged user and proves it by authenticating with the new password and failing with the old (root's own su never prompts, so the old checks proved nothing), rejects a wrong current password, has root set one without the old, checks the setuid privilege model for pwck/grpck and that /etc/shadow keeps its owner and mode. tests/pam-e2e.sh, which could not fail, is deleted. * docs: record the passwd PAM fix and the e2e CI job |
||
|
|
52c2debe8b |
release: publish a labelled static musl archive (#224) (#237)
* shadow-core: link libcrypt only where it exists; refuse pam on static musl musl implements crypt(3) inside libc and ships no libcrypt. Asking for -lcrypt there made a cross-build for x86_64-unknown-linux-musl fall through to the host's glibc-built libxcrypt and fail on glibc-only symbols; Alpine only worked because musl-dev ships an empty libcrypt.a. Linux-PAM loads its modules with dlopen, which static musl does not have, and no distribution ships libpam.a. Building the pam feature into a crt-static musl binary is therefore a configuration error; reject it at compile time instead of producing a binary whose authentication path can never work. The crypt tests stated the musl behaviour wrongly (musl does honour rounds=; it lacks yescrypt). They now assert the real behaviour per target instead of skipping. * release: publish a static musl archive next to the glibc one dist cannot vary cargo features per target and pam must be off for musl, so the musl archive is built by 'make dist-musl' and attached by dist as an extra artifact of the same release. The target enforces the lockfile, checks the binary has no DT_NEEDED entry and packs it with the licence, changelog, README and PLATFORM-SUPPORT.md so the trade-off travels with the binary. The archive name carries a -static suffix so it is never mistaken for the glibc build. CI runs the same make target on every pull request, smoke-tests the archive, runs shadow-core's unit tests against static musl and checks that the pam feature is rejected there, so a tag is never the first time the archive is built. * docs: describe both release archives and what the static musl one gives up The README now lists the two archives side by side with the one question a reader has to answer (is PAM, NSS or yescrypt needed on this host?), and PLATFORM-SUPPORT.md is rewritten around the archive that now exists: how it is built, which CI job guards it, and the exact effect of each gap. CONTRIBUTING listed two unsafe-permitted modules; there are three. * chore: use the canonical uutils/shadow repository URL The repository is uutils/shadow; the old shadow-rs name is a redirect. The README badges already used the new name while package metadata, the --help footer and SECURITY.md still pointed at the old one. |
||
|
|
9f197e0e50 |
chfn, chsh: authenticate the caller before applying a change (#235)
* shadow-core: move PrivDrop out of passwd The RAII guard that drops the effective UID for the PAM conversation lived inside uu_passwd, but it is process-level privilege handling that any setuid tool needs. Move it next to the seteuid wrapper it calls. Two changes while moving it: the error type becomes io::Result rather than PasswdError so other tools can use it, and the failure-to-restore message goes through uucore::show_error! instead of a hardcoded "passwd:" prefix, so it names whichever tool is running. * chfn, chsh: authenticate the caller before applying a change Both tools are installed setuid-root. They correctly restrict a non-root caller to their own account, checked against the real UID — but they never verify who is asking. Anyone with access to an unlocked session could change that user's login shell or GECOS field, and changing a login shell is a foothold. GNU prompts for the password here, and distributions already ship the service definitions we were ignoring: /etc/pam.d/chfn and /etc/pam.d/chsh both let root through with pam_rootok and fall back to common-auth for everyone else. CONTRIBUTING treats a difference from GNU as a bug, so this is one. Authenticate through PAM for non-root callers, reusing the pattern passwd already uses: drop to the real UID for the conversation so modules see the actual caller, then authenticate and check account validity. Without the pam feature there is no way to verify anyone, so the tools refuse rather than apply an unauthenticated change. That is a behaviour change for that build only, and the safe direction for a setuid binary. It also means every install path must enable the feature — 'make build' only enabled it for passwd, which would have shipped two setuid tools that reject all non-root use. Fixed, and the end-to-end assertion now covers all three tools instead of passwd alone. CI gained 'cargo test --features pam': the feature changes behaviour rather than only adding code, so both configurations need testing. |
||
|
|
8c45be4d56 |
ci: lint the PAM code path (#234)
CI runs clippy without --features pam, so everything behind that gate has never been linted. Since #220 the release binaries are built with it, so the code we ship is precisely the code nobody checks. Running it today fails on main: error: this `if` statement can be collapsed error: used underscore-prefixed binding (x4) error: `(align_of::<*const c_char>() - size_of::<c_int>())` is never smaller than 0 and has therefore no effect Fix all three and add the missing clippy invocation so the gate cannot rot again: - passwd: keep_tokens/use_stdin/target_user are used by the pam block, so name them properly and allow the unused warnings when the feature is off, rather than underscore-prefixing bindings that are read. - passwd: collapse the root check and the authenticate call into a let chain, as done elsewhere in the tree. - pam: the layout test clamped an unsigned subtraction with .max(0), which can never trigger. Assert what the test actually means — the struct is at least int + pointer, and padded to its own alignment — instead of reimplementing the compiler's layout rules. |
||
|
|
90faf4cc86 |
ci: reject AI tooling artifacts in pull requests (#219)
* ci: reject AI tooling artifacts in pull requests The project's public face carries no AI tooling attribution, but nothing enforced it: a session trailer reached a merged commit message and a pull request body before anyone noticed. Add a check that runs on pull requests and inspects only what the PR changes — commit messages, PR title and body, added lines, and any attempt to track the local-only guidance and review-export files that are meant to stay gitignored. History is untouched and unrelated files are not scanned. The clean-room audit records are exempt: they must describe their own methodology to serve as compliance evidence. Also drop the tooling credits from README and CHANGELOG. * ci: fix base-ref fetch in the artifact check `--depth=0` is rejected by git, and the extra fetch was redundant with the full-history checkout. Fetch the base branch plainly and diff against FETCH_HEAD. |
||
|
|
a5c5e816ea |
ci: publish release binaries with dist (#207) (#216)
Releases carried no binaries (v0.2.1 shipped zero assets). Add dist (formerly cargo-dist) 0.32.0 to build and upload them on tag push. Scope: only the workspace root package is distributed, producing the single shadow-rs multicall binary that provides all 14 tools via argv[0] — the same layout as `make install-multicall`. The 14 per-tool crates carry `dist = false` so a release is one archive per target rather than fifteen. `[dist.binaries]` pins the archive to `shadow-rs`, keeping the feature-gated `shadow-rs-completions` helper out of it. Targets are glibc-only for now: shadow-core::crypt links crypt(3) via `#[link(name = "crypt")]`, and the self-contained musl target provides no libcrypt, so an x86_64-unknown-linux-musl cross-build fails at link time. (Alpine CI passes only because it installs a system libcrypt.) `[dist.dependencies.apt]` installs libcrypt-dev on the runner for the same reason. `[profile.dist]` inherits the hardened release profile, so published binaries keep LTO, stripped symbols and panic = "abort". README documents deploying the archive (symlinks + setuid) and why musl is not published yet. Verified locally: `dist plan` and `dist build --artifacts=local` produce uu_shadow-x86_64-unknown-linux-gnu.tar.gz containing shadow-rs plus README/CHANGELOG/LICENSE and a .sha256; the extracted binary reports `shadow-rs (uutils shadow-rs) 0.2.1`. Closes #207. Claude-Session: https://claude.ai/code/session_011f42R6SMAfQXufTgdjSuhU |
||
|
|
eee120279b | chore(deps): update actions/checkout action to v7 | ||
|
|
8e9324652b |
ci: polish the image-build retry loop (#174)
Address review feedback on #173: - Don't sleep after the final build attempt (no retry follows it). - Forward the workflow-wide CARGO_NET_RETRY into the container with a bare '-e CARGO_NET_RETRY' instead of re-hardcoding the value, keeping a single source of truth. |
||
|
|
4ce4184e38 |
ci: make the Docker test matrix resilient to registry flakes (#172) (#173)
The Test (debian/alpine/fedora) matrix failed four times in a row on 2026-06-10, each on a transient network error (Docker Hub base-image pull i/o timeout; crates.io download broken pipe) unrelated to the code. - fail-fast: false so one distro's flake no longer cancels the others. - Retry 'docker compose build <target>' (the Docker Hub pull) with backoff. - Pass CARGO_NET_RETRY into the container and set it for runner-native jobs so cargo retries crate downloads; real test failures still fail fast (no step-level retry around the test run). No new third-party actions — a shell retry loop keeps the supply-chain surface unchanged. Closes #172. |
||
|
|
4ecf63ec1f |
security: AT_EXECFN validation + audit.yml (#154, #155)
#154: Reject setuid multicall invocations where argv[0] doesn't match AT_EXECFN from the ELF auxiliary vector. Prevents an attacker from spoofing argv[0] to route the multicall binary to a different tool in setuid context. Only enforced when euid != uid. #155: Add daily cargo-audit workflow matching uutils/coreutils pattern. Also triggers on Cargo.toml/Cargo.lock changes. Fixes #154. Fixes #155. |
||
|
|
e7c628256e | chore(deps): update actions/checkout action to v6 | ||
|
|
e684c218d0 | ci.yml: switch to non-versioned URL | ||
|
|
063d47c37e | ci.yml: simplify by pre-install toolchain | ||
|
|
fdb6d46a05 |
review: address 2 Copilot review comments
- Add homepage.workspace to root package (missing vs subcrates) - Gate Docker test-docker job to push only (seccomp:unconfined and SYS_ADMIN are unsafe for untrusted PR contexts) |
||
|
|
5a457b8781 |
ci: add Docker multi-distro tests, renovate, rust-toolchain
- Add Docker test matrix (debian/alpine/fedora) to GitHub Actions CI - Add renovate.json for automated dependency updates - Add rust-toolchain.toml for contributor convenience |
||
|
|
83080f53dd |
CI: upgrade cargo-deny-action to v2 for edition 2024 support
The v1 action bundles an old Cargo that doesn't recognize edition 2024. v2 uses the host toolchain, so we install stable Rust first. |
||
|
|
9e3faa966c | address Copilot review: fix docs, feature-gate crypt, MSRV 1.94, CI msrv job | ||
|
|
896a4214b6 |
infra: uutils compliance — edition 2024, unsafe deny, dead_code deny
Fixes #79 — CODE_OF_CONDUCT.md (Contributor Covenant 2.1) Fixes #80 — locales/en-US.ftl for all 14 tools Fixes #81 — rust-version = "1.85.0" in workspace Fixes #82 — edition 2024 (resolver 3, unsafe extern blocks) Fixes #83 — GitHub Actions CI (fmt, clippy, test, cargo-deny) Fixes #84 — .clippy.toml with MSRV and quality thresholds Fixes #85 — deny.toml tightened (multiple-versions=deny) Fixes #87 — panic=abort, codegen-units=1 in release profile Fixes #89 — Makefile with install/uninstall/clean targets Fixes #90 — workspace lints: cargo group, unused_qualifications Key policy changes enforced by cargo: - unsafe_code = "deny" workspace-wide. Only pam.rs and crypt.rs have #[allow(unsafe_code)] (C FFI boundaries). - dead_code = "deny" workspace-wide. No unused code allowed. - Edition 2024: set_var/remove_var are unsafe, so sanitize_env returns a Vec instead of mutating. Signal handler removed (was unsafe). localtime_r replaced with pure Rust calendar math. 456 tests, zero clippy warnings. |