Skip to main content

xtask/
deploy_render.rs

1//! Guards `.forgejo/scripts/deploy.sh`'s Job rendering.
2//!
3//! That script writes a Kubernetes Job whose init container runs a shell script,
4//! and it does so from an UNQUOTED `<<EOF` heredoc — so the outer shell expands
5//! the inner script before kubectl ever sees it. Nothing in the text looks
6//! dangerous while you read it, which is exactly the problem: a backtick in a
7//! *comment* becomes a command, and `$1` becomes deploy.sh's own argument.
8//!
9//! Both mistakes shipped. The first killed the deploy at the heredoc under
10//! `set -u` before any Job existed, which is why there were no logs to read.
11//! Reading the script cannot catch this; rendering it can.
12//!
13//! So this renders it the way production does — with `kubectl` and `git` shims
14//! on PATH — and checks what actually comes out.
15
16#[cfg(test)]
17mod tests {
18    use std::fs;
19    use std::os::unix::fs::PermissionsExt;
20    use std::path::{Path, PathBuf};
21    use std::process::Command;
22
23    fn repo_root() -> PathBuf {
24        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
25            .parent()
26            .expect("xtask has a parent")
27            .to_path_buf()
28    }
29
30    fn write_shim(dir: &Path, name: &str, body: &str) {
31        let path = dir.join(name);
32        fs::write(&path, body).expect("write shim");
33        let mut perms = fs::metadata(&path).expect("stat shim").permissions();
34        perms.set_mode(0o755);
35        fs::set_permissions(&path, perms).expect("chmod shim");
36    }
37
38    /// Renders the deploy Job with shims and returns the captured manifest.
39    ///
40    /// Each call gets its own directory: cargo runs a crate's tests in threads,
41    /// and a shared one had every test deleting the others' shims mid-run —
42    /// which looked exactly like the failure it is meant to detect.
43    fn render_job() -> String {
44        use std::sync::atomic::{AtomicUsize, Ordering};
45        static SEQ: AtomicUsize = AtomicUsize::new(0);
46        let unique = format!(
47            "{}-{}",
48            std::process::id(),
49            SEQ.fetch_add(1, Ordering::Relaxed)
50        );
51        let root = repo_root();
52        let script = root.join(".forgejo/scripts/deploy.sh");
53        assert!(
54            script.exists(),
55            "deploy.sh not found at {}",
56            script.display()
57        );
58
59        let tmp = std::env::temp_dir().join(format!("nomisync-deploy-render-{unique}"));
60        let _ = fs::remove_dir_all(&tmp);
61        fs::create_dir_all(&tmp).expect("temp dir");
62        let captured = tmp.join("job.yaml");
63
64        // `kubectl apply -f -` captures; everything else is a no-op success.
65        write_shim(
66            &tmp,
67            "kubectl",
68            &format!(
69                "#!/usr/bin/env bash\nfor a in \"$@\"; do [ \"$a\" = \"-f\" ] && exec cat > {}; done\nexit 0\n",
70                captured.display()
71            ),
72        );
73        // deploy.sh checks it is still master tip; answer with the sha it is given.
74        write_shim(
75            &tmp,
76            "git",
77            "#!/usr/bin/env bash\ncase \"$1\" in\n  ls-remote) echo \"$SHA refs/heads/master\";;\nesac\nexit 0\n",
78        );
79
80        let path = format!(
81            "{}:{}",
82            tmp.display(),
83            std::env::var("PATH").unwrap_or_default()
84        );
85        // deploy.sh waits on the Job after applying, which the shims cannot
86        // satisfy; the render is what we are testing, so cap the wait.
87        let _ = Command::new("timeout")
88            .args(["25", "bash", &script.to_string_lossy()])
89            .env("PATH", path)
90            .env("REPO", "rayslava/nomisync")
91            .env("SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
92            .env("GIT_TOKEN", "token")
93            .env("GITHUB_RUN_NUMBER", "1")
94            .env("GITHUB_RUN_ID", "1")
95            .current_dir(&root)
96            .output()
97            .expect("run deploy.sh");
98
99        let rendered = fs::read_to_string(&captured).unwrap_or_else(|_| {
100            panic!(
101                "deploy.sh applied nothing — it died before rendering the Job. \
102                 That is what an unescaped $ or a backtick in the heredoc does."
103            )
104        });
105        let _ = fs::remove_dir_all(&tmp);
106        rendered
107    }
108
109    /// The init container's script, as kubectl would receive it.
110    fn prepare_script(job: &str) -> String {
111        let marker = "        - |\n";
112        let start = job.rfind(marker).expect("a block scalar in the Job") + marker.len();
113        job[start..]
114            .lines()
115            .map(|line| line.strip_prefix("          ").unwrap_or(line))
116            .collect::<Vec<_>>()
117            .join("\n")
118    }
119
120    #[test]
121    fn deploy_job_renders_at_all() {
122        let job = render_job();
123        assert!(job.contains("kind: Job"), "not a Job manifest:\n{job}");
124        assert!(
125            job.contains("name: kaniko"),
126            "the kaniko container is missing, so the heredoc was truncated"
127        );
128    }
129
130    #[test]
131    fn the_outer_heredoc_does_not_eat_inner_dollars() {
132        // `print(m$1)` belongs to the CONTAINER's shell. Unescaped, deploy.sh's
133        // own shell expands it — to nothing, or fatally under set -u.
134        let job = render_job();
135        assert!(
136            job.contains("print(m$1)"),
137            "$1 was expanded away by the outer heredoc; escape it as \\$1"
138        );
139    }
140
141    #[test]
142    fn the_heredoc_source_contains_no_backticks() {
143        // Checked in the SOURCE, not the rendered output, because that is where
144        // the hazard lives: the outer shell EXECUTES a backtick and substitutes
145        // its output, so one in the source never reaches the rendered Job for an
146        // output check to notice. A backtick in a comment here silently runs a
147        // command on the runner and deletes the words around it.
148        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
149            .expect("read deploy.sh");
150        let start = source
151            .find("kubectl apply -f - <<EOF")
152            .expect("the Job heredoc");
153        let region = &source[start..];
154        let end = region.find("\nEOF\n").map_or(region.len(), |i| i + 1);
155
156        let offenders: Vec<&str> = region[..end]
157            .lines()
158            .filter(|line| line.contains('`'))
159            .collect();
160        assert!(
161            offenders.is_empty(),
162            "backticks inside the unquoted <<EOF are command substitution, even in comments:\n{}",
163            offenders.join("\n")
164        );
165    }
166
167    #[test]
168    fn the_prepare_script_is_valid_bash() {
169        let job = render_job();
170        let script = prepare_script(&job);
171        let dir = std::env::temp_dir().join(format!(
172            "nomisync-prepare-{}-{:?}",
173            std::process::id(),
174            std::thread::current().id()
175        ));
176        std::fs::create_dir_all(&dir).expect("temp dir");
177        let path = dir.join("prepare.sh");
178        std::fs::write(&path, &script).expect("write prepare");
179
180        let out = Command::new("bash")
181            .args(["-n", &path.to_string_lossy()])
182            .output()
183            .expect("bash -n");
184        let _ = std::fs::remove_dir_all(&dir);
185
186        assert!(
187            out.status.success(),
188            "the rendered prepare script is not valid bash:\n{}",
189            String::from_utf8_lossy(&out.stderr)
190        );
191    }
192}