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 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
193fn 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#[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}