Skip to main content

caboodle/
crew.rs

1use std::{
2    collections::HashSet,
3    env,
4    ffi::OsStr,
5    fs,
6    path::{Path, PathBuf},
7};
8
9use anyhow::{bail, Context, Result};
10use serde::Deserialize;
11
12use crate::{
13    adapter::{checked, download_https},
14    model::{CrewMode, CrewRuntimeState, CrewSelection, State},
15};
16
17const SHANTYTOWN_VERSION: &str = "0.4.0";
18const SHANTYTOWN_WHEEL_SHA256: &str =
19    "afd52cb5e2b8c67eef8fa7e3aa4e8c725419d0f1f3e84184d6a01b52134ca8ac";
20const CREEL_REVISION: &str = "0003aee9b1eec512e59d13b64a6c5a4d3b8b55d6";
21const CREEL_ARCHIVE_SHA256: &str =
22    "951809b5ce49f3180872c5a634c7e34bf57e7be0195da70e1dfe32c50101801c";
23
24#[derive(Debug, Default)]
25pub struct CrewEvidence {
26    pub creel_doctor: Option<PathBuf>,
27    pub creel_admission: Option<PathBuf>,
28}
29
30pub fn apply(selection: &CrewSelection, state: &mut State, skip_install: bool) -> Result<()> {
31    if selects_shantytown(selection.mode) {
32        let observed = shantytown_installed();
33        match shantytown_convergence(&observed) {
34            Convergence::Current => {}
35            Convergence::Decline => {
36                // Never install the older reviewed pin over a newer, unknown or
37                // unreadable st (aegis-fv4): that is a downgrade on a guess.
38                println!("shantytown: {}; not installed", decline_reason(&observed));
39                return apply_creel(selection, state, skip_install);
40            }
41            Convergence::Install => {
42                if skip_install {
43                    bail!("Shantytown is not installed and --skip-install was selected");
44                }
45                install_shantytown()?;
46            }
47        }
48        record(state, "shantytown", shantytown_version()?, false);
49        println!("shantytown: applied");
50    }
51    apply_creel(selection, state, skip_install)
52}
53
54fn apply_creel(selection: &CrewSelection, state: &mut State, skip_install: bool) -> Result<()> {
55    if selects_creel(selection.mode) {
56        if skip_install && !creel_root()?.join("REVISION").is_file() {
57            bail!("Creel is not installed and --skip-install was selected");
58        }
59        install_creel()?;
60        record(state, "creel", format!("creel {CREEL_REVISION}"), false);
61        println!("creel: applied");
62    }
63    Ok(())
64}
65
66pub fn verify(selection: &CrewSelection, evidence: &CrewEvidence, state: &mut State) -> Result<()> {
67    if selects_shantytown(selection.mode) {
68        let version = shantytown_version()?;
69        record(state, "shantytown", version, true);
70        println!("shantytown: verified");
71    }
72    if selects_creel(selection.mode) {
73        verify_creel_bundle()?;
74        let doctor = evidence.creel_doctor.as_deref().context(
75            "Creel verification requires --creel-doctor from the browser capability preflight",
76        )?;
77        let admission = evidence.creel_admission.as_deref().context(
78            "Creel verification requires --creel-admission from the provider-window governor",
79        )?;
80        validate_doctor(doctor)?;
81        validate_admission(admission)?;
82        record(state, "creel", format!("creel {CREEL_REVISION}"), true);
83        println!("creel: verified");
84    }
85    Ok(())
86}
87
88pub fn check_updates(selection: &CrewSelection) -> bool {
89    let mut current = true;
90    if selects_shantytown(selection.mode) {
91        match (shantytown_version(), shantytown_installed()) {
92            (Ok(version), _) => println!("shantytown: current ({version})"),
93            (Err(error), Observed::Absent) => {
94                current = false;
95                println!(
96                    "shantytown: missing or drifted ({error:#}); reviewed: st {SHANTYTOWN_VERSION}"
97                );
98            }
99            (Err(_), observed) => {
100                current = false;
101                println!("shantytown: {}", decline_reason(&observed));
102            }
103        }
104    }
105    if selects_creel(selection.mode) {
106        match verify_creel_bundle() {
107            Ok(()) => println!("creel: current (creel {CREEL_REVISION})"),
108            Err(error) => {
109                current = false;
110                println!("creel: missing or drifted ({error:#}); reviewed: creel {CREEL_REVISION}");
111            }
112        }
113    }
114    current
115}
116
117fn selects_shantytown(mode: CrewMode) -> bool {
118    matches!(mode, CrewMode::Shantytown | CrewMode::Both)
119}
120
121fn selects_creel(mode: CrewMode) -> bool {
122    matches!(mode, CrewMode::Creel | CrewMode::Both)
123}
124
125fn record(state: &mut State, name: &str, version: String, verified: bool) {
126    let remains_verified = state
127        .crew
128        .get(name)
129        .is_some_and(|old| old.version == version && old.verified);
130    state.crew.insert(
131        name.to_owned(),
132        CrewRuntimeState {
133            version,
134            applied: true,
135            verified: verified || remains_verified,
136        },
137    );
138}
139
140fn home() -> Result<PathBuf> {
141    env::var_os("HOME")
142        .map(PathBuf::from)
143        .context("HOME is required to install crew runtimes")
144}
145
146fn install_shantytown() -> Result<()> {
147    if shantytown_version().is_ok() {
148        return Ok(());
149    }
150    let download = tempfile::tempdir().context("create Shantytown download directory")?;
151    let wheel = download
152        .path()
153        .join(format!("shantytown-{SHANTYTOWN_VERSION}-py3-none-any.whl"));
154    download_https(
155        &format!(
156            "https://github.com/scbrown/shantytown/releases/download/v{SHANTYTOWN_VERSION}/shantytown-{SHANTYTOWN_VERSION}-py3-none-any.whl"
157        ),
158        &wheel,
159    )?;
160    verify_fixed_checksum(&wheel, SHANTYTOWN_WHEEL_SHA256, "Shantytown wheel")?;
161    let root = home()?
162        .join(".local/share/caboodle/shantytown")
163        .join(format!("v{SHANTYTOWN_VERSION}"));
164    if !root.join("bin/python").exists() {
165        fs::create_dir_all(root.parent().context("Shantytown root has no parent")?)?;
166        checked(
167            "python3",
168            [OsStr::new("-m"), OsStr::new("venv"), root.as_os_str()],
169            None,
170        )?;
171        checked(
172            root.join("bin/pip")
173                .to_str()
174                .context("Shantytown pip path is not UTF-8")?,
175            [
176                OsStr::new("install"),
177                OsStr::new("--no-deps"),
178                wheel.as_os_str(),
179            ],
180            None,
181        )?;
182    }
183    link_binary(&root.join("bin/st"), "st")
184}
185
186#[derive(Debug, PartialEq, Eq)]
187enum Convergence {
188    Current,
189    Install,
190    Decline,
191}
192
193/// Absent -> install; exactly the reviewed pin -> current; strictly older ->
194/// converge upward; newer or not comparable -> decline (aegis-fv4).
195fn shantytown_convergence(observed: &Observed) -> Convergence {
196    let installed = match observed {
197        Observed::Absent => return Convergence::Install,
198        Observed::Unreadable(_) => return Convergence::Decline,
199        Observed::Version(version) => version,
200    };
201    if installed.starts_with(&format!("st {SHANTYTOWN_VERSION}")) {
202        return Convergence::Current;
203    }
204    match crate::release_update::behind_reviewed(installed, &format!("st {SHANTYTOWN_VERSION}")) {
205        Some(true) => Convergence::Install,
206        Some(false) | None => Convergence::Decline,
207    }
208}
209
210/// What is on this host. Only a CONFIRMED absence may install: an st that is
211/// present but cannot report its version is unknown, not missing.
212#[derive(Debug, PartialEq, Eq)]
213enum Observed {
214    Absent,
215    Unreadable(String),
216    Version(String),
217}
218
219fn decline_reason(observed: &Observed) -> String {
220    let reviewed = format!("st {SHANTYTOWN_VERSION}");
221    match observed {
222        Observed::Version(installed) => format!(
223            "{} (installed: {installed}); reviewed: {reviewed}",
224            crate::engine::reviewed_drift_status(installed, &reviewed)
225        ),
226        Observed::Unreadable(error) => format!(
227            "present but its version is unreadable ({error}); reviewed: {reviewed}; refusing to replace it"
228        ),
229        Observed::Absent => format!("missing; reviewed: {reviewed}"),
230    }
231}
232
233fn shantytown_installed() -> Observed {
234    if crate::adapter::which_all("st", env::var_os("PATH").as_deref()).is_empty() {
235        return Observed::Absent;
236    }
237    match checked("st", ["--version"], None) {
238        Ok(output) => Observed::Version(String::from_utf8_lossy(&output.stdout).trim().to_owned()),
239        Err(error) => Observed::Unreadable(format!("{error:#}")),
240    }
241}
242
243fn shantytown_version() -> Result<String> {
244    let output = checked("st", ["--version"], None)?;
245    let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
246    if !version.starts_with(&format!("st {SHANTYTOWN_VERSION}")) {
247        bail!("Shantytown version is incompatible: {version}");
248    }
249    Ok(version)
250}
251
252fn install_creel() -> Result<()> {
253    let root = creel_root()?;
254    if root.join("REVISION").exists() {
255        return Ok(());
256    }
257    let download = tempfile::tempdir().context("create Creel download directory")?;
258    let archive = download.path().join("creel.tar.gz");
259    download_https(
260        &format!("https://github.com/scbrown/creel/archive/{CREEL_REVISION}.tar.gz"),
261        &archive,
262    )?;
263    verify_fixed_checksum(&archive, CREEL_ARCHIVE_SHA256, "Creel archive")?;
264    fs::create_dir_all(&root)?;
265    checked(
266        "tar",
267        [
268            OsStr::new("-xzf"),
269            archive.as_os_str(),
270            OsStr::new("--strip-components=1"),
271            OsStr::new("-C"),
272            root.as_os_str(),
273        ],
274        None,
275    )?;
276    fs::write(root.join("REVISION"), format!("{CREEL_REVISION}\n"))?;
277    Ok(())
278}
279
280fn creel_root() -> Result<PathBuf> {
281    if let Some(path) = env::var_os("CABOODLE_CREEL_ROOT") {
282        return Ok(PathBuf::from(path));
283    }
284    Ok(home()?
285        .join(".local/share/caboodle/creel")
286        .join(CREEL_REVISION))
287}
288
289fn verify_creel_bundle() -> Result<()> {
290    let root = creel_root()?;
291    let revision = fs::read_to_string(root.join("REVISION")).context("read Creel revision")?;
292    if revision.trim() != CREEL_REVISION {
293        bail!("installed Creel revision does not match the reviewed bundle");
294    }
295    for path in [
296        "app/index.html",
297        "app/sw.js",
298        "app/wasm/pkg/creel_quipu_provider_bg.wasm",
299    ] {
300        if !root.join(path).is_file() {
301            bail!("installed Creel bundle is missing {path}");
302        }
303    }
304    Ok(())
305}
306
307fn verify_fixed_checksum(path: &Path, expected: &str, label: &str) -> Result<()> {
308    if crate::embedding::sha256_file(path)? != expected {
309        bail!("{label} checksum mismatch");
310    }
311    Ok(())
312}
313
314fn link_binary(target: &Path, name: &str) -> Result<()> {
315    let bin_dir = env::var_os("CARGO_HOME")
316        .map(PathBuf::from)
317        .unwrap_or(home()?.join(".cargo"))
318        .join("bin");
319    fs::create_dir_all(&bin_dir)?;
320    let link = bin_dir.join(name);
321    if link.exists() || link.symlink_metadata().is_ok() {
322        bail!("refusing to replace existing {}", link.display());
323    }
324    #[cfg(unix)]
325    std::os::unix::fs::symlink(target, &link)
326        .with_context(|| format!("link {name} into {}", bin_dir.display()))?;
327    #[cfg(not(unix))]
328    bail!("crew runtime installation currently requires a Unix host");
329    Ok(())
330}
331
332#[derive(Deserialize)]
333#[serde(deny_unknown_fields)]
334struct DoctorContract {
335    schema_version: u32,
336    overall: CheckStatus,
337    checks: Vec<DoctorCheck>,
338}
339
340#[derive(Deserialize)]
341#[serde(deny_unknown_fields)]
342struct DoctorCheck {
343    id: String,
344    status: CheckStatus,
345    severity: Severity,
346    evidence: String,
347    remediation: String,
348    redacted: bool,
349}
350
351#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
352#[serde(rename_all = "lowercase")]
353enum CheckStatus {
354    Pass,
355    Fail,
356    Unknown,
357}
358
359#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
360#[serde(rename_all = "lowercase")]
361enum Severity {
362    Required,
363    Advisory,
364}
365
366fn validate_doctor(path: &Path) -> Result<()> {
367    let contract: DoctorContract = read_json(path, "Creel doctor")?;
368    if contract.schema_version != 1 {
369        bail!(
370            "unsupported Creel doctor schema {}",
371            contract.schema_version
372        );
373    }
374    if contract.checks.is_empty() {
375        bail!("Creel doctor returned no checks");
376    }
377    let mut ids = HashSet::new();
378    for check in &contract.checks {
379        if check.id.trim().is_empty() || !ids.insert(&check.id) {
380            bail!("Creel doctor check IDs must be non-empty and unique");
381        }
382        if !check.redacted
383            || check.evidence.trim().is_empty()
384            || check.remediation.trim().is_empty()
385        {
386            bail!(
387                "Creel doctor check {} lacks redacted evidence/remediation",
388                check.id
389            );
390        }
391        if check.severity == Severity::Required && check.status != CheckStatus::Pass {
392            bail!("Creel required doctor check {} did not pass", check.id);
393        }
394    }
395    if contract.overall != CheckStatus::Pass {
396        bail!("Creel doctor aggregate is not pass");
397    }
398    Ok(())
399}
400
401#[derive(Deserialize)]
402#[serde(deny_unknown_fields)]
403struct AdmissionContract {
404    schema_version: u32,
405    verdict: AdmissionVerdict,
406    provider_window: AdmissionSignal,
407    device_tab_cap: AdmissionSignal,
408    signal_freshness: AdmissionSignal,
409    reason: String,
410    redacted: bool,
411}
412
413#[derive(Deserialize, PartialEq, Eq)]
414#[serde(rename_all = "lowercase")]
415enum AdmissionVerdict {
416    Admit,
417    Refuse,
418    Unknown,
419}
420
421#[derive(Deserialize)]
422#[serde(deny_unknown_fields)]
423struct AdmissionSignal {
424    status: CheckStatus,
425    evidence: String,
426}
427
428fn validate_admission(path: &Path) -> Result<()> {
429    let contract: AdmissionContract = read_json(path, "Creel admission")?;
430    if contract.schema_version != 1 {
431        bail!(
432            "unsupported Creel admission schema {}",
433            contract.schema_version
434        );
435    }
436    if !contract.redacted || contract.reason.trim().is_empty() {
437        bail!("Creel admission reason must be present and redacted");
438    }
439    for (name, signal) in [
440        ("provider_window", &contract.provider_window),
441        ("device_tab_cap", &contract.device_tab_cap),
442        ("signal_freshness", &contract.signal_freshness),
443    ] {
444        if signal.status != CheckStatus::Pass || signal.evidence.trim().is_empty() {
445            bail!("Creel admission signal {name} is not a measured pass");
446        }
447    }
448    if contract.verdict != AdmissionVerdict::Admit {
449        bail!("Creel governor did not admit this launch");
450    }
451    Ok(())
452}
453
454fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> Result<T> {
455    let body = fs::read_to_string(path).with_context(|| format!("read {label} contract"))?;
456    serde_json::from_str(&body).with_context(|| format!("parse {label} contract"))
457}
458
459#[cfg(test)]
460mod convergence_tests {
461    use super::{shantytown_convergence, Convergence, Observed};
462
463    #[test]
464    fn shantytown_never_converges_downward_or_on_a_guess() {
465        let v = |s: &str| Observed::Version(s.to_owned());
466        assert_eq!(
467            shantytown_convergence(&Observed::Absent),
468            Convergence::Install
469        );
470        assert_eq!(
471            shantytown_convergence(&Observed::Unreadable("exit status 42".into())),
472            Convergence::Decline
473        );
474        assert_eq!(shantytown_convergence(&v("st 0.4.0")), Convergence::Current);
475        assert_eq!(shantytown_convergence(&v("st 0.3.9")), Convergence::Install);
476        for ahead_or_unknown in ["st 0.7.0 (a9a0136-dirty)", "st 0.5.0", "st dev", "st"] {
477            assert_eq!(
478                shantytown_convergence(&v(ahead_or_unknown)),
479                Convergence::Decline,
480                "{ahead_or_unknown}"
481            );
482        }
483    }
484}