1use 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
306pub 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)?; 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}