Skip to content

Commit 8f64cec

Browse files
committed
fix: address review findings across DSL, muxy, host and CLI
- ssh_spec: reject bare IPv6, require hex digit in literal - parser: Bash-correct double-quote escapes, share unit helpers via shellflow-text - plan: clarify ActiveState/EnabledState as supported subset - secrets: only chmod newly created identity dirs - host/credstore: elevated store existence check, own path buffer - muxy/conn: retired tombstone, chunked Lines, shutdown drop documented - muxy/session: WaitEnd with Disconnected propagation, guard redacted in Debug - main/ui/unit_cmd/preflight: scoped print_stderr expects, doc fixes - host/probe: scan via is_unit_file; privileged Failed maps to failed - host/backup/install: unified absolute-path validation, portable find, zeros doc - shellflow-text: extended unit suffixes; manifest uses shared validator - AGENTS.md: correct mutants exclusion
1 parent e8110c2 commit 8f64cec

63 files changed

Lines changed: 3729 additions & 1918 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.cargo/mutants.toml‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
# Mutation testing configuration for cargo-mutants.
22
#
3-
# The binary crates are exercised by `bin/shellflow/tests/integration.rs`,
4-
# which cargo-mutants does not run (it builds each crate's own test target
5-
# only). Their orchestration mutants therefore survive by construction, so
6-
# they are excluded and mutation stays focused on the library crates, where
7-
# the domain logic lives and is unit- and property-tested.
8-
exclude_globs = ["bin/shellflow/src/**", "examples/*/src/**"]
3+
# Scope: every crate with logic the unit/property tests can actually kill
4+
# mutants in. The previous configuration excluded **all** of
5+
# `bin/shellflow/src/**`, which hid 46 modules (parsing helpers, the manifest
6+
# reader, the masker's rendering, the helper exit ladder, …) from mutation
7+
# testing even though they carry unit tests. Only the modules whose behaviour
8+
# lives exclusively in the integration suite stay out.
9+
exclude_globs = [
10+
# The CLI dispatch itself: every branch ends in a subcommand call whose
11+
# effect is only observable end to end (`bin/shellflow/tests/integration.rs`).
12+
"bin/shellflow/src/main.rs",
13+
# Child-process supervision: mutants need a real spawned process tree, which
14+
# the integration harness provides and the unit target cannot.
15+
"bin/shellflow/src/executor/process.rs",
16+
# Demo targets, not shipped logic.
17+
"examples/*/src/**",
18+
]

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
push:
55
branches: [master, main]
66
pull_request:
7+
# Mutation testing runs here; see the `mutation` job.
8+
schedule:
9+
- cron: "0 4 * * *"
710

811
env:
912
CARGO_TERM_COLOR: always
@@ -21,6 +24,26 @@ jobs:
2124

2225
- uses: taiki-e/install-action@v2
2326
with:
24-
tool: just,cargo-shear,cargo-sort,typos-cli,rumdl
27+
tool: just,cargo-shear,cargo-sort,typos-cli,rumdl,cargo-fuzz
2528

29+
# `just ci` runs lint + test + build + fuzz-check + check-cn.
2630
- run: just ci
31+
32+
mutation:
33+
# Mutation testing is slow (it rebuilds the workspace per mutant), so it
34+
# runs nightly instead of on every push — but it does run, which the
35+
# AGENTS.md verification loop promises and CI did not previously deliver.
36+
if: github.event_name == 'schedule'
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v7
40+
41+
- uses: dtolnay/rust-toolchain@nightly
42+
with:
43+
components: rustfmt, clippy
44+
45+
- uses: taiki-e/install-action@v2
46+
with:
47+
tool: just,cargo-mutants
48+
49+
- run: just mutation

‎AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ just mutation
4040
- Edition 2024, resolver 3. Members are `crates/*`, `bin/*` and `examples/*`; the fuzz crates under `crates/*/fuzz` are detached workspaces and are excluded from the root workspace on purpose.
4141
- Because the fuzz crates are detached, `cargo fmt --all` skips them: `just format` formats their `fuzz_targets/*.rs` explicitly with `rustfmt --edition 2024`. Keep it that way when a fuzz target is added.
4242
- `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, `print_stdout`, `print_stderr` and `allow_attributes` are `deny`. The only escape hatch is `#[expect(lint, reason = "...")]`.
43+
- Test modules opt out of the test-only lints through one `#![cfg_attr(test, allow(...))]` per crate root (`panic`, `unwrap_used`, `expect_used`, `pedantic`, `nursery`); that is the only place a bare `allow` appears, and it never applies to production code.
44+
- `print_stdout` has **no** module-level exemption: every line the tool prints goes through `ui` (`Ui` on the run path, `ui::report!` for report subcommands). A `println!` in any other module fails the lint.
4345
- `.cargo/config.toml` sets `rustc-wrapper = "kache"`; clippy invocations must clear it (`RUSTC_WRAPPER=`) exactly as the Justfile does.
4446
- There is no `rust-toolchain.toml`: CI installs nightly, so use `cargo +nightly fmt` and `cargo +nightly clippy` exactly as the Justfile does.
45-
- `.cargo/mutants.toml` excludes `bin/shellflow/src/**` and `examples/*/src/**` because cargo-mutants only runs each crate's own test target; the orchestration logic is covered by `bin/shellflow/tests/integration.rs` instead.
47+
- `.cargo/mutants.toml` excludes only `bin/shellflow/src/main.rs`, `bin/shellflow/src/executor/process.rs`, and `examples/*/src/**`: every other `bin` module carries unit tests that kill mutants, so they stay in mutation scope. The orchestration paths that need a live process tree are covered by `bin/shellflow/tests/integration.rs` instead.
4648
- Never hand-type dependency versions. Shared crates go into root `[workspace.dependencies]` and members reference them with `workspace = true`.
4749

4850
## Dependency choices
@@ -61,7 +63,7 @@ just mutation
6163
- Directives are defined in `docs/design.md` section 4.1 (`shellflow-dsl` owns the scanner); add new ones to `shellflow-dsl` with a parse test, never ad hoc in the executors.
6264
- Multi-host fan-out goes through `JoinSet` plus a semaphore; never spawn one task per host without the bound.
6365
- The `dbus` feature (systemd unit state over D-Bus) is off by default so the standard build stays a single static binary; the CLI polls instead. Keep D-Bus code behind the feature.
64-
- Do not introduce `unsafe` anywhere; there is none today. If `std::env::set_var` is ever needed in tests, hold an `ENV_LOCK` and carry a `// SAFETY:` comment.
66+
- Do not introduce `unsafe` except where a libc-level API leaves no alternative, and then only with a `// SAFETY:` comment stating the invariant. Today there are 18 such blocks: `nix::sys::signal::signal` for the `systemd` helper's signal forwarding and for the CLI's test-only `set_var`/environment locks, plus `shellflow-secrets`' `O_NOFOLLOW` open and fchmod calls. If `std::env::set_var` is needed in tests, hold the crate's `ENV_LOCK` and carry the `// SAFETY:` comment.
6567
- `proptest` properties live next to the crate logic they exercise and run in the ordinary `cargo test` loop; the fuzz targets cover the hostile-input parsers (`fuzz_parse_script`, `fuzz_interpolate`, `fuzz_parse_ssh_spec`, `fuzz_parse_env_file`).
6668
- Directive parsing lives only in `shellflow-dsl`: never hand-roll a `strip_prefix("# @…")` scanner elsewhere (the whole-word rule, spec validation, and here-document awareness live in the grammar). A status/verify subcommand reads a playbook through `parse_script`.
67-
- The `run` path prints only through the `ui` module (masked, audited). Report subcommands that render terminal output directly declare it with a module-level `#[expect(clippy::print_stdout, …, reason = "…")]` in `main.rs`; a `println!` anywhere else fails the lint.
69+
- The `run` path prints only through the `ui` module (masked, audited); report subcommands print through the `ui::report!` / `ui::report_part!` macros, which route to the same sink. `main.rs` keeps one scoped `#[expect(clippy::print_stderr, …)]` for the fatal-error and interrupt banners, which must reach a possibly-broken stderr. A bare `println!` anywhere else fails the lint.

‎CHANGELOG.md‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,92 @@ All notable changes to this project are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
55
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
11+
- **The Muxy transport dropped the tail of every step.** `LineAssembler::finish`
12+
existed but was only called from tests, so the rows still on screen when a
13+
session ended were never flushed: a step whose output was shorter than one
14+
screen (the default is 50 rows) streamed nothing at all. The end of a session
15+
now flushes first, and cancellation collects the same flush before it drops
16+
the channel.
17+
- **An unterminated here-document silently truncated the plan.** Every later
18+
`# @` directive was swallowed as body text, producing a plan that looked
19+
valid and was missing steps. Parsing now fails with
20+
`line N: unterminated here-document `TAG`` pointing at the opening line.
21+
- **A helper timeout was classified as a script failure on the `@service` path**
22+
while `@wait_active` called it a timeout — two ladders, two answers. Both use
23+
`executor::classify_helper_exit`, so a slow host exits 5 (retry) and a broken
24+
deploy exits 4 (roll back) on every path.
25+
- **`--body 'hello world'` did not parse.** Seven directives tokenized their
26+
payload with `split_whitespace` while the other twenty-six were
27+
quote-aware. All 33 now share `split_words`/`split_flags`.
28+
- **`@assert foo banana` parsed and failed on the target.** `@assert` accepted
29+
any `[A-Za-z0-9-]` state while `@assert_enabled` enforced four legal values.
30+
Both now use closed enums (`ActiveState`, `EnabledState`).
31+
- **Blocking `systemd` verbs ran on a tokio worker.** `wait`, `reload`,
32+
`exec` and `watchdog` poll with `std::thread::sleep`; they are now dispatched
33+
through `spawn_blocking`.
34+
- **A UTF-8 BOM hid the first key of an env file**, so a store edited on
35+
Windows looked corrupt.
36+
- **The identity directory was created with the umask's permissions**; it is
37+
now `0700` like the key file it holds.
38+
- **An uninstalled signal handler was ignored** in `systemd exec`: a supervisor
39+
that cannot forward `SIGTERM` now fails instead of silently never stopping.
40+
- **A failed wait left the batch watchdog spinning** for the full timeout, and
41+
a probe's stdout reader was detached instead of aborted.
42+
43+
### Changed
44+
45+
- **`host.rs` is a module family, not a god module.** 1450 lines became a
46+
dispatch table (259 lines) plus `host/{probe,lifecycle,install,backup,
47+
credstore,privileged}.rs`, and `HostError` became an enum keyed on the reason
48+
(`Usage`/`Internal`/`Failed`/`TimedOut`/`Delegated`) instead of a
49+
`{ message, code }` pair. The eight hand-rolled `sudo` invocations now go
50+
through one constructor, so `sudo -n` cannot be forgotten at a new call site.
51+
- **The `muxy` feature no longer forks the executors' shape.**
52+
`executor::Native` carries the connection cache and plan index in every
53+
build, which removed 18 of the 31 `#[cfg(feature = "muxy")]` attributes from
54+
the step dispatch; what remains is the single transport decision and the
55+
attempt it guards.
56+
- **The target-side host verbs are tested.** `@service`'s policy (restart
57+
only a running unit, wait only for a unit that exists, skip `enable` for a
58+
generated unit), the digest gate, the credential-store agreement check and
59+
the leak comparison are now pure functions with unit tests, which is what
60+
mutation testing needed: `host.rs`, `host/lifecycle.rs` and
61+
`host/install.rs` are now at 0 surviving mutants.
62+
- **Every line the tool prints goes through `ui`.** Report subcommands used
63+
`println!` directly, which skipped the masker and needed a per-module
64+
`print_stdout` exemption; they now use `ui::report!` and the exemption is
65+
gone, so a bare `println!` outside `ui`/`main` fails the lint again.
66+
- **The `muxy` pump queue is bounded** (256 messages) with backpressure, so a
67+
slow consumer cannot grow memory without limit.
68+
- **Secrets no longer appear in `Debug` output**: `SecretMasker`, `StepParams`
69+
and `PumpMsg` hand-write `Debug` and show counts instead of values.
70+
- **`scp` takes its connection policy from `transport::SshPolicy`** instead of
71+
spelling `BatchMode`/`ConnectTimeout=15` itself.
72+
- **One `systemctl` invocation path** (`src/systemctl.rs`) and one unit-name
73+
rule set (`shellflow-text`) replace the copies in `host` and `systemd`.
74+
- **`--export` accepts `--public` in any position**; it was a trailing-suffix
75+
special case.
76+
- **Bracketed IPv6 destinations** (`deploy@[2001:db8::1]:2222`) parse and
77+
round-trip into `ssh`/`rsync` argv.
78+
79+
### Added
80+
81+
- `@assert` accepts the full `systemctl is-active` set: `active`, `reloading`,
82+
`inactive`, `failed`, `activating`, `deactivating`.
83+
- `just ci` runs `fuzz-check` and `check-cn`; a nightly CI job runs `just
84+
mutation`, and mutation testing now covers `bin/shellflow` except the two
85+
modules whose behaviour lives in the integration suite.
86+
87+
## [0.5.2] — 2026-10-26
88+
89+
Version-only release: the `0.5.0` index entry on crates.io is broken and cannot
90+
be published over, so `0.5.1`/`0.5.2` re-cut the same tree. The transport,
91+
exit-code and masking seams converged in this release (`e8110c2`).
92+
793
## [0.4.0] — 2026-10-22
894

995
The release that moves the deployment logic out of the playbooks and into the

‎Cargo.toml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = ["bin/*", "crates/*", "examples/*"]
77
exclude = [".github/", "crates/*/fuzz"]
88
resolver = "3"
99
[workspace.package]
10-
version = "0.5.1"
10+
version = "0.5.2"
1111
edition = "2024"
1212
license = "Apache-2.0"
1313
repository = "https://github.com/longcipher/shellflow"
@@ -29,11 +29,11 @@ muxy-protocol = { git = "https://github.com/muxy-app/muxy", version = "2.0.0-bet
2929
# `zig` and a network fetch. The transport tests therefore drive the real
3030
# protocol with an in-crate fake peer (see `crates/shellflow-muxy/src/harness.rs`).
3131
# local crates
32-
shellflow-core = { path = "crates/shellflow-core", version = "0.5.1" }
33-
shellflow-dsl = { path = "crates/shellflow-dsl", version = "0.5.1" }
34-
shellflow-muxy = { path = "crates/shellflow-muxy", version = "0.5.1" }
35-
shellflow-secrets = { path = "crates/shellflow-secrets", version = "0.5.1" }
36-
shellflow-text = { path = "crates/shellflow-text", version = "0.5.1" }
32+
shellflow-core = { path = "crates/shellflow-core", version = "0.5.2" }
33+
shellflow-dsl = { path = "crates/shellflow-dsl", version = "0.5.2" }
34+
shellflow-muxy = { path = "crates/shellflow-muxy", version = "0.5.2" }
35+
shellflow-secrets = { path = "crates/shellflow-secrets", version = "0.5.2" }
36+
shellflow-text = { path = "crates/shellflow-text", version = "0.5.2" }
3737

3838
# external crates
3939
age = "0.12.1"

‎Justfile‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ lint:
3131
test:
3232
cargo test --all-features
3333

34-
# .cargo/mutants.toml excludes the binary and example crates: cargo-mutants
35-
# only runs each crate's own test target.
34+
# .cargo/mutants.toml excludes only the modules whose behaviour lives in the
35+
# integration suite (`main.rs`, `executor/process.rs`) and the demo targets;
36+
# see the file for why.
3637
#
3738
# Run mutation tests with cargo-mutants
3839
mutation:
39-
cargo mutants
40+
cargo mutants --workspace
4041

4142
# Type-check every fuzz target (no execution)
4243
fuzz-check:
@@ -116,7 +117,13 @@ check-cn:
116117
echo "ok: no Chinese characters found"
117118
118119
# Full CI check
119-
ci: lint test build
120+
#
121+
# `fuzz-check` is part of the gate, not an optional extra: the fuzz crates are
122+
# detached workspaces, so neither `cargo check` nor `cargo test` at the root
123+
# compiles them, and a fuzz target broken by a parser change would otherwise
124+
# stay broken until someone ran it by hand. `check-cn` enforces AGENTS.md's
125+
# English-only rule.
126+
ci: lint test build fuzz-check check-cn
120127

121128
# ============================================================
122129
# Maintenance & Tools

‎README.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ deploy.sh` runs it unchanged, and your editor highlights it perfectly.
4848
`--check`/`--dry-run` stay on plain `bash -n -s`.
4949
- **Deployer-style pipeline** — `@local` build → `@copy` → `@remote` deploy,
5050
with concurrent multi-host fan-out (`JoinSet` + semaphore, `--parallel`
51-
defaults to all hosts).
51+
defaults to 16 hosts; `--parallel 0` means all).
5252
- **Detailed debugging** — `-v` / `-vv` / `-vvv`:
5353
- `-v`: plan preview + per-step timing.
5454
- `-vv`: exact `ssh`/`rsync`/`scp` argv, full payload previews, and
@@ -207,9 +207,17 @@ USAGE: shellflow [OPTIONS] [SCRIPT]
207207
shellflow <COMMAND>
208208
209209
COMMANDS:
210-
run Run a deploy script (default; `shellflow deploy.sh` still works)
211-
keys Manage age identities (generate, public)
212-
secret Encrypt, decrypt, and edit age-encrypted env files
210+
run Run a deploy script (default; `shellflow deploy.sh` still works)
211+
keys Manage age identities (generate, public)
212+
secret Encrypt, decrypt, and edit age-encrypted env files
213+
template Cross-check `${KEY}` templates against encrypted env files
214+
emit Print the embedded target-side helper assets
215+
cred-env Target-side credential helpers (run on the target host)
216+
systemd Target-side systemd helpers (wait, reload, status, watch, exec)
217+
host Target-side file/unit helpers (install, backup, assert, inventory)
218+
unit Render and check systemd units from a manifest
219+
service Service manifest workflows (check, diff, verify, status, init)
220+
audit Scan a repository or host for leaked configuration
213221
214222
ARGS:
215223
<SCRIPT> Deploy script path [default: deploy.sh]
@@ -234,6 +242,11 @@ OPTIONS:
234242
--allow-unfiltered-copy Accept the scp fallback dropping --delete/--exclude
235243
--local Run remote blocks/copies locally (debugging)
236244
--no-ssh-reuse Do not reuse SSH connections within a run
245+
--transport <MODE> auto (default) | muxy | ssh
246+
--no-muxy Alias for --transport ssh
247+
--muxy-keep Leave a cancelled step's muxy session running
248+
--muxy-cols <N> Muxy session width [default: 200]
249+
--muxy-rows <N> Muxy session height [default: 50]
237250
-h, --help Print help
238251
-V, --version Print version
239252
```

‎SKILL.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ shellflow service init <svc> [--host H]… [--pattern full|exec|no-secret|contai
310310
shellflow service check <svc> --host H [--journal-lines N] read-only audit
311311
of the deployed unit (argv/env
312312
leaks, wiring, journal shapes)
313+
shellflow service diff <svc> --host H diff the repository's unit/drop-ins
314+
against the deployed ones
315+
shellflow service manifest <svc> [--output PATH] print (or write) the
316+
rendered unit + drop-ins
317+
shellflow service verify <svc> repository-only consistency check:
318+
no host needed, reports wiring
319+
that cannot match a target
320+
shellflow service status [--host H]… one-line state per unit, bounded
321+
SSH fan-out
313322
shellflow audit [PATH] scan the repo for secret-shaped
314323
plaintext (pre-commit gate)
315324
```

‎bin/shellflow/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ sha2 = { workspace = true }
3737
shellflow-core = { workspace = true }
3838
shellflow-muxy = { workspace = true, optional = true }
3939
shellflow-secrets = { workspace = true }
40+
shellflow-text = { workspace = true }
4041
tokio = { workspace = true, features = ["full"] }
4142
toml_edit = { workspace = true }
4243
zbus = { workspace = true, optional = true }

‎bin/shellflow/src/audit.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,18 @@ pub(crate) fn audit(args: &AuditArgs) -> Result<()> {
4343
};
4444
for finding in scan_file(&content) {
4545
let rel = file.strip_prefix(root).unwrap_or(file);
46-
println!("FAIL {}:{}: {}", rel.display(), finding.line, finding.message);
46+
crate::ui::report!("FAIL {}:{}: {}", rel.display(), finding.line, finding.message);
4747
findings += 1;
4848
if findings >= 20 {
49-
println!("… stopping after 20 findings");
49+
crate::ui::report!("… stopping after 20 findings");
5050
bail!("audit: {findings}+ secret-shaped finding(s) under {}", root.display());
5151
}
5252
}
5353
}
5454
if findings > 0 {
5555
bail!("audit: {findings} secret-shaped finding(s) under {}", root.display());
5656
}
57-
println!("audit: {} file(s) clean", files.len());
57+
crate::ui::report!("audit: {} file(s) clean", files.len());
5858
Ok(())
5959
}
6060

0 commit comments

Comments
 (0)