forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! Shared primitives every task script generator needs.
//!
//! Split out of the former single-file `task.rs` (Refs #390): the nested-shell
//! work pushed it past the 500-line health limit. Nothing here changed in the
//! move — the generated bytes are identical.

use crate::core::shell_escape::slugify_identifier;
use crate::core::types::Resource;

/// GH-254's verdict, emitted on STDERR from inside the generated script when
/// the command exited 0 and the re-asserted `completion_check` still said no.
///
/// A `const` because `core::executor::failure_text` classifies on it (Refs
/// #390). "The command failed" and "the command SUCCEEDED and its own check
/// still fails" are different diagnoses with different next actions, and forjar
/// printed both as `exit code 1:` — #390's reporter spent six llama.cpp builds
/// debugging a compiler error that had never happened. Producer and classifier
/// share this string so they cannot drift apart, and the text is byte-identical
/// to what shipped, so every existing `not-converged` substring assertion keeps
/// holding.
///
/// It contains no single quote, which is what keeps the `echo '...'` below safe
/// and what any future edit must preserve.
pub const NOT_CONVERGED_MARKER: &str =
    "task=not-converged: command exited 0 but completion_check still fails";

/// Slugified service id used in `/tmp/forjar-svc-<rid>.{pid,log}` paths.
///
/// FJ-154: the resource name flows into shared filesystem paths; slugify it to
/// `[A-Za-z0-9._-]` so it can never split a redirect target or inject shell.
pub(super) fn service_rid(resource: &Resource) -> String {
    slugify_identifier(resource.name.as_deref().unwrap_or("task"))
}

/// Extract absolute binary path from a command string.
///
/// Handles: `nohup /path/bin ...`, `sudo /path/bin`, `/path/bin --args`,
/// `LD_LIBRARY_PATH=/foo nohup /path/bin ...`.
pub(crate) fn extract_absolute_binary(cmd: &str) -> Option<&str> {
    for token in cmd.split_whitespace() {
        // Skip env var assignments
        if token.contains('=') && !token.starts_with('/') {
            continue;
        }
        // Skip shell builtins and prefixes
        if matches!(token, "nohup" | "sudo" | "bash" | "sh" | "env" | "exec") {
            continue;
        }
        if token.starts_with('/') {
            return Some(token);
        }
        break;
    }
    None
}

/// A heredoc delimiter that provably does not appear in `body`.
///
/// Refs #390-E. The generator used a fixed `FORJAR_TIMEOUT`, so a command
/// containing that word on a line of its own closed the heredoc EARLY and the
/// remaining lines ran in the OUTER shell. Reproduced against 1.24.0: the script
/// printed `FORJAR_TIMEOUT: command not found` and then executed the lines that
/// were supposed to be inside. That is the C8 delimiter-collision class
/// `shell_escape.rs` documents as fixed for `file` content and which was still
/// live for `task`.
///
/// Deterministic by construction — the same command always yields the same
/// delimiter — because `recipe-determinism-v1` requires the generated script to
/// be a pure function of the declaration. Extending rather than hashing keeps
/// the common case readable: a command that never mentions the word gets the
/// plain `FORJAR_TIMEOUT` it always had.
pub(crate) fn heredoc_delimiter(base: &str, body: &str) -> String {
    let mut delim = base.to_string();
    // A bare delimiter line is what terminates a heredoc, so only a line equal
    // to it can collide -- but checking substring containment is cheaper and
    // strictly safer, and costs only a longer delimiter in a rare case.
    while body.contains(&delim) {
        delim.push_str("_X");
    }
    delim
}

/// Run `command` under `timeout` without losing strictness or eating stdin.
///
/// Refs #390-E, and this one silently corrupted results. `timeout N bash <<'D'`
/// gave the nested shell NEITHER the outer `set -euo pipefail` NOR a free stdin:
///
///   * The inner shell exits with the status of its LAST line, so a failing
///     line was swallowed. Measured on the published 1.24.0: a task with
///     `timeout:` and a passing `completion_check` whose command began with
///     `false` reported `1 converged`, while the identical config WITHOUT
///     `timeout:` correctly failed. A wrong result reported as success is worse
///     than a failure.
///   * The nested bash's stdin IS the heredoc, so a command reading stdin
///     consumed the rest of its own script -- the FJ-2732 hole that
///     `transport::stdin_isolation` was written to close, re-opened one layer in.
///
/// Passing the script on fd 3 (`bash /dev/fd/3 3<<'D'`) leaves stdin as the
/// outer shell left it (`/dev/null`, per the transport wrapper) and re-asserting
/// `set -euo pipefail` inside restores the strictness the outer script declared.
pub(super) fn timeout_wrapped(command: &str, timeout_secs: u64) -> String {
    let d = heredoc_delimiter("FORJAR_TIMEOUT", command);
    format!("timeout {timeout_secs} bash /dev/fd/3 3<<'{d}'\nset -euo pipefail\n{command}\n{d}\n")
}