Lines
96.43 %
Functions
91.67 %
Branches
100 %
//! Guards `.forgejo/scripts/deploy.sh`'s Job rendering.
//!
//! That script writes a Kubernetes Job whose init container runs a shell script,
//! and it does so from an UNQUOTED `<<EOF` heredoc — so the outer shell expands
//! the inner script before kubectl ever sees it. Nothing in the text looks
//! dangerous while you read it, which is exactly the problem: a backtick in a
//! *comment* becomes a command, and `$1` becomes deploy.sh's own argument.
//! Both mistakes shipped. The first killed the deploy at the heredoc under
//! `set -u` before any Job existed, which is why there were no logs to read.
//! Reading the script cannot catch this; rendering it can.
//! So this renders it the way production does — with `kubectl` and `git` shims
//! on PATH — and checks what actually comes out.
#[cfg(test)]
mod tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask has a parent")
.to_path_buf()
}
fn write_shim(dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
fs::write(&path, body).expect("write shim");
let mut perms = fs::metadata(&path).expect("stat shim").permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("chmod shim");
/// Renders the deploy Job with shims and returns the captured manifest.
///
/// Each call gets its own directory: cargo runs a crate's tests in threads,
/// and a shared one had every test deleting the others' shims mid-run —
/// which looked exactly like the failure it is meant to detect.
fn render_job() -> String {
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQ: AtomicUsize = AtomicUsize::new(0);
let unique = format!(
"{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
);
let root = repo_root();
let script = root.join(".forgejo/scripts/deploy.sh");
assert!(
script.exists(),
"deploy.sh not found at {}",
script.display()
let tmp = std::env::temp_dir().join(format!("nomisync-deploy-render-{unique}"));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).expect("temp dir");
let captured = tmp.join("job.yaml");
// `kubectl apply -f -` captures; everything else is a no-op success.
write_shim(
&tmp,
"kubectl",
&format!(
"#!/usr/bin/env bash\nfor a in \"$@\"; do [ \"$a\" = \"-f\" ] && exec cat > {}; done\nexit 0\n",
captured.display()
),
// deploy.sh checks it is still master tip; answer with the sha it is given.
"git",
"#!/usr/bin/env bash\ncase \"$1\" in\n ls-remote) echo \"$SHA refs/heads/master\";;\nesac\nexit 0\n",
let path = format!(
"{}:{}",
tmp.display(),
std::env::var("PATH").unwrap_or_default()
// deploy.sh waits on the Job after applying, which the shims cannot
// satisfy; the render is what we are testing, so cap the wait.
let _ = Command::new("timeout")
.args(["25", "bash", &script.to_string_lossy()])
.env("PATH", path)
.env("REPO", "rayslava/nomisync")
.env("SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
.env("GIT_TOKEN", "token")
.env("GITHUB_RUN_NUMBER", "1")
.env("GITHUB_RUN_ID", "1")
.current_dir(&root)
.output()
.expect("run deploy.sh");
let rendered = fs::read_to_string(&captured).unwrap_or_else(|_| {
panic!(
"deploy.sh applied nothing — it died before rendering the Job. \
That is what an unescaped $ or a backtick in the heredoc does."
)
});
rendered
/// The init container's script, as kubectl would receive it.
fn prepare_script(job: &str) -> String {
let marker = " - |\n";
let start = job.rfind(marker).expect("a block scalar in the Job") + marker.len();
job[start..]
.lines()
.map(|line| line.strip_prefix(" ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n")
#[test]
fn deploy_job_renders_at_all() {
let job = render_job();
assert!(job.contains("kind: Job"), "not a Job manifest:\n{job}");
job.contains("name: kaniko"),
"the kaniko container is missing, so the heredoc was truncated"
fn the_outer_heredoc_does_not_eat_inner_dollars() {
// `print(m$1)` belongs to the CONTAINER's shell. Unescaped, deploy.sh's
// own shell expands it — to nothing, or fatally under set -u.
job.contains("print(m$1)"),
"$1 was expanded away by the outer heredoc; escape it as \\$1"
fn the_heredoc_source_contains_no_backticks() {
// Checked in the SOURCE, not the rendered output, because that is where
// the hazard lives: the outer shell EXECUTES a backtick and substitutes
// its output, so one in the source never reaches the rendered Job for an
// output check to notice. A backtick in a comment here silently runs a
// command on the runner and deletes the words around it.
let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
.expect("read deploy.sh");
let start = source
.find("kubectl apply -f - <<EOF")
.expect("the Job heredoc");
let region = &source[start..];
let end = region.find("\nEOF\n").map_or(region.len(), |i| i + 1);
let offenders: Vec<&str> = region[..end]
.filter(|line| line.contains('`'))
.collect();
offenders.is_empty(),
"backticks inside the unquoted <<EOF are command substitution, even in comments:\n{}",
offenders.join("\n")
fn the_prepare_script_is_valid_bash() {
let script = prepare_script(&job);
let dir = std::env::temp_dir().join(format!(
"nomisync-prepare-{}-{:?}",
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("prepare.sh");
std::fs::write(&path, &script).expect("write prepare");
let out = Command::new("bash")
.args(["-n", &path.to_string_lossy()])
.expect("bash -n");
let _ = std::fs::remove_dir_all(&dir);
out.status.success(),
"the rendered prepare script is not valid bash:\n{}",
String::from_utf8_lossy(&out.stderr)