Skip to main content

caboodle/
job_runtime.rs

1//! Fixed-path immutable job runtime delivery; no identity/sudo/readiness edits.
2use anyhow::{bail, Context, Result};
3use fs2::FileExt;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fs::{self, File, OpenOptions},
9    io::{Read, Write},
10    os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt},
11    path::Path,
12};
13
14const ROOT: &str = "/opt/shantytown-job-launcher";
15const HELPER: &str = "/usr/local/libexec/st-job-launcher";
16const MAX_FILE: u64 = 268_435_456;
17const MAX_TOTAL: u64 = 1_073_741_824;
18#[cfg(target_os = "macos")]
19const NOFOLLOW: i32 = 0x100;
20#[cfg(not(target_os = "macos"))]
21const NOFOLLOW: i32 = 0x20000;
22
23#[derive(Deserialize)]
24#[serde(deny_unknown_fields)]
25struct Manifest {
26    version: u32,
27    platform: String,
28    files: BTreeMap<String, String>,
29}
30#[derive(Debug, Serialize)]
31pub struct Plan {
32    pub changed: bool,
33    pub manifest_sha256: String,
34    pub platform: String,
35    pub files: usize,
36    pub bytes: u64,
37}
38struct Verified {
39    plan: Plan,
40    manifest: Manifest,
41    raw: Vec<u8>,
42}
43
44fn hex(value: &str) -> bool {
45    value.len() == 64
46        && value
47            .bytes()
48            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
49}
50fn regular(path: &Path) -> Result<u64> {
51    let info = fs::symlink_metadata(path)?;
52    if !info.is_file() || info.len() > MAX_FILE {
53        bail!("runtime input must be a bounded regular file");
54    }
55    Ok(info.len())
56}
57fn hash(path: &Path) -> Result<String> {
58    regular(path)?;
59    let mut file = OpenOptions::new()
60        .read(true)
61        .custom_flags(NOFOLLOW)
62        .open(path)?;
63    let mut digest = Sha256::new();
64    let mut total = 0;
65    let mut buffer = [0u8; 65536];
66    loop {
67        let count = file.read(&mut buffer)?;
68        if count == 0 {
69            break;
70        }
71        total += count as u64;
72        if total > MAX_FILE {
73            bail!("runtime input grew beyond its bound");
74        }
75        digest.update(&buffer[..count]);
76    }
77    Ok(format!("{:x}", digest.finalize()))
78}
79fn safe_name(name: &str) -> bool {
80    !name.is_empty()
81        && name
82            .split('/')
83            .all(|part| !part.is_empty() && part != "." && part != "..")
84        && name
85            .bytes()
86            .all(|b| b.is_ascii_alphanumeric() || b"/._-".contains(&b))
87        && (name == "launcher"
88            || [
89                "runtime/bin/python3",
90                "runtime/bin/sd",
91                "runtime/bin/codex",
92                "runtime/bin/claude",
93            ]
94            .contains(&name)
95            || name.starts_with("runtime/app/shantytown/")
96            || name.starts_with("runtime/lib/"))
97}
98fn inventory(root: &Path) -> Result<(BTreeSet<String>, BTreeSet<String>)> {
99    let mut todo = vec![root.to_path_buf()];
100    let (mut files, mut dirs) = (BTreeSet::new(), BTreeSet::new());
101    while let Some(directory) = todo.pop() {
102        if !fs::symlink_metadata(&directory)?.is_dir() {
103            bail!("runtime directory required");
104        }
105        for entry in fs::read_dir(directory)? {
106            let entry = entry?;
107            let path = entry.path();
108            let name = path
109                .strip_prefix(root)?
110                .to_str()
111                .context("non-UTF8 runtime path")?
112                .to_string();
113            let info = fs::symlink_metadata(&path)?;
114            if info.is_dir() {
115                dirs.insert(name);
116                todo.push(path);
117            } else if info.is_file() {
118                files.insert(name);
119            } else {
120                bail!("runtime symlink or special file refused");
121            }
122            if files.len() + dirs.len() > 8192 {
123                bail!("runtime inventory exceeds its bound");
124            }
125        }
126    }
127    Ok((files, dirs))
128}
129fn parents(files: &BTreeSet<String>) -> BTreeSet<String> {
130    files
131        .iter()
132        .flat_map(|name| {
133            Path::new(name)
134                .ancestors()
135                .skip(1)
136                .filter(|p| !p.as_os_str().is_empty())
137                .map(|p| p.to_str().unwrap().to_string())
138        })
139        .collect()
140}
141fn load(directory: &Path, expected: &str) -> Result<Verified> {
142    if !hex(expected) {
143        bail!("explicit reviewed manifest SHA256 required");
144    }
145    let manifest_path = directory.join("manifest.json");
146    if regular(&manifest_path)? > 1_048_576 || hash(&manifest_path)? != expected {
147        bail!("runtime manifest differs from its reviewed pin");
148    }
149    let raw = fs::read(manifest_path)?;
150    if raw.len() > 1_048_576 || format!("{:x}", Sha256::digest(&raw)) != expected {
151        bail!("runtime manifest changed during preparation");
152    }
153    let manifest: Manifest = serde_json::from_slice(&raw)?;
154    if manifest.version != 1
155        || !["linux", "macos"].contains(&manifest.platform.as_str())
156        || manifest.files.len() > 4096
157        || manifest
158            .files
159            .iter()
160            .any(|(name, sha)| !safe_name(name) || !hex(sha))
161    {
162        bail!("invalid explicit native runtime manifest");
163    }
164    for required in [
165        "launcher",
166        "runtime/bin/python3",
167        "runtime/app/shantytown/__init__.py",
168        "runtime/app/shantytown/job_launch.py",
169        "runtime/app/shantytown/job_metadata.py",
170    ] {
171        if !manifest.files.contains_key(required) {
172            bail!("runtime artifact is incomplete");
173        }
174    }
175    let libraries: Vec<_> = manifest
176        .files
177        .keys()
178        .filter_map(|name| {
179            let minor = name
180                .strip_prefix("runtime/lib/python3.")?
181                .strip_suffix("/os.py")?;
182            (minor.parse::<u32>().ok()? >= 11).then_some(minor)
183        })
184        .collect();
185    if libraries.len() != 1
186        || !manifest.files.contains_key(&format!(
187            "runtime/lib/python3.{}/encodings/__init__.py",
188            libraries[0]
189        ))
190    {
191        bail!("one private Python >=3.11 standard library is required");
192    }
193    let mut expected_files: BTreeSet<String> = manifest.files.keys().cloned().collect();
194    expected_files.insert("manifest.json".into());
195    if inventory(directory)? != (expected_files.clone(), parents(&expected_files)) {
196        bail!("runtime artifact differs from its exact manifest inventory");
197    }
198    let mut bytes = 0;
199    for (name, sha) in &manifest.files {
200        bytes += regular(&directory.join(name))?;
201        if bytes > MAX_TOTAL || hash(&directory.join(name))? != *sha {
202            bail!("runtime artifact bytes differ from the reviewed manifest");
203        }
204    }
205    let launcher = fs::read(directory.join("launcher"))?;
206    if !launcher.starts_with(b"#!/opt/shantytown-job-launcher/bin/python3 -BIS\n") {
207        bail!("launcher must use the fixed isolated no-bytecode interpreter");
208    }
209    Ok(Verified {
210        plan: Plan {
211            changed: false,
212            manifest_sha256: expected.into(),
213            platform: manifest.platform.clone(),
214            files: manifest.files.len(),
215            bytes,
216        },
217        manifest,
218        raw,
219    })
220}
221
222pub fn preview(directory: &Path, expected: &str) -> Result<Plan> {
223    Ok(load(directory, expected)?.plan)
224}
225fn protected(path: &Path) -> Result<()> {
226    if !path.is_absolute() {
227        bail!("absolute protected runtime path required");
228    }
229    for part in path.ancestors() {
230        let info = fs::symlink_metadata(part)?;
231        if info.uid() != 0 || info.mode() & 0o022 != 0 || (!info.is_dir() && !info.is_file()) {
232            bail!("runtime path is mutable or foreign");
233        }
234    }
235    Ok(())
236}
237fn directory(path: &Path) -> Result<()> {
238    protected(path.parent().context("runtime directory has no parent")?)?;
239    if !path.exists() {
240        fs::DirBuilder::new().mode(0o755).create(path)?;
241        fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
242    }
243    protected(path)?;
244    if !path.is_dir() {
245        bail!("protected directory required");
246    }
247    Ok(())
248}
249fn sync(path: &Path) -> Result<()> {
250    File::open(path)?.sync_all()?;
251    Ok(())
252}
253fn copy(source: &Path, destination: &Path, executable: bool, expected: &str) -> Result<()> {
254    let mut input = OpenOptions::new()
255        .read(true)
256        .custom_flags(NOFOLLOW)
257        .open(source)?;
258    let mut output = OpenOptions::new()
259        .write(true)
260        .create_new(true)
261        .mode(0o600)
262        .custom_flags(NOFOLLOW)
263        .open(destination)?;
264    std::io::copy(
265        &mut Read::by_ref(&mut input).take(MAX_FILE + 1),
266        &mut output,
267    )?;
268    output.sync_all()?;
269    if hash(destination)? != expected {
270        bail!("runtime changed while copying; retain staging");
271    }
272    fs::set_permissions(
273        destination,
274        fs::Permissions::from_mode(if executable { 0o755 } else { 0o644 }),
275    )?;
276    output.sync_all()?;
277    Ok(())
278}
279fn verify_installed(root: &Path, verified: &Verified) -> Result<()> {
280    protected(root)?;
281    let mut expected: BTreeSet<String> = verified
282        .manifest
283        .files
284        .keys()
285        .filter_map(|name| name.strip_prefix("runtime/").map(str::to_string))
286        .collect();
287    expected.insert(".artifact.json".into());
288    if inventory(root)? != (expected.clone(), parents(&expected)) {
289        bail!("installed runtime inventory changed");
290    }
291    for name in expected {
292        let path = root.join(&name);
293        protected(&path)?;
294        let wanted = if name == ".artifact.json" {
295            &verified.plan.manifest_sha256
296        } else {
297            &verified.manifest.files[&format!("runtime/{name}")]
298        };
299        if hash(&path)? != *wanted {
300            bail!("installed runtime differs; explicit recovery required");
301        }
302    }
303    Ok(())
304}
305
306/// New immutable installation or exact same-artifact readback. Never an upgrade.
307pub fn apply(source: &Path, expected: &str) -> Result<Plan> {
308    crate::job_slots::require_root()?;
309    protected(source)?;
310    let mut verified = load(source, expected)?;
311    if verified.plan.platform != std::env::consts::OS {
312        bail!("runtime artifact is for another platform");
313    }
314    protected(&source.join("manifest.json"))?;
315    for name in verified.manifest.files.keys() {
316        protected(&source.join(name))?;
317    }
318    directory(Path::new("/opt"))?;
319    directory(Path::new("/usr/local/libexec"))?;
320    let lock = OpenOptions::new()
321        .read(true)
322        .write(true)
323        .create(true)
324        .truncate(false)
325        .mode(0o600)
326        .custom_flags(NOFOLLOW)
327        .open("/opt/.shantytown-job-runtime.lock")?;
328    let info = lock.metadata()?;
329    if !info.is_file() || info.uid() != 0 || info.mode() & 0o077 != 0 {
330        bail!("unsafe runtime installation lock");
331    }
332    lock.try_lock_exclusive()?;
333    let helper = Path::new(HELPER);
334    if helper.exists() || helper.is_symlink() {
335        protected(helper)?;
336        if hash(helper)? != verified.manifest.files["launcher"] {
337            bail!("different helper installed; explicit recovery required");
338        }
339    }
340    let root = Path::new(ROOT);
341    verified.plan.changed = !root.exists() || !helper.exists();
342    if root.exists() || root.is_symlink() {
343        verify_installed(root, &verified)?;
344    } else {
345        let staging = Path::new("/opt/.shantytown-job-launcher.pending");
346        fs::DirBuilder::new().mode(0o755).create(staging)?; // retain on every later failure
347        fs::set_permissions(staging, fs::Permissions::from_mode(0o755))?;
348        let files: BTreeSet<String> = verified
349            .manifest
350            .files
351            .keys()
352            .filter_map(|name| name.strip_prefix("runtime/").map(str::to_string))
353            .collect();
354        let dirs = parents(&files);
355        for relative in &dirs {
356            directory(&staging.join(relative))?;
357        }
358        for relative in files {
359            copy(
360                &source.join("runtime").join(&relative),
361                &staging.join(&relative),
362                relative.starts_with("bin/"),
363                &verified.manifest.files[&format!("runtime/{relative}")],
364            )?;
365        }
366        let receipt = staging.join(".artifact.json");
367        let mut output = OpenOptions::new()
368            .write(true)
369            .create_new(true)
370            .mode(0o644)
371            .open(&receipt)?;
372        output.write_all(&verified.raw)?;
373        output.sync_all()?;
374        verify_installed(staging, &verified)?;
375        for relative in dirs.iter().rev() {
376            sync(&staging.join(relative))?;
377        }
378        sync(staging)?;
379        fs::rename(staging, root)?;
380        sync(Path::new("/opt"))?;
381    }
382    if !helper.exists() {
383        let staged = helper.with_file_name(".st-job-launcher.pending");
384        copy(
385            &source.join("launcher"),
386            &staged,
387            true,
388            &verified.manifest.files["launcher"],
389        )?;
390        fs::rename(staged, helper)?;
391        sync(helper.parent().unwrap())?;
392    }
393    protected(helper)?;
394    if hash(helper)? != verified.manifest.files["launcher"] {
395        bail!("helper readback differs");
396    }
397    verify_installed(root, &verified)?;
398    Ok(verified.plan)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use std::os::unix::fs::symlink;
405
406    fn save(root: &Path, files: &BTreeMap<String, String>) -> String {
407        let raw = serde_json::to_vec(
408            &serde_json::json!({"version":1,"platform":std::env::consts::OS,"files":files}),
409        )
410        .unwrap();
411        fs::write(root.join("manifest.json"), &raw).unwrap();
412        format!("{:x}", Sha256::digest(raw))
413    }
414    fn fixture() -> (tempfile::TempDir, BTreeMap<String, String>, String) {
415        let root = tempfile::tempdir().unwrap();
416        let mut files = BTreeMap::new();
417        for name in [
418            "launcher",
419            "runtime/bin/python3",
420            "runtime/app/shantytown/__init__.py",
421            "runtime/app/shantytown/job_launch.py",
422            "runtime/app/shantytown/job_metadata.py",
423            "runtime/lib/python3.11/os.py",
424            "runtime/lib/python3.11/encodings/__init__.py",
425        ] {
426            let path = root.path().join(name);
427            fs::create_dir_all(path.parent().unwrap()).unwrap();
428            fs::write(
429                &path,
430                if name == "launcher" {
431                    b"#!/opt/shantytown-job-launcher/bin/python3 -BIS\n# fixture, never executed\n"
432                        .as_slice()
433                } else {
434                    b"synthetic inert fixture".as_slice()
435                },
436            )
437            .unwrap();
438            files.insert(name.to_string(), hash(&path).unwrap());
439        }
440        let pin = save(root.path(), &files);
441        (root, files, pin)
442    }
443    #[test]
444    fn preview_requires_exact_manifest_and_never_executes_fixture_bytes() {
445        let (root, files, pin) = fixture();
446        let plan = preview(root.path(), &pin).unwrap();
447        assert_eq!(plan.files, files.len());
448        assert_eq!(plan.manifest_sha256, pin);
449        assert!(preview(root.path(), &"0".repeat(64)).is_err());
450        fs::write(root.path().join("runtime/bin/python3"), "changed").unwrap();
451        assert!(preview(root.path(), &pin).is_err());
452    }
453    #[test]
454    fn optional_trusted_client_and_native_harnesses_are_explicitly_scoped() {
455        let (root, mut files, _) = fixture();
456        for name in ["sd", "codex", "claude"] {
457            let relative = format!("runtime/bin/{name}");
458            let path = root.path().join(&relative);
459            fs::write(&path, "inert native fixture, never executed").unwrap();
460            files.insert(relative, hash(&path).unwrap());
461        }
462        let pin = save(root.path(), &files);
463        let plan = preview(root.path(), &pin).unwrap();
464        assert_eq!(plan.files, files.len());
465        assert!(!plan.changed);
466    }
467    #[test]
468    fn extra_missing_or_symlinked_artifact_member_refuses() {
469        for fault in ["extra", "missing", "symlink"] {
470            let (root, _, pin) = fixture();
471            let launcher = root.path().join("launcher");
472            match fault {
473                "extra" => fs::write(root.path().join("extra"), "unexpected").unwrap(),
474                "missing" => fs::remove_file(launcher).unwrap(),
475                _ => {
476                    fs::remove_file(&launcher).unwrap();
477                    symlink("/usr/bin/true", launcher).unwrap();
478                }
479            }
480            assert!(preview(root.path(), &pin).is_err(), "{fault}");
481        }
482    }
483    #[test]
484    fn traversal_absolute_and_unscoped_paths_refuse_even_with_matching_manifest_pin() {
485        for name in [
486            "/absolute",
487            "runtime/../escape",
488            "runtime//bin/other",
489            "runtime/bin/shell",
490            "runtime/app/foreign/code.py",
491        ] {
492            let (root, mut files, _) = fixture();
493            files.insert(name.into(), "0".repeat(64));
494            let pin = save(root.path(), &files);
495            assert!(preview(root.path(), &pin).is_err(), "{name}");
496        }
497    }
498    #[test]
499    fn application_and_isolated_interpreter_are_mandatory() {
500        for name in [
501            "runtime/bin/python3",
502            "runtime/app/shantytown/job_metadata.py",
503            "runtime/lib/python3.11/os.py",
504        ] {
505            let (root, mut files, _) = fixture();
506            files.remove(name);
507            let pin = save(root.path(), &files);
508            assert!(preview(root.path(), &pin).is_err());
509        }
510        let (root, mut files, _) = fixture();
511        let path = root.path().join("launcher");
512        fs::write(&path, "#!/usr/bin/env python3\n").unwrap();
513        files.insert("launcher".into(), hash(&path).unwrap());
514        let pin = save(root.path(), &files);
515        assert!(preview(root.path(), &pin).is_err());
516    }
517    #[test]
518    fn copy_is_exclusive_and_mismatch_retains_nonexecutable_staging() {
519        let root = tempfile::tempdir().unwrap();
520        let source = root.path().join("source");
521        let target = root.path().join("target");
522        fs::write(&source, "fixture").unwrap();
523        let pin = hash(&source).unwrap();
524        copy(&source, &target, true, &pin).unwrap();
525        assert_eq!(hash(&target).unwrap(), pin);
526        assert_eq!(fs::metadata(&target).unwrap().mode() & 0o777, 0o755);
527        assert!(copy(&source, &target, true, &pin).is_err());
528        let bad = root.path().join("retained");
529        assert!(copy(&source, &bad, true, &"0".repeat(64)).is_err());
530        assert_eq!(fs::metadata(&bad).unwrap().mode() & 0o777, 0o600);
531        assert_eq!(fs::read(&bad).unwrap(), b"fixture");
532    }
533}