Hierarchical Unix filesystem permissions manager with snapshots, ACLs, and audit.
janitor is a single-binary Linux CLI for managing POSIX permissions and ACLs. It is built around four ideas:
- A hierarchical
grantthat walks the parent chain and sets the minimum traverse-only (--x) bit on every intermediate directory, so a user can reach a deep file without seeing its siblings. - An automatic snapshot before every mutation. A MessagePack backup is written to
/var/lib/janitor/backups(root) or~/.local/share/janitor/backups(user) before any change, so anygrant,chmod,chown, oraclcall can be reverted withjanitor restore <id>(orjanitor undofor the most recent one). - POSIX ACL support via
setfacl/getfacl, including default ACLs and recursive application. - Audit and inspection tooling:
audit,find-orphans,who-can,explain,compare,diff,export.
Safe defaults: lchown(2) is used for symlinks, an advisory file lock prevents concurrent mutations, SIGINT aborts cleanly with exit 130, and --dry-run prints every planned change without touching disk.
No runtime dependencies beyond a Linux kernel and (for the acl subcommand) the acl package. A statically linked musl build runs on any Linux x86_64 distribution — tested on 12 distros from RHEL 8 (kernel 4.18, glibc 2.28) to Fedora 43 / Ubuntu 25.10 (kernel 6.17, glibc 2.42).
- Installation
- Quick start
- Concept: hierarchical grant
- Command reference
- Presets
- JSON output
- Backup & restore
- Upgrading from 0.1.5
- Security notes
- Building from source
- Testing
- Contributing
- License
Every tagged release publishes prebuilt Debian, Red Hat, and portable tarball
artifacts for amd64 and arm64. Replace VERSION with the latest version
from the releases page.
VERSION=0.1.5
curl -LO "https://github.com/Tristram1337/janitor/releases/download/v${VERSION}/janitor-${VERSION}-linux-amd64-static.tar.gz"
tar xzf "janitor-${VERSION}-linux-amd64-static.tar.gz"
sudo install -m 0755 "janitor-${VERSION}-linux-amd64-static/janitor" /usr/local/bin/
sudo install -m 0644 "janitor-${VERSION}-linux-amd64-static/janitor.1" /usr/local/share/man/man1/
janitor completions bash | sudo tee /etc/bash_completion.d/janitor >/dev/nullThe static (musl) binary has no glibc requirement and runs on every Linux x86_64 system from RHEL 8 / Ubuntu 22.04 (kernel 4.18) to Fedora 43 (kernel 6.17). This is the recommended install method.
VERSION=0.1.5
curl -LO "https://github.com/Tristram1337/janitor/releases/download/v${VERSION}/janitor_${VERSION}-1_amd64.deb"
sudo apt install "./janitor_${VERSION}-1_amd64.deb"The package installs the binary to /usr/bin/janitor, the man page to
/usr/share/man/man1/janitor.1.gz, and shell completions for bash, zsh,
and fish. Dependencies (acl, passwd) are pulled in automatically.
Note: The
.debpackage uses the glibc build (requires glibc >= 2.39, i.e. Debian 13+ / Ubuntu 24.04+). For older releases use the static binary above.
VERSION=0.1.5
sudo dnf install "https://github.com/Tristram1337/janitor/releases/download/v${VERSION}/janitor-${VERSION}-1.x86_64.rpm"Dependencies (glibc, acl, shadow-utils) are resolved automatically.
Note: The
.rpmpackage uses the glibc build (requires glibc >= 2.39, i.e. Fedora 42+ / RHEL 10+ / CentOS Stream 10+). For RHEL 8/9, Rocky, or Alma 8/9 use the static binary above.
For systems where you know glibc >= 2.39 is available (Debian 13+, Ubuntu 24.04+, Fedora 42+):
VERSION=0.1.5
curl -LO "https://github.com/Tristram1337/janitor/releases/download/v${VERSION}/janitor-${VERSION}-linux-amd64.tar.gz"
tar xzf "janitor-${VERSION}-linux-amd64.tar.gz"
sudo install -m 0755 "janitor-${VERSION}-linux-amd64/janitor" /usr/local/bin/For older systems, use the static binary instead.
cargo install --path .| Build | glibc requirement | Minimum kernel | Distros |
|---|---|---|---|
| Static (musl) | none | 4.18 | RHEL 8+, Debian 12+, Ubuntu 22.04+, Alpine, any |
| Dynamic (glibc) | >= 2.39 | 4.18 | Debian 13+, Ubuntu 24.04+, Fedora 42+, RHEL 10+ |
Additional runtime dependencies (for full functionality):
aclpackage (setfacl/getfacl) — needed byjanitor aclsubcommands.shadow-utils(groupadd,gpasswd) — needed byjanitor grantmanaged groups.- The
.deband.rpmpackages declare these automatically.
# Give Alice read-only access to a nested file (siblings remain hidden):
sudo janitor grant /srv/docs/secret.txt -u alice -r
# Same, but read + write:
sudo janitor grant /srv/docs/report.txt -u alice -rw
# One-shot summary of any path (type, owner, mode, ACLs, effective rwx):
janitor info /srv/docs/report.txt -U alice
# See the resulting permission tree, colored for a specific user:
janitor tree /srv -U alice
# List every file under /home that Bob can execute (binary / script):
janitor --json tree /home -U bob | jq '.. | select(.x? == true) | .path'
# Audit the whole server for world-writable files and SUID binaries as JSON,
# and print just the paths:
sudo janitor --json audit / -W -s | jq -r '.[].path'
# Who can read /etc/shadow? (checks the parent chain, group memberships,
# and POSIX ACLs; returns three arrays: read / write / exec.)
sudo janitor --json who-can /etc/shadow | jq '.read'
# Give the `devs` group rwx on a project tree via POSIX ACL (new files
# inherit the same ACL thanks to -d):
sudo janitor acl grant /srv/app -g devs -rwx -d -R
# Oops, undo the last change (one-shot revert for permissions):
sudo janitor undo
# Exact rollback of a specific earlier change:
BID=$(sudo janitor ls | awk 'NR==3 {print $1}')
sudo janitor restore "$BID"Running:
sudo janitor grant /srv/docs/secret.txt -u alice -rperforms all of the following, atomically:
- Snapshots current permissions of
/srv,/srv/docs, and/srv/docs/secret.txt. - Creates a managed group named
pm_tmp_<owner>_<pathhash>(unless-g GROUPis given). - Adds Alice to the group.
- Sets the group-triad of
/srvand/srv/docsto exactly--x, so Alice can pass through but cannotlsthem. - ORs the requested bits (here just
r) onto the target file's group triad. - Prints a
backup: <id>line for exact rollback.
Sibling files in /srv/docs stay invisible to Alice. This is the key difference from chmod -R or a blunt chgrp.
Access bits are given with -r / -w / -x (e.g. -rw, -rwx), or as a string with -a rwx. If no access flag is given, defaults to read-only.
Paths are positional; commands have short aliases and every common flag has a single-letter equivalent.
| Command (alias) | Purpose |
|---|---|
grant (g) PATH [-u USER|-g GROUP] [-r] [-w] [-x] [-R] |
Hierarchical grant with auto-snapshot. |
revoke (rv) PATH -u USER |
Remove user from the managed group (all-or-nothing). |
restore (r) ID [--yes] [--skip-missing] [--allow-replaced] |
Full rollback of a specific backup, including the group membership a grant created. Refuses entries whose file type or inode changed since the snapshot; --allow-replaced accepts a new inode (an editor rewrite) while still refusing a type change. --skip-missing tolerates paths that no longer exist. |
undo (u) [--yes] [--skip-missing] [--allow-replaced] |
Restore the most recent backup (one-shot revert of the last change). |
tree (t) PATH [-L DEPTH] [-U USER] [-A] [-c WHEN] |
Colored permission tree. |
chmod MODE PATH... [-R] [-F FILE] [-E GLOB] [--from-file FILE] [--stdin0] |
Octal (inc. 4755/2755/1777/6755 special bits) or symbolic (u+s, g+s, +t, a+X, ...), with auto-snapshot. Accepts many PATHs in one call (single snapshot) and can stream them from a file or NUL-separated stdin. --reference FILE copies the mode from another path. |
chown SPEC PATH... [-R] [-F FILE] [-E GLOB] [--from-file FILE] [--stdin0] |
user, user:group, :group, user:, numeric 1000:1000. Symlinks are always lchown-ed. Same mass-path / exclude / stdin options as chmod. |
info (i) PATH [-U USER] |
One-shot summary: type, owner, group, mode (octal + symbolic), setuid/setgid/sticky, size, mtime, symlink target, ACLs, optional effective access for a user. |
history (h) PATH [--since DUR] |
Every backup whose target contains PATH, newest first. --since 30m/1h/2d/1w filters by age. Supports --json. |
copy-perms (cp) SRC DST [-R] [-A] [-E GLOB] |
Atomically copy mode + owner + group (+ ACLs with -A) from SRC to DST, snapshotting DST first. |
audit (a) PATH [-W] [-r] [-x] [-s] [-S] [-t] [-o USER] [-g GROUP] [-m MODE] [-A] [--no-owner] [--no-group] [-E GLOB] [--fix ACTION] [--best-effort] |
Scan filters are AND-combined. --fix ACTION applies chmod MODE / chown SPEC / preset NAME / strip-world-write / strip-setuid / strip-setgid / strip-sticky to every match under one snapshot. Exits non-zero if part of the tree could not be read; --best-effort downgrades that to a warning. |
find-orphans PATH [--best-effort] |
Files with UID/GID not in /etc/passwd or /etc/group. |
who-can (w) PATH |
Reverse query: which users can read / write / exec. |
diff ID / export ID |
Inspect a backup vs current / dump a backup as text or JSON. |
acl grant|revoke|show|strip PATH [-u USER|-g GROUP] [-r] [-w] [-x] [-d] [-R] |
POSIX ACL management with snapshots. |
preset (p) list |
Show all named modes with their octal values and description. |
preset (p) apply NAME PATH... [-R] [-E GLOB] |
Apply a named mode (private, group-shared, setgid-dir, ssh-key, ...). Accepts many PATHs under one snapshot. |
seal PATH -B USER:GROUP:MODE [-R] [--allow USER:PERM PATH] [--allow-group GROUP:PERM PATH] [-E GLOB] |
Atomic "uniform baseline + surgical pinholes". Baseline is POSIX-only (chown + chmod), ACLs are written only for the --allow pinholes and their parent-chain traversal bits. One snapshot covers everything. |
list-backups (ls) [-p SUBSTR] / prune-backups (prune) [-k N] |
List (optionally filter by target path) / prune snapshots. |
backup (b) PATH [-R] [--no-acl] |
Snapshot without changing anything (ACLs included by default). |
explain (e) PATH [-U USER] |
Human-readable r/w/x verdict walking the parent chain. |
compare A B [-R] |
Diff mode / owner / group / ACL. Exit 1 on drift. |
lock PATH [-r REASON] / unlock PATH / locks |
Block all janitor mutations on PATH (and descendants if PATH is a directory). |
policy apply|verify FILE |
Declarative YAML policy: rules: [{path, mode?, owner?, group?, preset?, recursive?, exclude?}]. verify exits 1 on drift. |
batch FILE |
Run many chmod / chown / preset ops in one transaction (one snapshot, one undo). Use - for stdin. |
attr show|set-immutable|clear-immutable|set-append-only|clear-append-only PATH |
Wrapper around chattr / lsattr. Snapshotted and --dry-run-aware like every other mutation. |
completions SHELL |
bash / zsh / fish / powershell / elvish. |
Global flags: -n, --dry-run, -j, --json (where supported), -h, --help, -V, --version.
See man janitor (or janitor(1)) for the full manual with workflows and more examples.
| Name | Mode | Description |
|---|---|---|
private |
700 | owner only |
private-dir |
700 | directory visible to owner only |
private-file |
600 | file readable/writable by owner only |
group-shared |
770 | rwx for owner and group, none for other |
group-read |
750 | rwx owner, rx group, none other |
public-read |
755 | rwx owner, rx group, rx other |
public-file |
644 | rw owner, r group, r other |
sticky-dir |
1777 | world-writable with sticky bit (/tmp style) |
setgid-dir |
2775 | group-shared dir with setgid (children inherit group) |
secret |
400 | read-only for owner, nobody else |
secret-dir |
500 | directory readable/traversable by owner only |
exec-only |
711 | owner rwx; others may traverse but not list |
ssh-key |
600 | private SSH key (what sshd demands) |
ssh-dir |
700 | ~/.ssh directory |
config |
640 | config file readable by owner's group |
log-file |
640 | log file readable by owner + group |
systemd-unit |
644 | systemd unit file (/etc/systemd/system/*.service) |
read-only |
444 | read for everyone, writable by no one |
no-access |
000 | no permissions at all (panic-button quarantine) |
sudo janitor preset apply setgid-dir /srv/project --recursive--json is supported on audit, find-orphans, who-can, diff, export, and list-backups, producing machine-readable output suitable for jq, Ansible, or monitoring pipelines:
# Paths of every world-writable file under /:
janitor --json audit / --world-writable | jq -r '.[].path'
# who-can returns an object { "read": [users], "write": [users], "exec": [users] };
# `.read` prints only the list of usernames who can read the target:
janitor --json who-can /etc/shadow | jq '.read'
# Count how many backups exist for a given subtree:
janitor --json ls -p /srv/app | jq 'length'All recipes assume sudo where needed. Every mutating command snapshots first,
so any of these can be undone with janitor undo or janitor restore <id>.
sudo janitor grant /srv/docs/secret.txt -u alice -r # grant read-only access to one file
sudo janitor grant /srv/project -u alice -r -w -R # grant read/write recursively
sudo janitor grant /srv/project -g devs -a rw -R # grant read/write to a group recursively
sudo janitor grant /srv/docs -u alice -x # grant traverse only on parent directory
sudo janitor -n grant /srv/project -u alice -rw -R # dry-run (show planned changes)
sudo janitor grant /srv/project -u alice -rw -R # apply changes
sudo janitor revoke /srv/project -u alice # revoke access granted by janitorsudo janitor chmod 644 /etc/nginx/nginx.conf # typical config file mode
sudo janitor chmod 755 /usr/local/bin/tool # executable file
sudo janitor chmod 4755 /usr/local/bin/suid-helper # setuid
sudo janitor chmod 2755 /srv/shared # setgid directory
sudo janitor chmod 1777 /srv/tmp # sticky directory
sudo janitor chmod 6755 /usr/local/bin/suid-sgid # setuid + setgid
sudo janitor chmod u+rwx,g=rx,o= /srv/private # symbolic mode example
sudo janitor chmod a+X /srv/project -R # recursive +X
sudo janitor chmod -F /etc/ssh/sshd_config /etc/ssh/sshd_config.bak # copy mode from referencesudo janitor chown alice /srv/alice # change owner only
sudo janitor chown alice:devs /srv/project -R # change owner and group recursively
sudo janitor chown :devs /srv/project # change group only
sudo janitor chown alice: /srv/alice # user and primary group
sudo janitor chown 1000:1000 /srv/legacy # numeric uid:gid
sudo janitor chown -F /etc/passwd /srv/new-file # copy owner/group from referencejanitor info /etc/shadow # show metadata and permissions
janitor info /etc/shadow -U alice # check effective access for user
janitor -j info /srv/project | jq # JSON outputjanitor audit / -W # world-writable paths
janitor audit / -r # world-readable paths
janitor audit / -x # world-executable paths
janitor audit / -s # setuid files
janitor audit / -S # setgid files
janitor audit / -t # sticky directories
janitor audit / -A # paths with POSIX ACLs
janitor audit / -m 777 # exact mode 0777
janitor audit / -o alice # owner = alice
janitor audit / -g devs # group = devs
janitor audit / --no-owner # unknown UID
janitor audit / --no-group # unknown GID
janitor audit /srv -W -s # combine filters (world-writable + setuid)
janitor --json audit / -W | jq -r '.[].path' # paths only (for scripts)janitor find-orphans /home # files with unknown UID/GID
janitor who-can /etc/shadow # users who can read/write/exec
janitor -j who-can /etc/shadow | jq '.write' # users with write access (JSON)sudo janitor acl grant /srv/project -u alice -rw -R # add user ACL recursively
sudo janitor acl grant /srv/project -g devs -rwx -d # add group default ACL
sudo janitor acl revoke /srv/project -u alice # remove user ACL
sudo janitor acl show /srv/project # show access and default ACL
sudo janitor acl strip /srv/project -R # remove all ACLs recursivelyjanitor tree /srv/project # show permission tree
janitor tree /srv/project -L 3 # limit depth to 3
janitor tree /srv/project -U alice # evaluate access for user
janitor tree /srv/project -A # show ACL markers
janitor tree /srv/project -H /srv/project/secret.txt # highlight one target path
janitor tree /srv/project -P # include parent chainjanitor preset list # show every preset with its mode
sudo janitor preset apply private ~/.gnupg -R # mode 700 recursively
sudo janitor preset apply private-dir ~/.config # mode 700 directory
sudo janitor preset apply private-file ~/.netrc # mode 600 file
sudo janitor preset apply group-shared /srv/team -R # mode 770 recursively
sudo janitor preset apply group-read /srv/docs -R # mode 750 recursively
sudo janitor preset apply public-read /srv/www -R # mode 755 recursively
sudo janitor preset apply public-file /var/www/index.html # mode 644 file
sudo janitor preset apply sticky-dir /srv/tmp # mode 1777 directory
sudo janitor preset apply setgid-dir /srv/project -R # mode 2775 recursively
sudo janitor preset apply secret /etc/secret.key # mode 400 file
sudo janitor preset apply secret-dir /root/secrets # mode 500 directory
sudo janitor preset apply exec-only /srv/drop # mode 711 directory
sudo janitor preset apply ssh-key ~/.ssh/id_ed25519 # mode 600 private key
sudo janitor preset apply ssh-dir ~/.ssh # mode 700 .ssh directory
sudo janitor preset apply config /etc/myapp.conf # mode 640 config file
sudo janitor preset apply log-file /var/log/myapp.log # mode 640 log file
sudo janitor preset apply systemd-unit /etc/systemd/system/myapp.service # mode 644 unit file
sudo janitor preset apply read-only /srv/release.tar.gz # mode 444 file
sudo janitor preset apply no-access /srv/quarantine -R # mode 000 recursively# Uniform tree: root:root 0700 everywhere, no ACLs written.
sudo janitor seal /srv/secrets -B root:root:700 -R
# Same baseline, but let alice read two specific files. Seal adds the
# minimal u:alice:--x ACL entries on every parent directory so she can
# actually reach them, and the final rwx entry on the leaf. One
# snapshot covers the whole operation.
sudo janitor seal /srv/secrets \
-B root:root:700 -R \
--allow alice:r /srv/secrets/company/.env \
--allow alice:rw /srv/secrets/shared/config.yamlWhy pinholes require ACLs. Once the baseline is
root:root 0700, no non-root user hasxon the parent directories, so plain chmod/ chown on a single leaf file can't grant access — the EACCES happens before the kernel ever looks at the leaf. The only POSIX-bit options would beo+xon every parent (breaks the uniform baseline for everyone) or chgrp'ing the whole chain to a group containing the pinhole user (same problem). A per-useru:alice:--xACL on each parent is the only surgical way to give exactly one user traversal while preserving700for everybody else. That's whysealwithout--allowis pure POSIX, andseal --allow ...requires a filesystem with ACL support.
sudo janitor copy-perms /etc/ssh/sshd_config /etc/ssh/sshd_config.bak # copy mode/owner/group from source
sudo janitor copy-perms /srv/template /srv/new -R # copy mode/owner/group recursively
sudo janitor copy-perms /srv/template /srv/new -R -A # copy mode/owner/group/ACL recursivelysudo janitor backup /srv/project -R # create manual snapshot (ACLs included)
sudo janitor list-backups # list snapshots (newest first)
sudo janitor list-backups -p /srv/project # list snapshots touching /srv/project
sudo janitor history /srv/project # show history for one path
sudo janitor -j history /srv/project | jq # history as JSON
sudo janitor diff <id> # preview restore changes
sudo janitor export <id> # export snapshot as text
sudo janitor -j export <id> # export snapshot as JSON
sudo janitor restore <id> # restore one snapshot
sudo janitor undo # restore most recent snapshot
sudo janitor prune-backups -k 20 # keep last 20 snapshotsjanitor completions bash | sudo tee /etc/bash_completion.d/janitor # bash completion (system-wide)
janitor completions zsh > ~/.zsh/completions/_janitor # zsh completion file
janitor completions fish > ~/.config/fish/completions/janitor.fish # fish completion file
janitor completions powershell > janitor.ps1 # PowerShell completion script
janitor completions elvish > janitor.elv # elvish completion fileEvery mutating command writes a MessagePack (.mpk) snapshot before it touches anything:
/var/lib/janitor/backups/ # when run as root, mode 0700
~/.local/share/janitor/backups/ # when run as a normal user
Each snapshot contains mode, uid, gid, symlink-ness, inode identity (dev/ino), and POSIX ACLs for every path it intends to modify (parents + target + recursive children). ACLs are always included by default; pass --no-acl to omit them.
Snapshotting is fail-closed: if a path cannot be stat'ed, or its ACL cannot be read on a filesystem that supports ACLs, the command aborts rather than mutating on top of a backup that cannot undo it. A missing acl package is not a failure — those entries are flagged as "not captured" and the run warns once.
Restore is atomic per path, and refuses any entry whose file type or inode no longer matches the snapshot, so a path swapped for a symlink or hard link since the backup cannot redirect the change to another file.
An ordinary editor save is write-then-rename, which also produces a new inode, so restore after editing a file reports it as replaced. Pass --allow-replaced when that is what happened; the file-type check still applies, so the symlink case stays blocked either way.
janitor list-backups # list available snapshots
janitor diff <id> # preview restore changes
janitor restore <id> # restore snapshot
janitor prune-backups --keep 50 # keep last 50 snapshotsBackups are never touched by restore itself, so you can re-apply or re-revert.
0.1.6 and 0.1.7 changed behaviour in ways an existing script can notice. Nothing here is a silent change — each one either errors out or produces a visibly different mode — but this is the list to check before rolling out.
Act on this one first (GHSA-xp8h-3m9f-w38h).
Every release before 0.1.7 that has seal (0.1.4, 0.1.5, 0.1.6) granted
each pinhole principal traversal across every pinhole's parent chain,
not just its own. If you sealed a tree with
more than one --allow, principals hold --x on branches they were never
meant to reach:
getfacl -R /your/sealed/base | grep -B3 ':--x' # look for foreign principals
janitor seal /your/sealed/base -B ... --allow ... # re-seal on 0.1.7 to clear| Change | What to check |
|---|---|
A symlink named directly as a chmod / chown operand is no longer dereferenced |
Scripts that ran janitor chown user path where path is a symlink now change the link, not its target. This matches what the README and man page always documented, and differs from coreutils. |
Symbolic modes with no who honour the umask |
janitor chmod +x f under umask 077 now gives 0700, not 0711. Write a+x to get the old, umask-ignoring behaviour. |
restore / undo fail on missing paths |
Add --skip-missing to restore the old silent-skip behaviour. |
restore / undo refuse entries whose inode changed |
An editor rewrite (write-then-rename) trips this. Add --allow-replaced; the file-type check still applies. |
audit / find-orphans fail on unreadable subtrees |
Add --best-effort to keep exit 0 on a partial scan. |
A getfacl failure aborts the command |
Only when the filesystem supports ACLs and the read fails anyway. A missing acl package is still not an error. |
Backups written by older versions still restore: they carry no inode identity, so those entries fall back to the file-type check alone.
- Parent directories receive only
--x(traverse); they are never made world-readable. - Symlinks are never followed for ownership or mode changes;
lchown(2)is used. This applies to a symlink named directly on the command line too, which is where janitor deliberately differs from coreutils (chmod/chowndereference their operands). restorerefuses entries whose file type or inode changed since the snapshot, so a swapped path cannot redirect a privileged metadata write.- Backup ids are validated before use, so
restorecannot be pointed at a payload outside the backup directory. - Scans that cannot read part of a tree exit non-zero instead of reporting no findings (
--best-effortopts out). - Granting access to a world-readable file emits a warning, since in that state the grant is purely advisory.
- The backup directory is
0700to prevent snapshot leakage. - An advisory
flockin the backup directory prevents concurrent mutations. SIGINTaborts with exit 130 and prints a recovery hint pointing atlist-backups.
janitor chmod and janitor chown accept the same argument syntax as the coreutils tools: octal (0644, 4755, 1777), full symbolic (u+s, g-w, a+X, +t), -R, and --reference. They are not wrappers around /bin/chmod or /bin/chown; they call chmod(2) and lchown(2) directly. The behavior is a strict superset:
- Snapshot-wrapped. Every invocation first records the prior mode, uid, gid (and optionally ACLs) to
<backup>/*.mpk. A singlejanitor undoreverts the whole call, including recursive walks and variadicPATH...lists. - Variadic plus stream input.
chmod 0644 a b c,chmod 0644 --from-file list.txt, andaudit ... --format paths0 | janitor chmod --stdin0 0644all produce one snapshot covering every path. -E/--exclude GLOBlets mass operations skip paths by full-path or basename match.- Lock-aware. If a path (or an ancestor) has been
janitor locked, the operation fails with a clear message instead of silently mutating protected state.
Scripts written against coreutils work unchanged; they just gain a backup and a working janitor undo. The one operational note: snapshots accumulate in the backup directory. Run janitor prune-backups --keep N periodically (or wire it into logrotate / systemd-tmpfiles) when using janitor for high-volume scripted operations.
janitor does not modify shell dotfiles, PAM config, sudoers, or SELinux/AppArmor contexts.
Requirements: Rust 1.85 or newer, a GNU toolchain, pkg-config.
git clone https://github.com/Tristram1337/janitor
cd janitor
cargo build --release
# Binary at target/release/janitor (glibc, ~3.4 MB stripped).For a fully static binary that runs on any Linux (RHEL 8+, Debian 12+, Ubuntu 22.04+, Alpine):
rustup target add x86_64-unknown-linux-musl
sudo apt install musl-tools # or: dnf install musl-gcc
cargo build --release --target x86_64-unknown-linux-musl
# Binary at target/x86_64-unknown-linux-musl/release/janitor (~3.6 MB, static-pie).Reproducible release profile (configured in Cargo.toml):
[profile.release]
lto = true
strip = true
codegen-units = 1The test suite consists of three layers:
# Unit tests (59 tests).
cargo test --release
# End-to-end smoke tests in Docker (263 assertions).
cargo build --release
docker build -f tests/Dockerfile -t janitor-test .
docker run --rm janitor-test
# Cross-distro tests (138 assertions, requires root on target host).
bash tests/cross-distro-test.sh
# Multi-host orchestrator (deploys + runs on N hosts via SSH).
bash tests/distro-test-orchestrator.shCI runs unit + Docker smoke tests on every push.
The v0.1.5 release passes 4,812 assertions across 12 distributions with zero failures:
| Distribution | Version | Kernel | glibc | Filesystem | SELinux | Smoke (263) | Cross-distro (138) |
|---|---|---|---|---|---|---|---|
| Rocky Linux | 8.8 | 4.18 | 2.28 | XFS | Enforcing | 263/0 | 138/0 |
| Rocky Linux | 9.2 | 5.14 | 2.34 | XFS | Enforcing | 263/0 | 138/0 |
| AlmaLinux | 8.10 | 4.18 | 2.28 | XFS | Enforcing | 263/0 | 138/0 |
| AlmaLinux | 9.7 | 5.14 | 2.34 | XFS | Enforcing | 263/0 | 138/0 |
| CentOS Stream | 9 | 5.14 | 2.34 | XFS | Enforcing | 263/0 | 138/0 |
| Fedora | 42 | 6.14 | 2.41 | Btrfs | Enforcing | 262/0 | 138/0 |
| Fedora | 43 | 6.17 | 2.41 | Btrfs | Enforcing | 262/0 | 138/0 |
| Debian | 12 (bookworm) | 6.1 | 2.36 | ext4 | — | 263/0 | 138/0 |
| Debian | 13 (trixie) | 6.12 | 2.40 | ext4 | — | 263/0 | 138/0 |
| Ubuntu | 22.04 LTS | 5.15 | 2.35 | ext4 | — | 263/0 | 138/0 |
| Ubuntu | 24.04 LTS | 6.8 | 2.39 | ext4 | — | 263/0 | 138/0 |
| Ubuntu | 25.10 | 6.17 | 2.42 | ext4 | — | 263/0 | 138/0 |
Filesystems tested: XFS, Btrfs, ext4, tmpfs. SELinux enforcing on 7 of 12 hosts with zero AVC denials. Kernel range: 4.18 (RHEL 8) through 6.17 (Fedora 43 / Ubuntu 25.10). glibc range: 2.28 through 2.42. Static musl binary used on all hosts.
Pull requests welcome. See CONTRIBUTING.md for development setup, coding conventions, and how to add a new subcommand.
Bug reports: please include janitor --version, kernel version, a minimal reproducer, and the output of janitor --json <cmd> if relevant.
MIT. Copyright (c) 2026 Tristram1337.