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
//! Refs #390-E: a nested shell must not silently discard strictness, stdin, or
//! the rest of its own script.
//!
//! THE FLAW THIS CLOSES.
//!
//! `timeout:` and `sudo: true` wrapped the user's command in
//! `bash <<'FORJAR_TIMEOUT'` / `sudo bash <<'FORJAR_SUDO'`. Three things follow
//! from that shape, and the first is silent corruption:
//!
//! 1. The nested shell does not inherit the outer `set -euo pipefail`, and a
//! shell exits with the status of its LAST command. Measured on the PUBLISHED
//! 1.24.0 binary: a task with `timeout: 30`, a passing `completion_check`, and
//! a command beginning with `false` reported `1 converged, 0 failed` and exit
//! 0 — while the identical config with the `timeout:` line deleted correctly
//! failed with exit 1. Apply called a wrong result a success.
//!
//! 2. The nested bash's stdin IS the heredoc, so a command that reads stdin
//! consumes the remainder of its own script. That is FJ-2732 — the defect
//! `transport::stdin_isolation` was written to close — re-opened one layer in,
//! below the wrapper that closes it.
//!
//! 3. A fixed delimiter collides. A command containing `FORJAR_TIMEOUT` on its
//! own line closes the heredoc early and the remaining lines run in the OUTER
//! shell. Reproduced against 1.24.0: `FORJAR_TIMEOUT: command not found`
//! followed by the lines that were meant to be inside. For `sudo:` this is
//! worse than untidy — it runs unprivileged the commands an author asked to
//! run as root.
//!
//! WHAT THIS TEST MUST NOT BECOME. Asserting on the generated text alone would
//! pass against a script that still behaves wrongly, so the strictness and stdin
//! cases EXECUTE the generated script through a real shell and assert on what
//! happened to the filesystem.
use forjar::core::types::{MachineTarget, Resource, ResourceType};
use std::process::Command;
fn task(command: &str, timeout: Option<u64>) -> Resource {
Resource {
resource_type: ResourceType::Task,
machine: MachineTarget::Single("localhost".into()),
command: Some(command.to_string()),
timeout,
..Default::default()
}
}
/// Run a generated script the way the transport does: stdin closed.
fn run(script: &str) -> std::process::Output {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sh");
std::fs::write(&path, script).unwrap();
Command::new("bash")
.arg(&path)
.stdin(std::process::Stdio::null())
.output()
.expect("bash must run")
}
#[test]
fn a_failing_line_under_timeout_is_not_swallowed() {
// THE REGRESSION, and the reason this is the highest-severity item from
// #390: without the fix the script exits 0 and apply records Converged.
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("ran-after-failure");
let cmd = format!("false\ntouch {}\n", marker.display());
let script = forjar::core::codegen::apply_script(&task(&cmd, Some(30))).unwrap();
let out = run(&script);
assert!(
!out.status.success(),
"a failing line inside `timeout:` must fail the script.\n\
Exit 0 here is apply reporting a wrong result as success.\n--- script ---\n{script}"
);
assert!(
!marker.exists(),
"the line after `false` RAN — the nested shell did not inherit `set -e`"
);
}
#[test]
fn the_same_command_without_timeout_already_failed() {
// THE CONTROL. Without it the test above could pass because of something
// unrelated to `timeout:`. This is the behaviour `timeout:` diverged from.
let script = forjar::core::codegen::apply_script(&task("false\ntrue\n", None)).unwrap();
assert!(
!run(&script).status.success(),
"the un-wrapped path must already be strict; if this fails the premise is wrong"
);
}
#[test]
fn a_stdin_reader_under_timeout_does_not_eat_its_own_script() {
// FJ-2732, one layer in. `cat` consumed the rest of the heredoc, so the
// second line never ran and the script still exited 0.
let dir = tempfile::tempdir().unwrap();
let eaten = dir.path().join("eaten");
let second = dir.path().join("second");
let cmd = format!("cat > {}\ntouch {}\n", eaten.display(), second.display());
let script = forjar::core::codegen::apply_script(&task(&cmd, Some(30))).unwrap();
run(&script);
assert!(
second.exists(),
"the line after a stdin-reading command did not run — the nested shell's \
stdin was its own script.\n--- script ---\n{script}"
);
assert!(
std::fs::metadata(&eaten).map(|m| m.len()).unwrap_or(0) == 0,
"the stdin-reader captured script text, which means it read the heredoc"
);
}
#[test]
fn a_command_containing_the_delimiter_does_not_escape_the_heredoc() {
// C8 delimiter collision. A bare `FORJAR_TIMEOUT` line closed the heredoc
// and the rest executed in the OUTER shell.
let script = forjar::core::codegen::apply_script(&task(
"echo one\nFORJAR_TIMEOUT\necho two\n",
Some(30),
))
.unwrap();
// The chosen delimiter must not appear in the body it is supposed to bound.
let delim_line = script
.lines()
.find(|l| l.contains("3<<'"))
.expect("a heredoc must be emitted for a timeout task");
let delim = delim_line
.split("3<<'")
.nth(1)
.and_then(|r| r.split('\'').next())
.expect("delimiter must be parseable");
assert_ne!(
delim, "FORJAR_TIMEOUT",
"the delimiter collides with the command body — the heredoc closes early"
);
assert!(
script.contains(&format!("3<<'{delim}'")) && script.contains(&format!("\n{delim}\n")),
"delimiter must open and close the heredoc"
);
}
#[test]
fn the_sudo_wrapper_is_stdin_isolated_and_collision_free() {
let mut r = task("echo hi\n", None);
r.sudo = true;
let script = forjar::core::codegen::apply_script(&r).unwrap();
// PMAT-159: fd 3 was the first answer and sudo closed it before exec, so
// the script now crosses the boundary as a private temp file.
//
// Asserted as an ORDER, not as a bag of substrings. Two disconnected
// `contains` calls are satisfied by a script that creates the temp file
// AFTER handing its name to sudo, or that arms the cleanup before there is
// a name to clean up, or that runs `sudo bash` from INSIDE the heredoc —
// all of which are broken and all of which contain both fragments. The
// pipeline is one sequence, so the test compares byte offsets.
let at = |needle: &str| -> usize {
script.find(needle).unwrap_or_else(|| {
panic!("missing from the sudo wrapper: {needle}\n--- script ---\n{script}")
})
};
let mktemp = at("forjar_sudo_script=\"$(mktemp ");
let trap = at("trap 'rm -f \"$forjar_sudo_script\"' EXIT INT TERM");
let cat = at("cat >\"$forjar_sudo_script\" <<'");
assert!(
mktemp < trap && trap < cat,
"the wrapper must name the temp file, then arm its cleanup, then fill \
it — got mktemp@{mktemp} trap@{trap} cat@{cat}.\n--- script ---\n{script}"
);
// The delimiter is chosen to avoid collision, so read it out of the script
// rather than hard-coding `FORJAR_SUDO` — a hard-coded one would make the
// "after the heredoc closes" assertion vacuous on a colliding body.
let delim = script[cat..]
.split("<<'")
.nth(1)
.and_then(|r| r.split('\'').next())
.expect("the sudo heredoc's delimiter must be parseable");
let close = cat
+ script[cat..]
.find(&format!("\n{delim}\n"))
.expect("the sudo heredoc must close")
+ 1;
let sudo = at("sudo bash \"$forjar_sudo_script\"");
assert!(
sudo > close,
"`sudo bash \"$forjar_sudo_script\"` must run AFTER the heredoc's \
closing `{delim}` — inside it, it is script text, not a command; \
got sudo@{sudo} close@{close}.\n--- script ---\n{script}"
);
let mut collide = task("echo hi\nFORJAR_SUDO\necho bye\n", None);
collide.sudo = true;
let s2 = forjar::core::codegen::apply_script(&collide).unwrap();
assert!(
!s2.contains("<<'FORJAR_SUDO'"),
"a script mentioning FORJAR_SUDO must not use it as its own delimiter — \
for a sudo: resource that runs the remainder UNPRIVILEGED"
);
}
#[test]
fn the_delimiter_is_deterministic() {
// recipe-determinism-v1: the generated script is a pure function of the
// declaration, so the collision-avoiding delimiter must be too.
let r = task("echo x\nFORJAR_TIMEOUT\n", Some(5));
let a = forjar::core::codegen::apply_script(&r).unwrap();
let b = forjar::core::codegen::apply_script(&r).unwrap();
assert_eq!(a, b, "delimiter selection must be deterministic");
}