forjar 1.33.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! 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::check_script_from(&file_assertions(resource, &p)),
        // `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}"
        ))]),
    }
}

/// What `state: file` DECLARES, as assertions (forjar#600).
///
/// This was `test -f`: whether SOMETHING is at the path. `--refresh` ("re-run
/// check scripts, only re-apply what fails") then certified a file holding the
/// wrong bytes, through both of its doors — refresh seeding with no lock entry,
/// and a lock entry, where one run printed `drift: … content changed` and then
/// `0 converged`, leaving a fleet pin at the version the declaration had moved
/// off. The check now asks about every attribute the apply script writes:
/// content (sha256 of the declared bytes), mode, owner, group — each with its
/// own divergent marker, so a failure names what is stale.
///
/// Where the host has neither `sha256sum` nor macOS's `shasum`, the content
/// assertion fails: rewriting identical bytes is harmless, certifying stale
/// ones is the defect. A symbolic `mode:` (`u+x`) is not an absolute state and
/// is not asserted; a numeric owner/group is compared with uid/gid.
///
/// Each value is read into a variable and THEN tested. `[ "$(a || b)" = x ]`
/// is the same program, but bashrs' SC2107 reads the `||` inside the command
/// substitution as `[ a || b ]` and forjar's I8 gate refuses the script.
fn file_assertions(resource: &Resource, p: &str) -> Vec<String> {
    let mut out = vec![verdict::assert_that(
        &format!("test -f {p}"),
        "exists:file",
        "missing:file",
    )];
    match declared_bytes(resource) {
        Some(Ok(bytes)) => {
            let want = sha256_hex(&bytes);
            out.push(verdict::assert_that(
                &format!(
                    "forjar_sha=$( {{ sha256sum {p} 2>/dev/null || shasum -a 256 {p} 2>/dev/null; }} | cut -d' ' -f1)\n  [ \"$forjar_sha\" = '{want}' ]"
                ),
                "content:declared",
                "stale:content",
            ));
        }
        Some(Err(e)) => out.push(verdict::always_diverged(&format!("unreadable source: {e}"))),
        None => {}
    }
    if let Some(mode) = resource.mode.as_deref().and_then(octal_mode) {
        out.push(verdict::assert_that(
            &format!(
                "forjar_mode=$(stat -c %a {p} 2>/dev/null || stat -f %Lp {p} 2>/dev/null)\n  [ \"$forjar_mode\" = {} ]",
                sh_squote(&mode)
            ),
            "mode:declared",
            "stale:mode",
        ));
    }
    for (who, name, id, marker) in [
        (
            resource.owner.as_deref(),
            ("%U", "%Su"),
            ("%u", "%u"),
            "owner",
        ),
        (
            resource.group.as_deref(),
            ("%G", "%Sg"),
            ("%g", "%g"),
            "group",
        ),
    ] {
        if let Some(who) = who {
            let (gnu, bsd) = if who.bytes().all(|b| b.is_ascii_digit()) {
                id
            } else {
                name
            };
            out.push(verdict::assert_that(
                &format!(
                    "forjar_{marker}=$(stat -c {gnu} {p} 2>/dev/null || stat -f {bsd} {p} 2>/dev/null)\n  [ \"$forjar_{marker}\" = {} ]",
                    sh_squote(who)
                ),
                &format!("{marker}:declared"),
                &format!("stale:{marker}"),
            ));
        }
    }
    out
}

/// The bytes the apply script would write, or `None` when the resource
/// declares no content (existence is then the whole declaration).
fn declared_bytes(resource: &Resource) -> Option<Result<Vec<u8>, String>> {
    if let Some(ref source) = resource.source {
        Some(read_source_file(source))
    } else {
        resource.content.as_ref().map(|c| Ok(c.as_bytes().to_vec()))
    }
}

fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    format!("{:x}", Sha256::digest(bytes))
}

/// `0644` and `644` are both `644` to `stat %a`; a symbolic mode has no
/// absolute value to compare, so it is not asserted.
fn octal_mode(mode: &str) -> Option<String> {
    if mode.is_empty() || !mode.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
        return None;
    }
    let m = mode.trim_start_matches('0');
    Some(if m.is_empty() {
        "0".to_string()
    } else {
        m.to_string()
    })
}

/// 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()));
    }
}

/// Replace the file's content by RENAME, never by rewriting it in place.
///
/// `> path` truncates and rewrites the SAME inode. bash reads a script lazily,
/// so a shell parked on a child (a wrapper waiting on the command it wraps)
/// resumes, when the child exits, at its OLD byte offset in the NEW bytes and
/// executes whatever lines happen to sit there. Measured on lambda-labs: ten
/// live `~/.local/bin/cargo` wrappers parked on `setpriv … "$real"` at line 135
/// of a script forjar was about to replace (forjar#634). A rename swaps the
/// directory entry; every reader keeps the inode it opened.
///
/// The new file is staged beside the target (same filesystem, so `mv` is a
/// rename), seeded with `cp -p` of the old one so an attribute the resource
/// does not declare (an undeclared `mode:` on an executable) survives exactly
/// as an in-place write preserved it, and it gets its declared owner and mode
/// BEFORE the rename — otherwise the target exists for a moment at the umask
/// mode and an exec in that window fails. A stale or planted staging path is
/// removed first, so `>` never follows a symlink someone left there.
///
/// A DIRECTORY at the path is refused before anything is staged. `> dir` used
/// to fail with EISDIR; `mv -f staged dir` instead moves the file INTO the
/// directory and exits 0, so the apply would report converged over a path that
/// is still a directory. An unreadable source is refused before staging too,
/// so the failed apply leaves no `.forjar-new` behind.
fn push_atomic_replace_lines(lines: &mut Vec<String>, path: &str, resource: &Resource) {
    if let Some(ref source) = resource.source {
        if read_source_file(source).is_err() {
            push_file_content_lines(lines, path, resource);
            return;
        }
    }
    let staged = format!("{path}.forjar-new");
    let p = sh_squote(path);
    let s = sh_squote(&staged);
    lines.push(format!(
        "if [ -d {p} ]; then echo {}; exit 1; fi",
        sh_squote(&format!(
            "ERROR: {path} is a directory; state: file will not replace it"
        ))
    ));
    lines.push(format!("rm -f {s}"));
    lines.push(format!("if [ -f {p} ]; then cp -p {p} {s}; fi"));
    push_file_content_lines(lines, &staged, resource);
    push_ownership_lines(lines, &staged, resource);
    lines.push(format!("mv -f {s} {p}"));
}

/// 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"));
            if resource.source.is_some() || resource.content.is_some() {
                push_atomic_replace_lines(&mut lines, path, resource);
            } else {
                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.forjar-new'"));
        // ...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"));
    }
}