use crate::core::shell_escape::{sh_squote, sh_write_file};
use crate::core::types::Resource;
use crate::resources::verdict;
fn read_source_file(path: &str) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
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",
),
"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 => verdict::check_script_from(&[verdict::always_diverged(&format!(
"unsupported file state: {other}"
))]),
}
}
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
}
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))
}
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()
})
}
fn push_ownership_lines(lines: &mut Vec<String>, path: &str, resource: &Resource) {
let p = sh_squote(path);
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)),
(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));
}
}
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) => {
lines.push(format!(
"echo {}; exit 1",
sh_squote(&format!("ERROR: cannot read source file: {e}"))
));
}
}
} else if let Some(ref content) = resource.content {
lines.push(sh_write_file(path, content.as_bytes()));
}
}
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}"));
}
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())
));
}
}
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 => {
lines.push(format!(
"echo {}",
sh_squote(&format!("unsupported file state: {other}"))
));
}
}
lines.join("\n")
}
pub fn state_query_script(resource: &Resource) -> String {
let path = resource.path.as_deref().unwrap_or("/dev/null");
let p = sh_squote(path);
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() {
let mut r = file_resource("/etc/x';reboot;'");
r.state = Some("absent".to_string());
let script = apply_script(&r);
assert!(script.contains("'/etc/x'\"'\"';reboot;'\"'\"''"));
assert!(!script.contains("rm -rf '/etc/x';reboot"));
}
#[test]
fn fj154_owner_injection_neutralized() {
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;'\"'\"''"));
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);
assert!(script.contains("| base64 -d > '/etc/conf.forjar-new'"));
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() {
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() {
let mut r = file_resource("/etc/conf");
r.state = Some("file".to_string());
r.source = Some("/no/such$(touch /tmp/pwn)".to_string());
let script = apply_script(&r);
assert!(script.contains("echo 'ERROR: cannot read source file: /no/such$(touch /tmp/pwn)"));
assert!(script.contains("; exit 1"));
assert!(!script.contains("echo ERROR"));
assert!(!script.contains(": /no/such' $(touch"));
}
#[test]
fn fj165_unsupported_state_label_injection_neutralized() {
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)'"));
assert!(!check.contains("echo unsupported"));
assert!(!check.contains("' $(touch"));
}
}