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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! FJ-007: File/directory resource handler.
use crate::core::shell_escape::{sh_squote, sh_write_file};
use crate::core::types::Resource;
use crate::resources::verdict;
/// Read a local file, or describe why it could not be read.
fn read_source_file(path: &str) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
/// Generate shell to check file state.
pub fn check_script(resource: &Resource) -> String {
let path = resource.path.as_deref().unwrap_or("/dev/null");
let state = resource.state.as_deref().unwrap_or("file");
let p = sh_squote(path);
match state {
"directory" => verdict::single(
&format!("test -d {p}"),
"exists:directory",
"missing:directory",
),
// INVERTED: `absent` converges when the path is GONE, so the passing
// condition is the negation and `missing:` is the SUCCESS marker. This
// is why the verdict cannot be derived from the marker text at the
// codegen boundary — only the generator knows which way the resource
// points.
"absent" => verdict::single(
&format!("! test -e {p}"),
"missing:absent",
"exists:present",
),
"symlink" => verdict::single(&format!("test -L {p}"), "exists:symlink", "missing:symlink"),
"file" => verdict::single(&format!("test -f {p}"), "exists:file", "missing:file"),
// `other` is the config-derived state string; escape the label. An
// unrecognised state is not a pass — forjar cannot show the resource
// is converged, so it must say so.
other => verdict::check_script_from(&[verdict::always_diverged(&format!(
"unsupported file state: {other}"
))]),
}
}
/// Append chown/chmod lines for the given resource ownership and mode.
fn push_ownership_lines(lines: &mut Vec<String>, path: &str, resource: &Resource) {
let p = sh_squote(path);
// `group:` WITHOUT `owner:` USED TO BE SILENTLY IGNORED.
//
// `group` was only ever read inside the `owner` branch, so a resource
// declaring group alone emitted no ownership command at all — and the lock
// then recorded `group: <declared>` under `status: converged` while the file
// on disk kept whatever group it had. A declared attribute that is never
// applied and is nonetheless reported as converged is the worst shape a
// config tool has: the operator has written down an intent the system has
// quietly agreed to ignore. (forjar#310, confirmed 3/3 by the release gate.)
match (&resource.owner, &resource.group) {
(Some(owner), Some(group)) => lines.push(format!(
"chown {} {}",
sh_squote(&format!("{owner}:{group}")),
p
)),
(Some(owner), None) => lines.push(format!("chown {} {}", sh_squote(owner), p)),
// chgrp, not `chown :group` — chgrp says what is meant, and `chown`
// with a leading colon is a portability trap (BSD and GNU disagree
// about `:group` vs `.group`, and this fleet has a macOS host).
(None, Some(group)) => lines.push(format!("chgrp {} {}", sh_squote(group), p)),
(None, None) => {}
}
if let Some(ref mode) = resource.mode {
lines.push(format!("chmod {} {}", sh_squote(mode), p));
}
}
/// Generate the file-content write commands (source or inline content).
fn push_file_content_lines(lines: &mut Vec<String>, path: &str, resource: &Resource) {
if let Some(ref source) = resource.source {
match read_source_file(source) {
Ok(bytes) => {
lines.push(sh_write_file(path, &bytes));
}
Err(e) => {
// `e` embeds the config-derived source path; escape the whole
// message so a path with a quote can't break out of echo.
lines.push(format!(
"echo {}; exit 1",
sh_squote(&format!("ERROR: cannot read source file: {e}"))
));
}
}
} else if let Some(ref content) = resource.content {
// C8 (GH #296): inline content is DATA and must never reach the target's shell
// parser. It used to be interpolated into a `<<'FORJAR_EOF'` heredoc
// under a comment claiming the body was literal; a body is literal only
// until a line equals the delimiter, so content containing `FORJAR_EOF`
// closed the heredoc and executed the remainder as shell. `sh_write_file`
// has no delimiter to hit, and is byte-exact (a heredoc always appends a
// trailing newline the declared content may not have).
lines.push(sh_write_file(path, content.as_bytes()));
}
}
/// Generate shell to converge file to desired state.
pub fn apply_script(resource: &Resource) -> String {
let path = resource.path.as_deref().unwrap_or("/dev/null");
let state = resource.state.as_deref().unwrap_or("file");
let p = sh_squote(path);
let mut lines = vec!["set -euo pipefail".to_string()];
match state {
"directory" => {
lines.push(format!("mkdir -p {p}"));
push_ownership_lines(&mut lines, path, resource);
}
"absent" => {
lines.push(format!("rm -rf {p}"));
}
"symlink" => {
let target = resource.target.as_deref().unwrap_or("/dev/null");
lines.push(format!("ln -sfn {} {p}", sh_squote(target)));
}
"file" => {
if let Some(parent) = std::path::Path::new(path).parent() {
if parent != std::path::Path::new("/") {
lines.push(format!(
"mkdir -p {}",
sh_squote(&parent.display().to_string())
));
}
}
// A SYMLINK AT A MANAGED PATH IS DRIFT, NOT A WRITE TARGET.
//
// Without this, `>` and `chmod`/`chown` FOLLOW the link, so forjar
// writes the declared content and mode onto whatever the link points
// at — an arbitrary path, with forjar's privileges, which on this
// fleet is root. Measured: a managed path swapped for a symlink to
// `victim/important.conf` left that file reading
// `SECRET=managed-payload` with its mode changed 664 -> 600, while
// apply printed `1 converged`. `policy.deny_paths` does not stop it,
// because the path forjar was asked to write really is allowed.
//
// The dangling-symlink variant is worse: `>` CREATES the target, so
// it is a write primitive at any path the link names.
//
// Pre-#307 this could not be reached in the converged case — the old
// drift gate refused the apply outright, which protected the victim
// BY ACCIDENT. #307 removed that refusal and the latent defect became
// reachable. It is pre-existing either way. (forjar#310.)
//
// `state: file` DECLARES a regular file at this path. So converging
// means REPLACING the link, not following it. `state: symlink` is a
// separate arm above and is unaffected.
//
// `if` rather than `[ -L p ] && rm -f p` on purpose: under
// `set -euo pipefail` the `&&` form exits 1 for every non-symlink,
// i.e. the normal case, aborting the whole apply.
lines.push(format!("if [ -L {p} ]; then rm -f {p}; fi"));
push_file_content_lines(&mut lines, path, resource);
push_ownership_lines(&mut lines, path, resource);
}
other => {
// `other` is the config-derived state string; escape the label.
lines.push(format!(
"echo {}",
sh_squote(&format!("unsupported file state: {other}"))
));
}
}
lines.join("\n")
}
/// Generate shell to query file state (for hashing).
pub fn state_query_script(resource: &Resource) -> String {
let path = resource.path.as_deref().unwrap_or("/dev/null");
let p = sh_squote(path);
// SIZE IS REPORTED FOR FILES ONLY — NEVER FOR DIRECTORIES.
//
// This digest becomes `details.live_hash`, which drift compares against a
// fresh run of this same script. `stat`'s `size` for a DIRECTORY is the
// space its entry table occupies, and that grows as files are added
// inside: measured 4096 -> 12288 at 400 entries. Folding it in would make
// every managed directory permanently "drifted" the moment anything wrote
// into it — and once the apply gate consults drift, permanently
// un-appliable. A directory's identity under forjar is
// owner/group/mode/existence; how many files someone put inside it is not
// drift. (forjar#305; guarded by
// tests/falsification_apply_sees_the_target_file.rs::
// a_managed_directory_does_not_drift_when_its_contents_change.)
//
// For a regular file the size is redundant with the content hash below,
// but harmless and cheap, so it stays: it keeps the digest meaningful for
// a file whose hashing tool is unavailable on the target.
format!(
"if [ -e {p} ]; then\n\
if [ -d {p} ]; then\n\
stat -c 'owner=%U group=%G mode=%a' {p} 2>/dev/null || \
stat -f 'owner=%Su group=%Sg mode=%Lp' {p} 2>/dev/null\n\
else\n\
stat -c 'owner=%U group=%G mode=%a size=%s' {p} 2>/dev/null || \
stat -f 'owner=%Su group=%Sg mode=%Lp size=%z' {p} 2>/dev/null\n\
if [ -f {p} ]; then\n\
cat {p} | blake3sum 2>/dev/null || sha256sum {p} | cut -d' ' -f1\n\
fi\n\
fi\n\
else\n\
echo 'MISSING'\n\
fi"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{MachineTarget, ResourceType};
fn file_resource(path: &str) -> Resource {
Resource {
resource_type: ResourceType::File,
machine: MachineTarget::Single("m1".to_string()),
path: Some(path.to_string()),
..Default::default()
}
}
#[test]
fn fj154_file_path_with_quote_is_escaped() {
// Injection payload in path must be neutralized, not break out.
let mut r = file_resource("/etc/x';reboot;'");
r.state = Some("absent".to_string());
let script = apply_script(&r);
// The raw `;reboot;` is never left as bare shell — quote was escaped.
assert!(script.contains("'/etc/x'\"'\"';reboot;'\"'\"''"));
assert!(!script.contains("rm -rf '/etc/x';reboot"));
}
#[test]
fn fj154_owner_injection_neutralized() {
// Defect #14 canonical example: owner `x';reboot;'`.
let mut r = file_resource("/etc/foo");
r.state = Some("directory".to_string());
r.owner = Some("x';reboot;'".to_string());
let script = apply_script(&r);
assert!(script.contains("'x'\"'\"';reboot;'\"'\"''"));
// No bare `chown 'x';reboot` breakout.
assert!(!script.contains("chown 'x';reboot"));
}
#[test]
fn fj154_owner_group_mode_quoted() {
let mut r = file_resource("/etc/foo");
r.state = Some("directory".to_string());
r.owner = Some("noah".to_string());
r.group = Some("staff".to_string());
r.mode = Some("0644".to_string());
let script = apply_script(&r);
assert!(script.contains("chown 'noah:staff' '/etc/foo'"));
assert!(script.contains("chmod '0644' '/etc/foo'"));
}
#[test]
fn fj154_symlink_target_quoted() {
let mut r = file_resource("/link");
r.state = Some("symlink".to_string());
r.target = Some("/real/target".to_string());
let script = apply_script(&r);
assert!(script.contains("ln -sfn '/real/target' '/link'"));
}
#[test]
fn fj154_inline_content_path_quoted() {
let mut r = file_resource("/etc/conf");
r.state = Some("file".to_string());
r.content = Some("hello".to_string());
let script = apply_script(&r);
// The destination path is one quoted shell word...
assert!(script.contains("| base64 -d > '/etc/conf'"));
// ...and the declared content is what the script deploys there.
// C8 (GH #296): assert on the decoded payload, not on the script text — the
// old `script.contains("hello")` was equally true of a script whose
// heredoc had already closed and thrown the rest away.
assert_eq!(
crate::core::shell_escape::decode_written_file(&script, "/etc/conf"),
Some(b"hello".to_vec())
);
}
#[test]
fn cbc8_inline_content_cannot_close_a_heredoc() {
// The blocker, at the codegen boundary: content carrying the old fixed
// delimiter plus a command must appear NOWHERE as shell.
let mut r = file_resource("/etc/conf");
r.state = Some("file".to_string());
let payload = "ok\nFORJAR_EOF\nreboot\n";
r.content = Some(payload.to_string());
let script = apply_script(&r);
assert!(!script.contains("FORJAR_EOF"), "{script}");
assert!(!script.contains("reboot"), "{script}");
assert_eq!(
crate::core::shell_escape::decode_written_file(&script, "/etc/conf"),
Some(payload.as_bytes().to_vec())
);
}
#[test]
fn fj154_check_and_query_paths_quoted() {
let r = file_resource("/etc/foo");
assert!(check_script(&r).contains("test -f '/etc/foo'"));
assert!(state_query_script(&r).contains("[ -e '/etc/foo' ]"));
}
#[test]
fn fj165_source_read_error_message_injection_neutralized() {
// #165 (#161 sweep gap): when the source file can't be read, the error
// message embeds the config-derived source path. A path with command
// substitution must stay inside the single-quoted echo word.
let mut r = file_resource("/etc/conf");
r.state = Some("file".to_string());
// Nonexistent path (read fails) carrying an injection payload.
r.source = Some("/no/such$(touch /tmp/pwn)".to_string());
let script = apply_script(&r);
// The `$(` payload is inside a single-quoted echo word.
assert!(script.contains("echo 'ERROR: cannot read source file: /no/such$(touch /tmp/pwn)"));
assert!(script.contains("; exit 1"));
// No bare command substitution outside quotes.
assert!(!script.contains("echo ERROR"));
assert!(!script.contains(": /no/such' $(touch"));
}
#[test]
fn fj165_unsupported_state_label_injection_neutralized() {
// #165 (#161 sweep gap): the `other` arm echoes the config-derived
// state string raw — escape it in both check_script and apply_script.
let mut r = file_resource("/etc/foo");
r.state = Some("x$(touch /tmp/pwn)".to_string());
let check = check_script(&r);
let apply = apply_script(&r);
assert!(check.contains("echo 'unsupported file state: x$(touch /tmp/pwn)'"));
assert!(apply.contains("echo 'unsupported file state: x$(touch /tmp/pwn)'"));
// No bare (unquoted) label, and no break-out of the single-quoted word.
assert!(!check.contains("echo unsupported"));
assert!(!check.contains("' $(touch"));
}
}