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    let output = checked("sha256sum", [path.as_os_str()], None)?;
309    let actual = String::from_utf8_lossy(&output.stdout)
310        .split_whitespace()
311        .next()
312        .context("sha256sum returned no digest")?
313        .to_owned();
314    if actual != expected {
315        bail!("{label} checksum mismatch");
316    }
317    Ok(())
318}
319
320fn link_binary(target: &Path, name: &str) -> Result<()> {
321    let bin_dir = env::var_os("CARGO_HOME")
322        .map(PathBuf::from)
323        .unwrap_or(home()?.join(".cargo"))
324        .join("bin");
325    fs::create_dir_all(&bin_dir)?;
326    let link = bin_dir.join(name);
327    if link.exists() || link.symlink_metadata().is_ok() {
328        bail!("refusing to replace existing {}", link.display());
329    }
330    #[cfg(unix)]
331    std::os::unix::fs::symlink(target, &link)
332        .with_context(|| format!("link {name} into {}", bin_dir.display()))?;
333    #[cfg(not(unix))]
334    bail!("crew runtime installation currently requires a Unix host");
335    Ok(())
336}
337
338#[derive(Deserialize)]
339#[serde(deny_unknown_fields)]
340struct DoctorContract {
341    schema_version: u32,
342    overall: CheckStatus,
343    checks: Vec<DoctorCheck>,
344}
345
346#[derive(Deserialize)]
347#[serde(deny_unknown_fields)]
348struct DoctorCheck {
349    id: String,
350    status: CheckStatus,
351    severity: Severity,
352    evidence: String,
353    remediation: String,
354    redacted: bool,
355}
356
357#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359enum CheckStatus {
360    Pass,
361    Fail,
362    Unknown,
363}
364
365#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
366#[serde(rename_all = "lowercase")]
367enum Severity {
368    Required,
369    Advisory,
370}
371
372fn validate_doctor(path: &Path) -> Result<()> {
373    let contract: DoctorContract = read_json(path, "Creel doctor")?;
374    if contract.schema_version != 1 {
375        bail!(
376            "unsupported Creel doctor schema {}",
377            contract.schema_version
378        );
379    }
380    if contract.checks.is_empty() {
381        bail!("Creel doctor returned no checks");
382    }
383    let mut ids = HashSet::new();
384    for check in &contract.checks {
385        if check.id.trim().is_empty() || !ids.insert(&check.id) {
386            bail!("Creel doctor check IDs must be non-empty and unique");
387        }
388        if !check.redacted
389            || check.evidence.trim().is_empty()
390            || check.remediation.trim().is_empty()
391        {
392            bail!(
393                "Creel doctor check {} lacks redacted evidence/remediation",
394                check.id
395            );
396        }
397        if check.severity == Severity::Required && check.status != CheckStatus::Pass {
398            bail!("Creel required doctor check {} did not pass", check.id);
399        }
400    }
401    if contract.overall != CheckStatus::Pass {
402        bail!("Creel doctor aggregate is not pass");
403    }
404    Ok(())
405}
406
407#[derive(Deserialize)]
408#[serde(deny_unknown_fields)]
409struct AdmissionContract {
410    schema_version: u32,
411    verdict: AdmissionVerdict,
412    provider_window: AdmissionSignal,
413    device_tab_cap: AdmissionSignal,
414    signal_freshness: AdmissionSignal,
415    reason: String,
416    redacted: bool,
417}
418
419#[derive(Deserialize, PartialEq, Eq)]
420#[serde(rename_all = "lowercase")]
421enum AdmissionVerdict {
422    Admit,
423    Refuse,
424    Unknown,
425}
426
427#[derive(Deserialize)]
428#[serde(deny_unknown_fields)]
429struct AdmissionSignal {
430    status: CheckStatus,
431    evidence: String,
432}
433
434fn validate_admission(path: &Path) -> Result<()> {
435    let contract: AdmissionContract = read_json(path, "Creel admission")?;
436    if contract.schema_version != 1 {
437        bail!(
438            "unsupported Creel admission schema {}",
439            contract.schema_version
440        );
441    }
442    if !contract.redacted || contract.reason.trim().is_empty() {
443        bail!("Creel admission reason must be present and redacted");
444    }
445    for (name, signal) in [
446        ("provider_window", &contract.provider_window),
447        ("device_tab_cap", &contract.device_tab_cap),
448        ("signal_freshness", &contract.signal_freshness),
449    ] {
450        if signal.status != CheckStatus::Pass || signal.evidence.trim().is_empty() {
451            bail!("Creel admission signal {name} is not a measured pass");
452        }
453    }
454    if contract.verdict != AdmissionVerdict::Admit {
455        bail!("Creel governor did not admit this launch");
456    }
457    Ok(())
458}
459
460fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> Result<T> {
461    let body = fs::read_to_string(path).with_context(|| format!("read {label} contract"))?;
462    serde_json::from_str(&body).with_context(|| format!("parse {label} contract"))
463}
464
465#[cfg(test)]
466mod convergence_tests {
467    use super::{shantytown_convergence, Convergence, Observed};
468
469    #[test]
470    fn shantytown_never_converges_downward_or_on_a_guess() {
471        let v = |s: &str| Observed::Version(s.to_owned());
472        assert_eq!(
473            shantytown_convergence(&Observed::Absent),
474            Convergence::Install
475        );
476        assert_eq!(
477            shantytown_convergence(&Observed::Unreadable("exit status 42".into())),
478            Convergence::Decline
479        );
480        assert_eq!(shantytown_convergence(&v("st 0.4.0")), Convergence::Current);
481        assert_eq!(shantytown_convergence(&v("st 0.3.9")), Convergence::Install);
482        for ahead_or_unknown in ["st 0.7.0 (a9a0136-dirty)", "st 0.5.0", "st dev", "st"] {
483            assert_eq!(
484                shantytown_convergence(&v(ahead_or_unknown)),
485                Convergence::Decline,
486                "{ahead_or_unknown}"
487            );
488        }
489    }
490}