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)]
17
mod 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
4
    fn repo_root() -> PathBuf {
24
4
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
25
4
            .parent()
26
4
            .expect("xtask has a parent")
27
4
            .to_path_buf()
28
4
    }
29

            
30
6
    fn write_shim(dir: &Path, name: &str, body: &str) {
31
6
        let path = dir.join(name);
32
6
        fs::write(&path, body).expect("write shim");
33
6
        let mut perms = fs::metadata(&path).expect("stat shim").permissions();
34
6
        perms.set_mode(0o755);
35
6
        fs::set_permissions(&path, perms).expect("chmod shim");
36
6
    }
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
3
    fn render_job() -> String {
44
        use std::sync::atomic::{AtomicUsize, Ordering};
45
        static SEQ: AtomicUsize = AtomicUsize::new(0);
46
3
        let unique = format!(
47
            "{}-{}",
48
3
            std::process::id(),
49
3
            SEQ.fetch_add(1, Ordering::Relaxed)
50
        );
51
3
        let root = repo_root();
52
3
        let script = root.join(".forgejo/scripts/deploy.sh");
53
3
        assert!(
54
3
            script.exists(),
55
            "deploy.sh not found at {}",
56
            script.display()
57
        );
58

            
59
3
        let tmp = std::env::temp_dir().join(format!("nomisync-deploy-render-{unique}"));
60
3
        let _ = fs::remove_dir_all(&tmp);
61
3
        fs::create_dir_all(&tmp).expect("temp dir");
62
3
        let captured = tmp.join("job.yaml");
63

            
64
        // `kubectl apply -f -` captures; everything else is a no-op success.
65
3
        write_shim(
66
3
            &tmp,
67
3
            "kubectl",
68
3
            &format!(
69
3
                "#!/usr/bin/env bash\nfor a in \"$@\"; do [ \"$a\" = \"-f\" ] && exec cat > {}; done\nexit 0\n",
70
3
                captured.display()
71
3
            ),
72
        );
73
        // deploy.sh checks it is still master tip; answer with the sha it is given.
74
3
        write_shim(
75
3
            &tmp,
76
3
            "git",
77
3
            "#!/usr/bin/env bash\ncase \"$1\" in\n  ls-remote) echo \"$SHA refs/heads/master\";;\nesac\nexit 0\n",
78
        );
79

            
80
3
        let path = format!(
81
            "{}:{}",
82
3
            tmp.display(),
83
3
            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
3
        let _ = Command::new("timeout")
88
3
            .args(["25", "bash", &script.to_string_lossy()])
89
3
            .env("PATH", path)
90
3
            .env("REPO", "rayslava/nomisync")
91
3
            .env("SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
92
3
            .env("GIT_TOKEN", "token")
93
3
            .env("GITHUB_RUN_NUMBER", "1")
94
3
            .env("GITHUB_RUN_ID", "1")
95
3
            .current_dir(&root)
96
3
            .output()
97
3
            .expect("run deploy.sh");
98

            
99
3
        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
3
        let _ = fs::remove_dir_all(&tmp);
106
3
        rendered
107
3
    }
108

            
109
    /// The init container's script, as kubectl would receive it.
110
1
    fn prepare_script(job: &str) -> String {
111
1
        let marker = "        - |\n";
112
1
        let start = job.rfind(marker).expect("a block scalar in the Job") + marker.len();
113
1
        job[start..]
114
1
            .lines()
115
81
            .map(|line| line.strip_prefix("          ").unwrap_or(line))
116
1
            .collect::<Vec<_>>()
117
1
            .join("\n")
118
1
    }
119

            
120
    #[test]
121
1
    fn deploy_job_renders_at_all() {
122
1
        let job = render_job();
123
1
        assert!(job.contains("kind: Job"), "not a Job manifest:\n{job}");
124
1
        assert!(
125
1
            job.contains("name: kaniko"),
126
            "the kaniko container is missing, so the heredoc was truncated"
127
        );
128
1
    }
129

            
130
    #[test]
131
1
    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
1
        let job = render_job();
135
1
        assert!(
136
1
            job.contains("print(m$1)"),
137
            "$1 was expanded away by the outer heredoc; escape it as \\$1"
138
        );
139
1
    }
140

            
141
    #[test]
142
1
    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
1
        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
149
1
            .expect("read deploy.sh");
150
1
        let start = source
151
1
            .find("kubectl apply -f - <<EOF")
152
1
            .expect("the Job heredoc");
153
1
        let region = &source[start..];
154
1
        let end = region.find("\nEOF\n").map_or(region.len(), |i| i + 1);
155

            
156
1
        let offenders: Vec<&str> = region[..end]
157
1
            .lines()
158
145
            .filter(|line| line.contains('`'))
159
1
            .collect();
160
1
        assert!(
161
1
            offenders.is_empty(),
162
            "backticks inside the unquoted <<EOF are command substitution, even in comments:\n{}",
163
            offenders.join("\n")
164
        );
165
1
    }
166

            
167
    #[test]
168
1
    fn the_prepare_script_is_valid_bash() {
169
1
        let job = render_job();
170
1
        let script = prepare_script(&job);
171
1
        let dir = std::env::temp_dir().join(format!(
172
1
            "nomisync-prepare-{}-{:?}",
173
1
            std::process::id(),
174
1
            std::thread::current().id()
175
1
        ));
176
1
        std::fs::create_dir_all(&dir).expect("temp dir");
177
1
        let path = dir.join("prepare.sh");
178
1
        std::fs::write(&path, &script).expect("write prepare");
179

            
180
1
        let out = Command::new("bash")
181
1
            .args(["-n", &path.to_string_lossy()])
182
1
            .output()
183
1
            .expect("bash -n");
184
1
        let _ = std::fs::remove_dir_all(&dir);
185

            
186
1
        assert!(
187
1
            out.status.success(),
188
            "the rendered prepare script is not valid bash:\n{}",
189
            String::from_utf8_lossy(&out.stderr)
190
        );
191
1
    }
192
}