codecalc-exec 0.4.0

Sandboxed multi-language executor core for codecalc (Rust)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Platform-specific sandboxing.
//!
//! The three OSes do not offer the same primitives, and pretending otherwise is
//! how a sandbox ends up reporting limits it never applied. What each one
//! actually gives us:
//!
//! | Guarantee            | Linux                | macOS                | Windows                       |
//! |----------------------|----------------------|----------------------|-------------------------------|
//! | CPU time             | RLIMIT_CPU           | RLIMIT_CPU           | — (wall clock only)           |
//! | Address space        | RLIMIT_AS            | RLIMIT_AS (see below)| Job ProcessMemoryLimit        |
//! | File size            | RLIMIT_FSIZE         | RLIMIT_FSIZE         | — (output capped on read)     |
//! | Open files           | RLIMIT_NOFILE        | RLIMIT_NOFILE        | — (no equivalent)             |
//! | Fork bomb            | RLIMIT_NPROC (uid)   | RLIMIT_NPROC (uid)   | Job ActiveProcessLimit (job!) |
//! | Kill the whole tree  | killpg(SIGKILL)      | killpg(SIGKILL)      | TerminateJobObject            |
//! | CPU + peak memory    | wait4 rusage         | wait4 rusage         | Job accounting                |
//! | Block network        | LD_PRELOAD shim      | DYLD_… (SIP-limited) | — not implemented             |
//!
//! Two things are worth noticing in that table. Windows' ActiveProcessLimit is
//! scoped to the JOB, which makes it a strictly better fork-bomb guard than
//! RLIMIT_NPROC's uid-wide budget — the thing that broke 14 of 31 runtimes on
//! Linux cannot happen there. And Windows has no CPU-time or open-file limit
//! here, which is why `Wait::unenforced` exists: every caller is told which
//! guarantees did NOT apply, rather than being left to assume they all did.

use std::path::{Path, PathBuf};
use std::process::Command;

#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub use unix::{current_uid_tasks, spawn_and_wait};

#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::{current_uid_tasks, spawn_and_wait};

/// Raw std handles, for the Windows creation-time job path (THE-818).
///
/// `std::process::Command` cannot pass `PROC_THREAD_ATTRIBUTE_JOB_LIST`, so
/// that path needs a raw `CreateProcessW` — which needs the three handles
/// `Stdio::from(File)` would otherwise consume. Captured before the conversion
/// and carried here. Zeroed and ignored on Unix.
#[derive(Clone, Copy, Default)]
// Read only by the Windows creation-time path; on Unix the struct is carried
// and ignored, which is the point of it being cross-platform.
#[cfg_attr(not(windows), allow(dead_code))]
pub struct RawStdio {
    pub stdin: isize,
    pub stdout: isize,
    pub stderr: isize,
}

/// Resolved, per-execution resource ceilings handed to the platform layer.
#[derive(Clone, Copy)]
pub struct ResolvedLimits {
    pub timeout_secs: u64,
    /// Unix only — Windows Job Objects have no CPU-time limit, and report it via
    /// `Wait::unenforced` instead of pretending otherwise.
    #[cfg_attr(windows, allow(dead_code))]
    pub cpu_secs: u64,
    pub memory_bytes: u64,
    /// Unix only (RLIMIT_FSIZE). Windows caps output when reading it back.
    #[cfg_attr(windows, allow(dead_code))]
    pub fsize_bytes: u64,
    /// Unix only (RLIMIT_NOFILE). Windows has no per-process equivalent.
    #[cfg_attr(windows, allow(dead_code))]
    pub nofile: u64,
    /// Max concurrent processes. Uid-wide on Unix (RLIMIT_NPROC), job-scoped on
    /// Windows (ActiveProcessLimit).
    pub max_processes: u64,
    /// Only the Windows backend reads this (to report it as unenforceable);
    /// Unix applies the shim at the Command level before spawn_and_wait.
    #[cfg_attr(unix, allow(dead_code))]
    pub no_net: bool,
}

/// Outcome of running one child to completion (or killing it).
pub struct Wait {
    pub exit_code: i64,
    /// Unix signal that killed the child, if any. Always None on Windows, which
    /// has no signals — an abnormal exit shows up as a non-zero exit_code.
    pub signal: Option<i32>,
    pub timed_out: bool,
    pub cpu_ms: u64,
    /// Peak resident memory in KiB. Normalising to KiB is deliberate: the raw
    /// source differs per platform (getrusage's ru_maxrss is KiB on Linux and
    /// BYTES on macOS/BSD; Job accounting reports bytes), and reading one as the
    /// other is a silent 1024x error. See the unit conversion in each backend.
    pub peak_memory_kb: u64,
    /// Limits this platform could not apply, by name. Surfaced in the JSON so a
    /// caller can tell "the limit held" from "there was no limit".
    pub unenforced: Vec<&'static str>,
}

/// Where the network-blocking shim lives, if this platform has one.
pub fn no_net_shim(exe_dir: &Path) -> Option<std::path::PathBuf> {
    let name = if cfg!(target_os = "macos") {
        "blocknet.dylib"
    } else if cfg!(target_os = "linux") {
        "blocknet.so"
    } else {
        return None; // Windows: no LD_PRELOAD equivalent for this purpose
    };
    let p = exe_dir.join(name);
    if p.is_file() { Some(p) } else { None }
}

/// Env var used to preload that shim. DYLD_INSERT_LIBRARIES on macOS is honoured
/// only for non-SIP-protected binaries, so `--no-net` is weaker there than on
/// Linux; the caller reports it as unenforced when the shim is absent.
pub fn preload_env_var() -> Option<&'static str> {
    if cfg!(target_os = "macos") {
        Some("DYLD_INSERT_LIBRARIES")
    } else if cfg!(target_os = "linux") {
        Some("LD_PRELOAD")
    } else {
        None
    }
}

/// Apply the no-net shim to a command if this platform supports one.
/// Returns false when the platform (or a missing shim) means no blocking happened.
pub fn apply_no_net(cmd: &mut Command, exe_dir: &Path) -> bool {
    match (preload_env_var(), no_net_shim(exe_dir)) {
        (Some(var), Some(shim)) => {
            cmd.env(var, shim.to_string_lossy().into_owned());
            true
        }
        _ => false,
    }
}

/// Quote one argument the way the MSVC C runtime parses it back.
///
/// `CreateProcessW` takes one STRING; the callee re-splits it. Getting this
/// wrong corrupts every execution silently, which is exactly how THE-817
/// happened one layer up. Rule, from Microsoft's own parser description:
/// backslashes are literal EXCEPT immediately before a quote, where they are
/// escapes and must be doubled — including the run before the closing quote.
// Used by the Windows creation-time path; unused on Unix, where it is kept
// compiled and TESTED anyway. A rule only one platform can check is a rule
// nothing checks — the lesson from THE-817's `os.path` vs `ntpath` branch.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn quote_arg(arg: &std::ffi::OsStr) -> String {
    let s = arg.to_string_lossy();
    if !s.is_empty() && !s.contains([' ', '\t', '"']) {
        return s.into_owned();
    }
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    let mut backslashes = 0usize;
    for c in s.chars() {
        match c {
            '\\' => {
                backslashes += 1;
                out.push('\\');
            }
            '"' => {
                // Double the run, then escape the quote itself.
                for _ in 0..backslashes {
                    out.push('\\');
                }
                backslashes = 0;
                out.push('\\');
                out.push('"');
            }
            _ => {
                backslashes = 0;
                out.push(c);
            }
        }
    }
    // The run before the CLOSING quote is escaping it, so double it too.
    for _ in 0..backslashes {
        out.push('\\');
    }
    out.push('"');
    out
}

/// Does `path` traverse the Windows Store app-execution alias directory,
/// `…\Microsoft\WindowsApps\…`?
///
/// Those aliases are zero-length reparse stubs that hand off to the Store
/// activation broker, which launches the real program OUTSIDE the caller's job
/// object — escaping every sandbox limit. The Windows backend refuses a runtime
/// that resolves to one (THE-818). Matching is the `Microsoft`→`WindowsApps`
/// PAIR, never a bare `WindowsApps`: the real package store at
/// `Program Files\WindowsApps` holds legitimate executables and must not match.
///
/// Split on BOTH separators so the check is byte-identical on the Linux CI that
/// tests it and the Windows host that runs it. `Path::components` treats `\` as
/// an ordinary character off-Windows, so a backslash alias path would collapse
/// to one component there and the pair would never be seen — the same
/// `os.path` vs `ntpath` platform split THE-817 was. Kept compiled and TESTED on
/// every platform for exactly that reason; only the Windows backend calls it.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn is_windowsapps_alias_path(path: &Path) -> bool {
    let display = path.to_string_lossy();
    let mut prev_is_microsoft = false;
    for part in display.split(['/', '\\']).filter(|p| !p.is_empty()) {
        if prev_is_microsoft && part.eq_ignore_ascii_case("WindowsApps") {
            return true;
        }
        prev_is_microsoft = part.eq_ignore_ascii_case("Microsoft");
    }
    false
}

/// Outcome of choosing a runtime executable from an ordered candidate list.
#[cfg_attr(not(windows), allow(dead_code))]
pub enum RuntimeChoice {
    /// A real, launchable executable — the first candidate that is a file and
    /// is not an app-execution alias.
    Found(PathBuf),
    /// Every matching candidate was a Store app-execution alias. Carries the
    /// FIRST one seen, so the caller can fail closed naming a concrete path.
    OnlyAlias(PathBuf),
    /// Nothing on the candidate list was a file at all.
    None,
}

/// Choose a runtime executable from `candidates`, scanning in order.
///
/// The security-critical resolution decision behind THE-818, kept here —
/// separate from Windows filesystem I/O — so the Linux CI can exercise the
/// control flow with injected classifiers. A candidate that is a file but an
/// app-execution alias is SKIPPED rather than returned, so an alias early on
/// PATH cannot shadow a real interpreter later on it; if every file found was an
/// alias, the first is returned as `OnlyAlias` for a specific fail-closed error
/// instead of a bare "not found". `is_file` and `is_alias` are injected so the
/// same logic is driven by real Windows checks in production and by fakes in the
/// tests below.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn choose_runtime<I, F, G>(candidates: I, is_file: F, is_alias: G) -> RuntimeChoice
where
    I: IntoIterator<Item = PathBuf>,
    F: Fn(&Path) -> bool,
    G: Fn(&Path) -> bool,
{
    let mut first_alias: Option<PathBuf> = None;
    for candidate in candidates {
        if !is_file(&candidate) {
            continue;
        }
        if is_alias(&candidate) {
            if first_alias.is_none() {
                first_alias = Some(candidate);
            }
            continue;
        }
        return RuntimeChoice::Found(candidate);
    }
    match first_alias {
        Some(alias) => RuntimeChoice::OnlyAlias(alias),
        None => RuntimeChoice::None,
    }
}

#[cfg(test)]
mod choose_runtime_tests {
    use super::{RuntimeChoice, choose_runtime};
    use std::path::{Path, PathBuf};

    // The candidate ordering is PATH order; the classifiers stand in for the
    // real Windows `is_file`/`is_app_execution_alias` so the branch logic — not
    // the filesystem — is what these tests pin down.
    fn cands(list: &[&str]) -> Vec<PathBuf> {
        list.iter().map(PathBuf::from).collect()
    }
    fn is(p: &Path, name: &str) -> bool {
        p.to_str() == Some(name)
    }

    #[test]
    fn a_real_file_after_an_alias_is_preferred_and_the_alias_skipped() {
        // The THE-818 case: the alias sorts first on PATH, a real interpreter
        // sits behind it. The real one must win.
        let r = choose_runtime(cands(&["alias", "real"]), |_| true, |p| is(p, "alias"));
        assert!(matches!(r, RuntimeChoice::Found(p) if p == Path::new("real")));
    }

    #[test]
    fn a_real_file_before_an_alias_wins_immediately() {
        let r = choose_runtime(cands(&["real", "alias"]), |_| true, |p| is(p, "alias"));
        assert!(matches!(r, RuntimeChoice::Found(p) if p == Path::new("real")));
    }

    #[test]
    fn only_aliases_fails_closed_naming_the_first() {
        // Fail closed, and name the FIRST alias (what an operator's PATH hits).
        let r = choose_runtime(cands(&["alias1", "alias2"]), |_| true, |_| true);
        assert!(matches!(r, RuntimeChoice::OnlyAlias(p) if p == Path::new("alias1")));
    }

    #[test]
    fn no_file_match_is_none_not_alias() {
        // Nothing is a file → None, never a spurious alias refusal.
        let r = choose_runtime(cands(&["a", "b"]), |_| false, |_| true);
        assert!(matches!(r, RuntimeChoice::None));
    }

    #[test]
    fn missing_candidates_are_stepped_over_to_reach_a_real_file() {
        // PATHEXT / multiple dirs produce candidates that are not files; the
        // scan must step past them to the real hit.
        let r = choose_runtime(cands(&["missing", "real"]), |p| is(p, "real"), |_| false);
        assert!(matches!(r, RuntimeChoice::Found(p) if p == Path::new("real")));
    }

    #[test]
    fn a_run_of_aliases_never_masks_a_later_real_interpreter() {
        let r = choose_runtime(
            cands(&["a1", "a2", "real", "a3"]),
            |_| true,
            |p| !is(p, "real"),
        );
        assert!(matches!(r, RuntimeChoice::Found(p) if p == Path::new("real")));
    }
}

#[cfg(test)]
mod alias_path_tests {
    use super::is_windowsapps_alias_path;
    use std::path::Path;

    fn is_alias(s: &str) -> bool {
        is_windowsapps_alias_path(Path::new(s))
    }

    #[test]
    fn the_real_store_alias_path_is_detected() {
        // The shape THE-818 measured `python3` resolving to on a Win11 box.
        assert!(is_alias(
            r"C:\Users\alice\AppData\Local\Microsoft\WindowsApps\python3.EXE"
        ));
    }

    #[test]
    fn the_package_store_is_not_an_alias() {
        // Program Files\WindowsApps holds REAL installed Store binaries. A bare
        // `WindowsApps` match would wrongly refuse these — the pair guards it.
        assert!(!is_alias(
            r"C:\Program Files\WindowsApps\SomeVendor.App\python.exe"
        ));
    }

    #[test]
    fn a_normal_interpreter_is_not_an_alias() {
        assert!(!is_alias(r"C:\Python312\python.exe"));
        assert!(!is_alias(r"C:\Users\alice\venv\Scripts\python.exe"));
    }

    #[test]
    fn case_is_ignored_on_both_components() {
        // Windows paths are case-insensitive; the drive may hand back any casing.
        assert!(is_alias(
            r"c:\users\x\appdata\local\microsoft\windowsapps\PYTHON3.EXE"
        ));
    }

    #[test]
    fn forward_slashes_are_matched_too() {
        // The env/PATH can carry either separator; both must split identically.
        assert!(is_alias("C:/opt/Local/Microsoft/WindowsApps/python3.exe"));
    }

    #[test]
    fn microsoft_without_windowsapps_is_not_an_alias() {
        // Microsoft appears all over Program Files; only the adjacency matters.
        assert!(!is_alias(r"C:\Program Files\Microsoft\dotnet\dotnet.exe"));
    }
}

#[cfg(test)]
mod quoting_tests {
    use super::quote_arg;
    use std::ffi::OsStr;

    // These test the REAL function, not a restatement of it. An earlier draft
    // asserted against a reference reimplementation written in the same sitting
    // — which agrees by construction and proves nothing. quote_arg lives here
    // rather than in the cfg(windows) module for exactly that reason: a rule
    // only Windows can check is a rule nothing checks.

    fn q(s: &str) -> String {
        quote_arg(OsStr::new(s))
    }

    #[test]
    fn a_plain_argument_is_not_quoted() {
        assert_eq!(q("main.py"), "main.py");
    }

    #[test]
    fn backslashes_not_before_a_quote_are_literal() {
        // A Windows path must survive untouched. THE-817 inverted.
        assert_eq!(q(r"C:\Temp\main.py"), r"C:\Temp\main.py");
    }

    #[test]
    fn a_space_forces_quoting() {
        assert_eq!(q("John Smith"), "\"John Smith\"");
    }

    #[test]
    fn the_backslash_run_before_the_closing_quote_is_doubled() {
        // Without doubling, the trailing backslash escapes OUR closing quote
        // and the argument swallows the rest of the command line.
        assert_eq!(q(r"C:\a b\"), "\"C:\\a b\\\\\"");
    }

    #[test]
    fn an_embedded_quote_is_escaped() {
        assert_eq!(q(r#"say "hi""#), r#""say \"hi\"""#);
    }

    #[test]
    fn a_backslash_run_before_an_embedded_quote_is_doubled() {
        assert_eq!(q(r#"a\"b"#), r#""a\\\"b""#);
    }

    #[test]
    fn the_empty_argument_survives_as_an_empty_quoted_string() {
        // Dropping it would silently shift every later argument left.
        assert_eq!(q(""), "\"\"");
    }
}