1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
//! FJ-036: Shell purification pipeline — bashrs integration.
//!
//! Invariant I8: No raw shell execution — all shell is bashrs-purified.
//!
//! Three levels of shell safety:
//! - `validate_script()` — lint-based validation, errors only (warnings pass)
//! - `lint_script()` — full linter pass, returns all diagnostics
//! - `purify_script()` — parse → purify AST → reformat (strongest guarantee)
use bashrs::bash_parser::BashParser;
use bashrs::bash_quality::Formatter;
use bashrs::bash_transpiler::{PurificationOptions, Purifier};
use bashrs::linter::{lint_shell, Diagnostic, LintResult, Severity};
use super::purifier_sec017::{sec017_is_path_only, world_writable_modes};
/// forjar's own code for a chmod whose declared mode grants world write.
/// Not a bashrs code: bashrs has no finding for this shape (see
/// [`super::purifier_sec017`], measurement row 5).
const WORLD_WRITABLE_CODE: &str = "FJ-CHMOD-WW";
/// The source line a diagnostic points at, if the span is inside the script.
///
/// Spans are 1-indexed. The script here is the one the caller linted — for the
/// I8 gate that is `strip_data_payloads`' output, which is the text bashrs
/// judged and the text `numbered_for_diagnosis` prints, so the line numbers
/// agree with the diagnostic the operator sees.
fn source_line<'a>(lines: &[&'a str], one_indexed: usize) -> Option<&'a str> {
one_indexed
.checked_sub(1)
.and_then(|i| lines.get(i))
.copied()
}
/// True where a SEC017 Error is a hit on the PATH rather than on the mode.
///
/// The judgement is bashrs's own, made again with the path text redacted
/// ([`sec017_is_path_only`]), never by matching on the message text: the
/// message says "chmod 666" for `chmod '0644' '/opt/app666/t'`, which is
/// precisely the text that is wrong.
fn is_path_false_positive(lines: &[&str], diag: &Diagnostic) -> bool {
if diag.code != "SEC017" {
return false;
}
match source_line(lines, diag.span.start_line) {
Some(line) => sec017_is_path_only(line),
None => false,
}
}
/// forjar's own findings: every world-writable mode written on any line.
///
/// This is the half of the trade that makes PMAT-204 a change of instrument
/// rather than a loosened gate. bashrs SEC017 produces NO finding for
/// `chmod '0666' '/tmp/plain/t'` — its digit-boundary check refuses to see
/// `666` behind the leading `0` — so the one shape forjar generates for a
/// world-writable mode was invisible to the gate. It is not invisible now.
fn world_writable_chmod_errors(lines: &[&str]) -> Vec<String> {
lines
.iter()
.enumerate()
.flat_map(|(i, line)| {
world_writable_modes(line).into_iter().map(move |mode| {
format!(
"[error] {WORLD_WRITABLE_CODE}: line {}: chmod mode '{mode}' is world-writable \
(the o+w bit is set) — every user on the target could rewrite this path",
i + 1
)
})
})
.collect()
}
/// Validate a shell script via bashrs linter.
///
/// Fails only on Error-severity diagnostics. Warnings are acceptable
/// in generated scripts (e.g., SC2162 for `read` without `-r`).
pub fn validate_script(script: &str) -> Result<(), String> {
let result = lint_shell(script);
let lines: Vec<&str> = script.lines().collect();
// SC1xxx (SYNTAX) rules were excluded here for a long time, with the note:
// "bashrs has false positives on generated scripts (SC1035 on `in` in quoted
// strings, SC1020 on `]` in heredocs)". Both of those were real, and both
// are the quote-blindness class fixed upstream — SC1035/SC1020 by bashrs
// 6.67.0 (GH-226, which resolves quoting once and hands the shell-syntax
// rules a source where literal text is inert filler), SC1028/SC1078 by
// 6.68.0 (paiml/bashrs#243, #245).
//
// Excluding them cost more than it saved. SC1xxx is the SYNTAX-error family,
// which is precisely what a gate over GENERATED shell most wants to catch —
// forjar was blind to malformed output of its own making. The SC2135/SC2136
// pair in #281 was caught only because it happened to land in SC2*; the same
// defect under an SC1 code would have shipped silently.
//
// Measured before removing (forjar#285), against every resource this fleet
// declares: 437 resources x 3 phases = 1,311 generated scripts, emitted with
// `forjar codegen` from all nine machine YAMLs and linted at Error severity.
// SC1* findings: ZERO. That sweep did NOT apply `strip_data_payloads`, so it
// is stricter than this call site, which lints the sanitised script.
//
// PMAT-204: SEC017 is the one rule whose verdict forjar re-derives, because
// it decides from the LITERAL TEXT of the whole chmod line and forjar puts
// a config-supplied PATH on that line. `chmod '0644' '/opt/app666/config'`
// was refused as world-writable; the file could not be written at all.
// Measured 2026-09-07 against the pinned bashrs (6.68.0):
//
// chmod '0644' '/tmp/.tmp5k666T/target.txt' ERROR false — 666 in PATH
// chmod '0644' '/opt/app666/t' ERROR false positive
// chmod '0644' '/tmp/x777y/target.txt' ERROR false positive
// chmod '0644' '/tmp/plain/target.txt' clean
// chmod '0666' '/tmp/plain/t' NO FINDING AT ALL
// chmod 666 /tmp/real ERROR true positive
//
// Row 5 is why this is a change of INSTRUMENT, not a reduction: SEC017's
// digit-boundary check cannot see `666` inside `'0666'`, so the shape
// forjar generates for a genuinely world-writable mode was the one shape
// the gate never caught. Reading the mode argument fixes both directions —
// the exemption below drops rows 1-3, and `world_writable_chmod_errors`
// adds row 5 under forjar's own code. Row 6, the true positive, is
// untouched: a bare numeric mode is never exempted.
//
// Nothing else changes. Every other Error-severity diagnostic, including
// SEC017 on a line this cannot parse, still refuses the script. To falsify:
// see `super::purifier_sec017`.
let mut msgs: Vec<String> = result
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.filter(|d| !is_path_false_positive(&lines, d))
.map(|d| format!("[{}] {}: {}", d.severity, d.code, d.message))
.collect();
msgs.extend(world_writable_chmod_errors(&lines));
if msgs.is_empty() {
Ok(())
} else {
Err(format!("bashrs lint errors:\n{}", msgs.join("\n")))
}
}
/// Lint a shell script and return the full diagnostic result.
pub fn lint_script(script: &str) -> LintResult {
lint_shell(script)
}
/// Count lint errors (severity == Error) in a script.
pub fn lint_error_count(script: &str) -> usize {
let result = lint_shell(script);
result
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count()
}
/// Validate first, falling back to full purification if validation fails.
///
/// This is the recommended entry point for scripts that might need fixing:
/// - If `validate_script()` passes, return the script as-is (fast path)
/// - If validation fails, attempt `purify_script()` to fix it
/// - If purification also fails, return the error
pub fn validate_or_purify(script: &str) -> Result<String, String> {
if validate_script(script).is_ok() {
return Ok(script.to_string());
}
purify_script(script)
}
/// Purify a shell script through the full bashrs pipeline.
///
/// Parse → purify AST → format back to shell → validate.
/// Returns the purified script or an error if any stage fails.
pub fn purify_script(script: &str) -> Result<String, String> {
// Parse shell to AST
let mut parser = BashParser::new(script).map_err(|e| format!("bashrs parse: {e}"))?;
let ast = parser.parse().map_err(|e| format!("bashrs parse: {e}"))?;
// Purify AST (injection prevention, proper quoting, determinism)
let options = PurificationOptions::default();
let mut purifier = Purifier::new(options);
let purified_ast = purifier
.purify(&ast)
.map_err(|e| format!("bashrs purify: {e}"))?;
// Format purified AST back to shell code
let formatter = Formatter::new();
let purified = formatter
.format(&purified_ast)
.map_err(|e| format!("bashrs format: {e}"))?;
// Final validation pass (errors only)
validate_script(&purified)?;
Ok(purified)
}
#[cfg(test)]
mod sc1_gate_tests {
use super::*;
/// The SC1xxx family must be LIVE, not filtered away.
///
/// forjar#285: this gate excluded every `SC1*` finding for a long time,
/// which made it blind to the syntax-error family — precisely what a gate
/// over GENERATED shell most wants to catch. A test that only asserted the
/// good cases pass would go green with the family switched back off, so
/// this one requires a real syntax error to be REJECTED.
#[test]
fn a_real_syntax_error_is_rejected() {
// Unterminated double-quoted string: SC1078, an SC1* code.
let broken = "echo \"this string never closes\n";
let err = validate_script(broken).expect_err(
"a script with an unterminated string was accepted — the SC1 family is filtered again",
);
assert!(
err.contains("SC1"),
"rejected, but not by an SC1 rule: {err}"
);
}
/// Guard the guard: the shapes that JUSTIFIED the exclusion must still pass,
/// or re-enabling the family trades a blind spot for a false positive that
/// aborts `forjar apply` fleet-wide.
///
/// Both are named in the original comment: SC1035 on `in` inside a quoted
/// string, SC1020 on `]` inside a heredoc. Both are the quote-blindness
/// class fixed by bashrs 6.67.0 (GH-226) and 6.68.0.
#[test]
fn the_false_positives_that_justified_the_exclusion_are_gone() {
let quoted_in = "grep \"^Diff in\" \"$f\"\necho \"select a in b\"\n";
assert!(
validate_script(quoted_in).is_ok(),
"SC1035-shape false positive is back: `in` inside a quoted string"
);
let bracket_in_heredoc =
"cat > /tmp/x <<'PAYLOAD'\nsome text with ] a bracket\nand [ 0-9 ] regex-ish\nPAYLOAD\n";
assert!(
validate_script(bracket_in_heredoc).is_ok(),
"SC1020-shape false positive is back: `]` inside a heredoc"
);
}
/// The generated shape that #281 was about must still pass — a folded YAML
/// scalar inlined into a check script.
#[test]
fn a_folded_condition_check_script_still_passes() {
let script = crate::resources::verdict::single(
"sh -c 'for u in a b; do test -n \"$u\"; done; exit 0'",
"forjar=converged",
"forjar=diverged",
);
assert!(
validate_script(&script).is_ok(),
"forjar generates a check script its own gate rejects"
);
}
}