Skip to main content

caboodle/
adapter.rs

1use std::{
2    env,
3    ffi::{OsStr, OsString},
4    fs,
5    path::{Path, PathBuf},
6    process::{Command, Output},
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use anyhow::{bail, Context, Result};
11use serde_json::{json, Value};
12
13use crate::model::{QuipuFlavor, ToolName};
14
15mod manifest;
16mod quipu_alias;
17pub(crate) use manifest::{
18    find_program, identify_member, prove_release, release_target, single_program, stage, verify_at,
19};
20
21pub trait Adapter {
22    fn name(&self) -> ToolName;
23    /// Release identity reviewed and pinned by this Caboodle build.
24    fn desired_version(&self) -> String;
25    fn install(&self) -> Result<()>;
26    fn version(&self) -> Result<String>;
27    fn verify(&self) -> Result<()>;
28
29    fn is_current(&self, installed: &str) -> bool {
30        installed == self.desired_version()
31    }
32}
33
34/// Why `install` cannot fetch a reviewed artifact for this host, if it cannot.
35/// Read-only: it inspects the platform, never the network. The same target
36/// functions drive the installers, so the answer cannot drift from them.
37pub fn release_blocker(name: ToolName, quipu_flavor: QuipuFlavor) -> Option<String> {
38    let result = match name {
39        ToolName::Quipu if quipu_flavor == QuipuFlavor::Release => quipu_release_target(),
40        ToolName::Bobbin => bobbin_release_target(),
41        ToolName::Yupana => yupana_release_target(),
42        ToolName::Member(name) => match crate::members::get(name) {
43            Some(m) => manifest::release_target(m),
44            None => return Some(format!("unknown stack member {name}")),
45        },
46        // Camayoc is a source archive, Desire Path and the lancedb Quipu
47        // flavor are source builds: their blockers are missing prerequisites.
48        // Named, never a wildcard: a new variant must decide (aegis-1i5h1j).
49        ToolName::Quipu | ToolName::Camayoc | ToolName::DesirePath => return None,
50    };
51    result.err().map(|error| format!("{error:#}"))
52}
53
54/// Host commands the install and verification steps of one tool shell out to.
55pub fn prerequisites(name: ToolName, quipu_flavor: QuipuFlavor) -> &'static [&'static str] {
56    match name {
57        ToolName::Quipu if quipu_flavor == QuipuFlavor::Lancedb => &["cargo"],
58        ToolName::Quipu => &["curl", "tar"],
59        // Camayoc's bootstrap is bash + python3, and it talks to Quipu with curl.
60        ToolName::Camayoc => &["curl", "tar", "sha256sum", "bash", "python3"],
61        ToolName::Bobbin => &["curl", "tar", "git"],
62        ToolName::Yupana => &["curl", "tar"],
63        ToolName::DesirePath => &["go"],
64        ToolName::Member(name) => crate::members::prerequisites(name),
65    }
66}
67
68/// The reviewed source build to run by hand where no release exists for a host.
69pub fn source_install_command(name: ToolName) -> Option<String> {
70    match name {
71        ToolName::Quipu => Some(format!("cargo {}", quipu_install_args(QuipuFlavor::Release).join(" "))),
72        ToolName::Yupana => Some(format!(
73            "cargo install --git https://github.com/scbrown/yupana --tag v{YUPANA_VERSION} --locked --features mcp"
74        )),
75        // A member installs only its reviewed release; there is no source path.
76        ToolName::Camayoc | ToolName::Bobbin | ToolName::DesirePath | ToolName::Member(_) => None,
77    }
78}
79
80/// Executables a tool puts on PATH, in the order a shell would look them up.
81pub fn programs(name: ToolName) -> &'static [&'static str] {
82    match name {
83        ToolName::Quipu => &["qp", "quipu", "quipu-server"],
84        ToolName::Camayoc => &[],
85        ToolName::Bobbin => &["bobbin"],
86        ToolName::Yupana => &["yupana"],
87        ToolName::DesirePath => &["dp"],
88        ToolName::Member(name) => crate::members::programs(name),
89    }
90}
91
92/// Every executable named `program` on `path`, in lookup order.
93pub fn which_all(program: &str, path: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
94    let Some(path) = path else {
95        return Vec::new();
96    };
97    let mut found: Vec<PathBuf> = Vec::new();
98    let mut targets: Vec<PathBuf> = Vec::new();
99    for candidate in env::split_paths(path).map(|directory| directory.join(program)) {
100        if !is_executable(&candidate) {
101            continue;
102        }
103        // A directory listed twice, or a symlink to a copy already seen, is the
104        // same binary rather than a shadowing one.
105        let target = candidate
106            .canonicalize()
107            .unwrap_or_else(|_| candidate.clone());
108        if !targets.contains(&target) {
109            targets.push(target);
110            found.push(candidate);
111        }
112    }
113    found
114}
115
116pub(crate) fn is_executable(path: &Path) -> bool {
117    let Ok(metadata) = path.metadata() else {
118        return false;
119    };
120    if !metadata.is_file() {
121        return false;
122    }
123    #[cfg(unix)]
124    {
125        use std::os::unix::fs::PermissionsExt;
126        metadata.permissions().mode() & 0o111 != 0
127    }
128    #[cfg(not(unix))]
129    true
130}
131
132/// What a shell actually runs for one managed program (aegis-70qlhs).
133///
134/// Versions are read from the MANAGED copy (`$CARGO_HOME/bin`), so a stale
135/// binary earlier on PATH can make `verify` report a tool current while the
136/// user runs something else. That happened: a fresh `cargo install` exited 0,
137/// the new binary sat on disk, and an older copy in `~/.local/bin` kept
138/// answering. Nothing said which copy ran.
139#[derive(Debug, PartialEq, Eq)]
140pub enum PathResolution {
141    /// PATH runs the managed copy, or there is no managed copy to compare.
142    Managed,
143    /// The program is not on PATH at all.
144    NotOnPath,
145    /// PATH runs a different file that reports the same `--version`.
146    SameBuild { runs: PathBuf, managed: PathBuf },
147    /// PATH runs a different file that reports a different `--version`.
148    Shadowed {
149        runs: PathBuf,
150        runs_version: String,
151        managed: PathBuf,
152        managed_version: String,
153    },
154}
155
156/// Resolve `program` the way a shell would, against the managed copy.
157pub fn path_resolution(program: &str, path: Option<&OsStr>) -> PathResolution {
158    let Some(managed) = managed_bin_dir().map(|dir| dir.join(program)) else {
159        return PathResolution::Managed;
160    };
161    if !is_executable(&managed) {
162        return PathResolution::Managed;
163    }
164    let Some(runs) = which_all(program, path).into_iter().next() else {
165        return PathResolution::NotOnPath;
166    };
167    let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
168    if canonical(&runs) == canonical(&managed) {
169        return PathResolution::Managed;
170    }
171    let version = |p: &Path| {
172        p.to_str()
173            .and_then(|s| read_version(s).ok())
174            .unwrap_or_else(|| "unreadable".to_owned())
175    };
176    let (runs_version, managed_version) = (version(&runs), version(&managed));
177    if runs_version == managed_version {
178        PathResolution::SameBuild { runs, managed }
179    } else {
180        PathResolution::Shadowed {
181            runs,
182            runs_version,
183            managed,
184            managed_version,
185        }
186    }
187}
188
189/// Where the release and cargo installers write binaries.
190pub fn managed_bin_dir() -> Option<PathBuf> {
191    env::var_os("CARGO_HOME")
192        .map(PathBuf::from)
193        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
194        .map(|home| home.join("bin"))
195}
196
197pub fn adapter(name: ToolName, quipu_flavor: QuipuFlavor) -> Box<dyn Adapter> {
198    match name {
199        ToolName::Quipu => Box::new(Quipu {
200            flavor: quipu_flavor,
201            external: None,
202        }),
203        ToolName::Camayoc => Box::new(Camayoc),
204        ToolName::Bobbin => Box::new(Bobbin),
205        ToolName::Yupana => Box::new(Yupana),
206        ToolName::DesirePath => Box::new(DesirePath),
207        ToolName::Member(name) => Box::new(manifest::ManifestAdapter(
208            crate::members::get(name)
209                .expect("a Member name is only minted for an embedded manifest"),
210        )),
211    }
212}
213
214/// Resolve external binaries once so identity and round trips use the same files.
215pub fn adapter_for_plan(name: ToolName, plan: &crate::model::Plan) -> Result<Box<dyn Adapter>> {
216    if name != ToolName::Quipu || !plan.external_quipu {
217        return Ok(adapter(name, plan.quipu_flavor));
218    }
219    let path = env::var_os("PATH");
220    let resolve = |name| -> Result<PathBuf> {
221        which_all(name, path.as_deref())
222            .into_iter()
223            .next()
224            .with_context(|| format!("external Quipu requires {name} on PATH"))?
225            .canonicalize()
226            .with_context(|| format!("resolve external {name}"))
227    };
228    Ok(Box::new(Quipu {
229        flavor: plan.quipu_flavor,
230        external: Some((resolve("quipu")?, resolve("quipu-server")?)),
231    }))
232}
233
234fn output<P, I, S>(program: P, args: I, cwd: Option<&Path>) -> Result<Output>
235where
236    P: AsRef<OsStr>,
237    I: IntoIterator<Item = S>,
238    S: AsRef<OsStr>,
239{
240    let args: Vec<OsString> = args
241        .into_iter()
242        .map(|arg| arg.as_ref().to_os_string())
243        .collect();
244    let program = program.as_ref();
245    let mut command = Command::new(program);
246    command.args(&args);
247    if let Some(cwd) = cwd {
248        command.current_dir(cwd);
249    }
250    command.output().with_context(|| {
251        format!(
252            "run {} {}",
253            program.to_string_lossy(),
254            args.iter()
255                .map(|arg| arg.to_string_lossy())
256                .collect::<Vec<_>>()
257                .join(" ")
258        )
259    })
260}
261
262pub(crate) fn checked<P, I, S>(program: P, args: I, cwd: Option<&Path>) -> Result<Output>
263where
264    P: AsRef<OsStr>,
265    I: IntoIterator<Item = S>,
266    S: AsRef<OsStr>,
267{
268    let program = program.as_ref();
269    let result = output(program, args, cwd)?;
270    if !result.status.success() {
271        bail!(
272            "{} failed ({})\nstdout:\n{}\nstderr:\n{}",
273            program.to_string_lossy(),
274            result.status,
275            String::from_utf8_lossy(&result.stdout).trim(),
276            String::from_utf8_lossy(&result.stderr).trim()
277        );
278    }
279    Ok(result)
280}
281
282fn read_version(program: &str) -> Result<String> {
283    let result = checked(program, ["--version"], None)?;
284    let version = String::from_utf8_lossy(&result.stdout).trim().to_owned();
285    if version.is_empty() {
286        bail!("{} --version returned no version", program);
287    }
288    Ok(version)
289}
290
291fn read_cargo_version(program: &str) -> Result<String> {
292    let program_path = cargo_program(program);
293    read_version(
294        program_path
295            .to_str()
296            .with_context(|| format!("{} path is not valid UTF-8", program_path.display()))?,
297    )
298}
299
300fn cargo_program(program: &str) -> PathBuf {
301    let cargo_home = env::var_os("CARGO_HOME")
302        .map(PathBuf::from)
303        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")));
304    if let Some(path) = cargo_home.map(|home| home.join("bin").join(program)) {
305        if path.is_file() {
306            return path;
307        }
308    }
309    PathBuf::from(program)
310}
311
312fn require_minimum_version(output: &str, minimum: (u64, u64, u64), program: &str) -> Result<()> {
313    let raw = output
314        .split_whitespace()
315        .nth(1)
316        .with_context(|| format!("{program} --version omitted a semantic version"))?;
317    let mut parts = raw.trim_start_matches('v').split('.');
318    let found = (
319        parts.next().and_then(|value| value.parse().ok()),
320        parts.next().and_then(|value| value.parse().ok()),
321        parts.next().and_then(|value| value.parse().ok()),
322    );
323    let found = match found {
324        (Some(major), Some(minor), Some(patch)) => (major, minor, patch),
325        _ => bail!("{program} returned an invalid semantic version: {raw}"),
326    };
327    if found < minimum {
328        bail!(
329            "{program} {}.{}.{} is too old; CABOODLE requires at least {}.{}.{}",
330            found.0,
331            found.1,
332            found.2,
333            minimum.0,
334            minimum.1,
335            minimum.2
336        );
337    }
338    Ok(())
339}
340
341struct Quipu {
342    flavor: QuipuFlavor,
343    external: Option<(PathBuf, PathBuf)>,
344}
345
346const QUIPU_VERSION: &str = "0.9.0";
347
348/// The reviewed feature set per flavor. Both flavors build the identical
349/// pinned revision — only the feature list differs — so choosing `lancedb`
350/// can never smuggle in an unreviewed Quipu.
351fn quipu_features(flavor: QuipuFlavor) -> &'static str {
352    match flavor {
353        QuipuFlavor::Release => "full",
354        QuipuFlavor::Lancedb => "full,lancedb",
355    }
356}
357
358impl Adapter for Quipu {
359    fn name(&self) -> ToolName {
360        ToolName::Quipu
361    }
362
363    fn desired_version(&self) -> String {
364        match self.flavor {
365            QuipuFlavor::Release => format!("quipu {QUIPU_VERSION}; quipu-server {QUIPU_VERSION}"),
366            QuipuFlavor::Lancedb => {
367                format!("quipu {QUIPU_VERSION}; quipu-server {QUIPU_VERSION} (+lancedb)")
368            }
369        }
370    }
371
372    fn is_current(&self, _installed: &str) -> bool {
373        // version() already enforces the reviewed minimum for both binaries;
374        // a newer compatible Quipu release is not a downgrade candidate.
375        // Flavor drift is invisible here — `quipu --version` cannot say which
376        // features were compiled in — so verify() is what catches it.
377        true
378    }
379
380    fn install(&self) -> Result<()> {
381        if self.external.is_some() {
382            bail!("external Quipu is not owned by Caboodle; installation refused");
383        }
384        preflight_owned_quipu_alias()?;
385        match self.flavor {
386            QuipuFlavor::Release => install_quipu_release(),
387            // The published archive is the reviewed `full` build. LanceDB is
388            // an explicit non-default flavor and still needs a source build
389            // until Quipu publishes a separately identifiable artifact.
390            QuipuFlavor::Lancedb => {
391                let mut args: Vec<OsString> = quipu_install_args(self.flavor)
392                    .iter()
393                    .map(OsString::from)
394                    .collect();
395                args.push("--root".into());
396                args.push(
397                    owned_quipu_bin()?
398                        .parent()
399                        .context("cargo bin has no root")?
400                        .as_os_str()
401                        .into(),
402                );
403                checked("cargo", args, None)?;
404                ensure_owned_quipu_alias()
405            }
406        }
407    }
408
409    fn version(&self) -> Result<String> {
410        // `cargo install` writes these binaries under CARGO_HOME, which may be
411        // later on PATH than a legacy Quipu install. Read back the location we
412        // actually update so a successful install cannot be reported as stale.
413        let client = if let Some((client, _)) = &self.external {
414            read_version(
415                client
416                    .to_str()
417                    .context("external Quipu path is not UTF-8")?,
418            )?
419        } else {
420            read_cargo_version("quipu")?
421        };
422        require_minimum_version(&client, (0, 3, 27), "quipu")?;
423        let server = if let Some((_, server)) = &self.external {
424            read_version(
425                server
426                    .to_str()
427                    .context("external server path is not UTF-8")?,
428            )?
429        } else {
430            read_cargo_version("quipu-server")?
431        };
432        require_minimum_version(&server, (0, 3, 27), "quipu-server")?;
433        Ok(match &self.external {
434            Some((c, s)) => format!(
435                "{client}; {server}; external executables: {} | {}",
436                c.display(),
437                s.display()
438            ),
439            None => format!("{client}; {server}"),
440        })
441    }
442
443    fn verify(&self) -> Result<()> {
444        let client = self
445            .external
446            .as_ref()
447            .map(|(c, _)| c.clone())
448            .map(Ok)
449            .unwrap_or_else(selected_quipu_client)?;
450        verify_quipu_alias(&client)?;
451        self.verify_core()
452    }
453}
454
455impl Quipu {
456    fn verify_core(&self) -> Result<()> {
457        if self.flavor == QuipuFlavor::Lancedb {
458            verify_quipu_lancedb_feature()?;
459        }
460        let client = self
461            .external
462            .as_ref()
463            .map(|(client, _)| client.as_path())
464            .unwrap_or_else(|| Path::new("quipu"));
465        let root = tempfile::tempdir().context("create quipu verification directory")?;
466        let db = root.path().join("verify.db");
467        let episode = root.path().join("episode.json");
468        let marker = "caboodle-verify-roundtrip";
469        fs::write(
470            &episode,
471            format!(
472                r#"{{"name":"caboodle verification","episode_body":"functional round trip","source":"caboodle","group_id":"caboodle-verification","nodes":[{{"name":"{marker}","type":"Verification","description":"caboodle isolated verification"}}],"edges":[]}}"#
473            ),
474        )
475        .context("write quipu verification episode")?;
476
477        let query = format!(
478            "SELECT ?s ?label WHERE {{ ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label . FILTER(?label = \"{marker}\") }}"
479        );
480        let before = checked(
481            client,
482            [
483                OsStr::new("read"),
484                OsStr::new(&query),
485                OsStr::new("--db"),
486                db.as_os_str(),
487            ],
488            None,
489        )?;
490        if String::from_utf8_lossy(&before.stdout).contains(marker) {
491            bail!("quipu negative control unexpectedly found the verification node");
492        }
493
494        checked(
495            client,
496            [
497                OsStr::new("episode"),
498                episode.as_os_str(),
499                OsStr::new("--db"),
500                db.as_os_str(),
501            ],
502            None,
503        )?;
504        let after = checked(
505            client,
506            [
507                OsStr::new("read"),
508                OsStr::new(&query),
509                OsStr::new("--db"),
510                db.as_os_str(),
511            ],
512            None,
513        )?;
514        if !String::from_utf8_lossy(&after.stdout).contains(marker) {
515            bail!("quipu episode landed but read-back did not find the verification node");
516        }
517        // The stack's shared vocabulary rides with Quipu: the pinned Quechua
518        // release must load and resolve a term, with an absent-term control.
519        crate::vocabulary::verify_at(client).context("Quechua vocabulary verification")?;
520        Ok(())
521    }
522}
523
524/// Prove exactly the selected pair, using a throwaway client store, never a live server.
525pub(crate) fn verify_quipu_pair(client: &Path, server: &Path) -> Result<()> {
526    Quipu {
527        flavor: QuipuFlavor::Release,
528        external: Some((client.to_path_buf(), server.to_path_buf())),
529    }
530    .verify_core()?;
531    let help = checked(server, ["--help"], None)?;
532    let help = String::from_utf8_lossy(&help.stdout);
533    if !help.contains("--db") || !help.contains("--bind") {
534        bail!("Quipu server omitted isolated database/bind command contract");
535    }
536    Ok(())
537}
538
539pub(crate) fn quipu_release_target() -> Result<&'static str> {
540    quipu_release_target_for(env::consts::ARCH, env::consts::OS)
541}
542
543fn quipu_release_target_for(arch: &str, os: &str) -> Result<&'static str> {
544    match (arch, os) {
545        ("x86_64", "linux") => Ok("x86_64-unknown-linux-gnu"),
546        ("aarch64", "linux") => Ok("aarch64-unknown-linux-gnu"),
547        ("x86_64", "macos") => Ok("x86_64-apple-darwin"),
548        ("aarch64", "macos") => Ok("aarch64-apple-darwin"),
549        (arch, os) => bail!("Quipu has no checksummed CABOODLE release for {arch}-{os}"),
550    }
551}
552
553fn fetch_quipu_release(target: &str, download: &Path) -> Result<PathBuf> {
554    // release-please tags the crate package; the release workflow prefixes
555    // that complete tag again for both the asset and its top-level directory.
556    let tag = format!("quipu-ai-v{QUIPU_VERSION}");
557    let directory = format!("quipu-{tag}-{target}");
558    let archive_name = format!("{directory}.tar.gz");
559    let base = format!("https://github.com/scbrown/quipu/releases/download/{tag}");
560    let archive = download.join(&archive_name);
561    let checksum = download.join(format!("{archive_name}.sha256"));
562    download_https(&format!("{base}/{archive_name}"), &archive)?;
563    download_https(&format!("{base}/{archive_name}.sha256"), &checksum)?;
564    verify_checksum(&archive, &checksum)?;
565
566    let unpacked = download.join(directory);
567    checked(
568        "tar",
569        [
570            OsStr::new("-xzf"),
571            archive.as_os_str(),
572            OsStr::new("-C"),
573            download.as_os_str(),
574        ],
575        None,
576    )?;
577    for program in ["quipu", "quipu-server"] {
578        if !unpacked.join(program).is_file() {
579            bail!("Quipu release archive omitted {program}");
580        }
581    }
582    Ok(unpacked)
583}
584
585fn install_quipu_release() -> Result<()> {
586    let target = quipu_release_target()?;
587    let download = tempfile::tempdir().context("create Quipu download directory")?;
588    let unpacked = fetch_quipu_release(target, download.path())?;
589    let home = env::var_os("HOME").context("HOME is required to install Quipu")?;
590    let bin = env::var_os("CARGO_HOME")
591        .map(PathBuf::from)
592        .unwrap_or_else(|| PathBuf::from(home).join(".cargo"))
593        .join("bin");
594    fs::create_dir_all(&bin).with_context(|| format!("create {}", bin.display()))?;
595    for program in ["quipu", "quipu-server"] {
596        let source = unpacked.join(program);
597        if !source.is_file() {
598            bail!("Quipu release archive omitted {}", source.display());
599        }
600        fs::copy(&source, bin.join(program))
601            .with_context(|| format!("install checksummed Quipu binary {program}"))?;
602    }
603    ensure_owned_quipu_alias()
604}
605
606fn owned_quipu_bin() -> Result<PathBuf> {
607    env::var_os("CARGO_HOME")
608        .map(PathBuf::from)
609        .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
610        .map(|root| root.join("bin"))
611        .context("HOME or CARGO_HOME is required for owned Quipu installation")
612}
613
614pub(crate) fn preflight_owned_quipu_alias() -> Result<()> {
615    quipu_alias::preflight(&owned_quipu_bin()?).map_err(anyhow::Error::msg)
616}
617
618pub(crate) fn ensure_owned_quipu_alias() -> Result<()> {
619    quipu_alias::ensure(&owned_quipu_bin()?).map_err(anyhow::Error::msg)
620}
621
622/// Post-install self-healing for the owned Quipu directory only; an externally
623/// installed Quipu elsewhere is never touched (aegis-61f).
624#[cfg(unix)]
625pub fn repair_owned_quipu_alias() -> Result<String> {
626    use quipu_alias::Repair;
627    let bin = owned_quipu_bin()?;
628    let qp = bin.join("qp");
629    Ok(
630        match quipu_alias::repair(&bin).map_err(anyhow::Error::msg)? {
631            Repair::NoQuipu => format!("no owned quipu in {}; nothing to repair", bin.display()),
632            Repair::Current => format!("{} -> quipu already current", qp.display()),
633            Repair::Created => format!("created {} -> quipu", qp.display()),
634            Repair::Retargeted => format!("retargeted {} -> quipu", qp.display()),
635            Repair::ForeignPreserved => format!(
636                "{} is not a symlink; preserved it, qp NOT repaired",
637                qp.display()
638            ),
639            Repair::PackageManaged(package) => format!(
640                "quipu in {} is managed by apt (package {package}); qp left to the package",
641                bin.display()
642            ),
643        },
644    )
645}
646
647fn selected_quipu_client() -> Result<PathBuf> {
648    let owned = cargo_program("quipu");
649    if owned.is_file() {
650        return Ok(owned);
651    }
652    which_all("quipu", env::var_os("PATH").as_deref())
653        .into_iter()
654        .next()
655        .context("Quipu CLI is not on PATH")
656}
657
658pub(crate) fn ensure_existing_quipu_alias() -> Result<()> {
659    let client = selected_quipu_client()?;
660    let bin = client
661        .parent()
662        .context("selected Quipu CLI has no directory")?;
663    quipu_alias::ensure(bin).map_err(anyhow::Error::msg)
664}
665
666pub(crate) fn verify_existing_quipu_alias() -> Result<()> {
667    verify_quipu_alias(&selected_quipu_client()?)
668}
669
670fn verify_quipu_alias(client: &Path) -> Result<()> {
671    let bin = client.parent().context("Quipu CLI has no directory")?;
672    let alias = bin.join("qp");
673    let resolved = client.canonicalize()?;
674    let resolved_bin = resolved.parent().context("Quipu CLI has no directory")?;
675    // The relative link may sit beside the selected quipu or beside the file it
676    // resolves to: a package manager (Homebrew) links bin/ into its own tree,
677    // and the formula owns `qp -> quipu` there (aegis-6f1).
678    let relative =
679        |dir: &Path| fs::read_link(dir.join("qp")).ok().as_deref() == Some(Path::new("quipu"));
680    if !relative(bin) && !relative(resolved_bin) {
681        bail!(
682            "Quipu verification requires relative qp -> quipu in {}",
683            bin.display()
684        );
685    }
686    // The qp a user types (beside the selected quipu) must be the same program.
687    if alias.canonicalize().ok().as_deref() != Some(resolved.as_path()) {
688        bail!("qp and quipu do not resolve to the same executable");
689    }
690    if read_version(alias.to_str().context("qp path is not UTF-8")?)?
691        != read_version(client.to_str().context("Quipu path is not UTF-8")?)?
692    {
693        bail!("qp and quipu version outputs disagree");
694    }
695    Ok(())
696}
697
698fn quipu_install_args(flavor: QuipuFlavor) -> [&'static str; 8] {
699    [
700        "install",
701        "quipu-ai",
702        "--version",
703        QUIPU_VERSION,
704        "--locked",
705        "--features",
706        quipu_features(flavor),
707        "--bins",
708    ]
709}
710
711/// The plan asked for the lancedb flavor. `quipu --version` cannot say which
712/// features were compiled in, so ask the running server's per-feature compile
713/// map — the only read-back that can prove the flavor, as opposed to trusting
714/// that `cargo install --features lancedb` exited zero.
715fn verify_quipu_lancedb_feature() -> Result<()> {
716    let server = env::var("QUIPU_SERVER").unwrap_or_else(|_| "http://localhost:3030".to_owned());
717    let version = curl_get_json(&format!("{server}/version"))
718        .context("read the Quipu server per-feature compile map")?;
719    require_compiled_feature(&version, "lancedb")
720}
721
722fn require_compiled_feature(version: &Value, feature: &str) -> Result<()> {
723    let compiled = version
724        .get("features")
725        .and_then(Value::as_object)
726        .with_context(|| {
727            format!(
728                "Quipu /version returned no per-feature compile map; cannot prove the {feature} flavor"
729            )
730        })?;
731    if compiled.get(feature).and_then(Value::as_bool) != Some(true) {
732        bail!(
733            "the plan requires the {feature} flavor but the running Quipu was compiled without it; \
734             reinstall the reviewed revision with `cargo install quipu-ai --version {QUIPU_VERSION} \
735             --locked --features full,{feature} --bins`"
736        );
737    }
738    Ok(())
739}
740
741struct Bobbin;
742struct Yupana;
743struct DesirePath;
744
745struct Camayoc;
746const BOBBIN_VERSION: &str = "0.27.3";
747
748impl Adapter for Camayoc {
749    fn name(&self) -> ToolName {
750        ToolName::Camayoc
751    }
752
753    fn desired_version(&self) -> String {
754        format!("camayoc {CAMAYOC_REVISION}")
755    }
756
757    fn install(&self) -> Result<()> {
758        install_camayoc_bundle()
759    }
760
761    fn version(&self) -> Result<String> {
762        let revision = fs::read_to_string(camayoc_root()?.join("REVISION"))
763            .context("read installed Camayoc revision")?;
764        let revision = revision.trim();
765        if revision.is_empty() {
766            bail!("installed Camayoc revision is empty");
767        }
768        Ok(format!("camayoc {revision}"))
769    }
770
771    fn verify(&self) -> Result<()> {
772        let root = camayoc_root()?;
773        // Isolated like every other adapter (aegis-ro425e.2): bootstrap is pointed at a
774        // free localhost port inside a tempdir, so it starts its OWN quipu-server from
775        // <tempdir>/.quipu instead of loading ontology, shapes and a marker into whatever
776        // QUIPU_SERVER names. Bootstrapping the user's real server is a separate, explicit
777        // step: run the bundle's scripts/bootstrap.sh yourself.
778        let scratch = tempfile::tempdir().context("create Camayoc verification directory")?;
779        let port = free_local_port()?;
780        let server = format!("http://127.0.0.1:{port}");
781        let _guard = ScratchServer(scratch.path().join(".quipu/server.pid"));
782        let bootstrap = Command::new("bash")
783            .arg(root.join("scripts/bootstrap.sh"))
784            .current_dir(scratch.path())
785            .env("QUIPU_SERVER", &server)
786            .env("CLAUDE_PROJECT_DIR", scratch.path())
787            .env_remove("QUIPU_AUTH_TOKEN")
788            .output()
789            .context("run Camayoc bootstrap against a scratch Quipu")?;
790        if !bootstrap.status.success() {
791            bail!(
792                "Camayoc bootstrap failed against scratch Quipu {server} ({})\nstdout:\n{}\nstderr:\n{}",
793                bootstrap.status,
794                String::from_utf8_lossy(&bootstrap.stdout).trim(),
795                String::from_utf8_lossy(&bootstrap.stderr).trim()
796            );
797        }
798        if !scratch.path().join(".quipu/server.pid").exists() {
799            bail!("Camayoc bootstrap did not start a scratch Quipu at {server}; refusing to verify against a server caboodle does not own");
800        }
801        verify_camayoc_first_ingest(&root, &server)
802    }
803}
804
805/// Stops the scratch quipu-server Camayoc's bootstrap started, on every exit path.
806struct ScratchServer(PathBuf);
807
808impl Drop for ScratchServer {
809    fn drop(&mut self) {
810        if let Ok(pid) = fs::read_to_string(&self.0) {
811            let pid = pid.trim();
812            if !pid.is_empty() && pid.chars().all(|c| c.is_ascii_digit()) {
813                let _ = Command::new("kill").arg(pid).status();
814            }
815        }
816    }
817}
818
819fn free_local_port() -> Result<u16> {
820    let listener =
821        std::net::TcpListener::bind("127.0.0.1:0").context("reserve a free localhost port")?;
822    Ok(listener.local_addr()?.port())
823}
824
825impl Adapter for Bobbin {
826    fn name(&self) -> ToolName {
827        ToolName::Bobbin
828    }
829
830    fn desired_version(&self) -> String {
831        format!("bobbin {BOBBIN_VERSION}")
832    }
833
834    fn is_current(&self, installed: &str) -> bool {
835        installed.strip_prefix("bobbin ").is_some_and(|rest| {
836            rest == BOBBIN_VERSION || rest.starts_with(&format!("{BOBBIN_VERSION} "))
837        })
838    }
839
840    fn install(&self) -> Result<()> {
841        install_bobbin_release()
842    }
843
844    fn version(&self) -> Result<String> {
845        read_version("bobbin")
846    }
847
848    fn verify(&self) -> Result<()> {
849        let root = tempfile::tempdir().context("create bobbin verification repository")?;
850        let nonce = SystemTime::now()
851            .duration_since(UNIX_EPOCH)
852            .context("system clock predates Unix epoch")?
853            .as_nanos();
854        let repo = format!("caboodle-verify-{}-{nonce}", std::process::id());
855        let marker = format!("caboodle_verify_marker_{}_{nonce}", std::process::id());
856        checked("git", ["init", "--quiet"], Some(root.path()))?;
857        checked("bobbin", ["init"], Some(root.path()))?;
858        fs::write(
859            root.path().join("control.rs"),
860            "/// Proves the negative-control index is live.\npub fn caboodle_control() -> usize {\n    1\n}\n",
861        )
862        .context("write bobbin negative-control fixture")?;
863        checked(
864            "bobbin",
865            [
866                "index",
867                ".",
868                "--source",
869                ".",
870                "--repo",
871                &repo,
872                "--skip-calibrate",
873            ],
874            Some(root.path()),
875        )?;
876
877        let before = checked(
878            "bobbin",
879            ["grep", &marker, "--repo", &repo, "--json"],
880            Some(root.path()),
881        )?;
882        if bobbin_result_count(&before.stdout)? != 0 {
883            bail!("bobbin negative control unexpectedly found the fixture marker");
884        }
885
886        fs::write(
887            root.path().join("fixture.rs"),
888            format!(
889                "/// A unique CABOODLE verification routine.\npub fn {marker}() -> &'static str {{\n    \"indexed\"\n}}\n"
890            ),
891        )
892        .context("write bobbin verification fixture")?;
893        checked(
894            "bobbin",
895            [
896                "index",
897                ".",
898                "--source",
899                ".",
900                "--repo",
901                &repo,
902                "--skip-calibrate",
903            ],
904            Some(root.path()),
905        )?;
906        let after = checked(
907            "bobbin",
908            ["grep", &marker, "--repo", &repo, "--json"],
909            Some(root.path()),
910        )?;
911        if bobbin_result_count(&after.stdout)? == 0 {
912            bail!("bobbin indexed the fixture but search did not return its marker");
913        }
914        Ok(())
915    }
916}
917
918const YUPANA_VERSION: &str = "0.12.7";
919
920impl Adapter for Yupana {
921    fn name(&self) -> ToolName {
922        ToolName::Yupana
923    }
924
925    fn desired_version(&self) -> String {
926        format!("yupana {YUPANA_VERSION}")
927    }
928
929    fn install(&self) -> Result<()> {
930        let (target, checksum) = yupana_release_for(env::consts::ARCH, env::consts::OS)?;
931        let root = tempfile::tempdir().context("create Yupana download directory")?;
932        fetch_yupana_release(target, checksum, root.path())?;
933        let home = env::var_os("HOME").context("HOME is required to install Yupana")?;
934        let bin = env::var_os("CARGO_HOME")
935            .map(PathBuf::from)
936            .unwrap_or_else(|| PathBuf::from(home).join(".cargo"))
937            .join("bin");
938        fs::create_dir_all(&bin).with_context(|| format!("create {}", bin.display()))?;
939        fs::copy(root.path().join("yupana"), bin.join("yupana"))
940            .context("install checksummed Yupana binary")?;
941        Ok(())
942    }
943
944    fn version(&self) -> Result<String> {
945        let version = read_version("yupana")?;
946        require_minimum_version(&version, (0, 6, 4), "yupana")?;
947        Ok(version)
948    }
949
950    fn verify(&self) -> Result<()> {
951        let root = tempfile::tempdir().context("create Yupana verification repository")?;
952        let state = root.path().join("state");
953        fs::write(
954            root.path().join("control.rs"),
955            "pub fn caboodle_yupana_control() -> usize { 1 }\n",
956        )?;
957        yupana_checked(root.path(), &state, ["analyze", "."])?;
958        let before = yupana_checked(
959            root.path(),
960            &state,
961            ["callers", "caboodle_yupana_target", "."],
962        )?;
963        if String::from_utf8_lossy(&before.stdout).contains("caboodle_yupana_caller") {
964            bail!("Yupana negative control unexpectedly found the fixture caller");
965        }
966        fs::write(
967            root.path().join("fixture.rs"),
968            "pub fn caboodle_yupana_target() -> usize { 1 }\npub fn caboodle_yupana_caller() -> usize { caboodle_yupana_target() }\n",
969        )?;
970        yupana_checked(root.path(), &state, ["analyze", "."])?;
971        let after = yupana_checked(
972            root.path(),
973            &state,
974            ["callers", "caboodle_yupana_target", "."],
975        )?;
976        if !String::from_utf8_lossy(&after.stdout).contains("caboodle_yupana_caller") {
977            bail!("Yupana analyzed the fixture but callers did not return its caller");
978        }
979        Ok(())
980    }
981}
982
983fn yupana_release_target() -> Result<&'static str> {
984    yupana_release_for(env::consts::ARCH, env::consts::OS).map(|(target, _)| target)
985}
986
987// Digests of the published v0.12.7 archives, verified against SHA256SUMS.
988fn yupana_release_for(arch: &str, os: &str) -> Result<(&'static str, &'static str)> {
989    match (arch, os) {
990        ("x86_64", "linux") => Ok((
991            "x86_64-linux-gnu",
992            "44bfefb529a5977cd6ced67e662ca1cab87ec53e62a4cdf173ebbb367b5f8667",
993        )),
994        ("aarch64", "macos") => Ok((
995            "aarch64-apple-darwin",
996            "7f02e1c96b97a21abbc5478341d5ee44d10ea860b803e45d721b1c5a66647770",
997        )),
998        ("x86_64", "macos") => Ok((
999            "x86_64-apple-darwin",
1000            "94b872a6cd3ea9de4bf4271b1d16fa569f1979b4d216e0fd307d88e1b66364a2",
1001        )),
1002        _ => bail!("Yupana v{YUPANA_VERSION} has no checksummed CABOODLE release for {arch}-{os}"),
1003    }
1004}
1005
1006fn fetch_yupana_release(target: &str, checksum: &str, root: &Path) -> Result<()> {
1007    let archive_name = format!("yupana-v{YUPANA_VERSION}-{target}.tar.gz");
1008    let archive = root.join(&archive_name);
1009    download_https(
1010        &format!(
1011            "https://github.com/scbrown/yupana/releases/download/v{YUPANA_VERSION}/{archive_name}"
1012        ),
1013        &archive,
1014    )?;
1015    verify_yupana_checksum(&archive, checksum)?;
1016    checked(
1017        "tar",
1018        [
1019            OsStr::new("-xzf"),
1020            archive.as_os_str(),
1021            OsStr::new("-C"),
1022            root.as_os_str(),
1023        ],
1024        None,
1025    )?;
1026    Ok(())
1027}
1028
1029fn verify_yupana_checksum(archive: &Path, expected: &str) -> Result<()> {
1030    if manifest::sha256_file(archive)? != expected {
1031        bail!("Yupana release checksum mismatch");
1032    }
1033    Ok(())
1034}
1035
1036fn yupana_checked<I, S>(cwd: &Path, state: &Path, args: I) -> Result<Output>
1037where
1038    I: IntoIterator<Item = S>,
1039    S: AsRef<OsStr>,
1040{
1041    let home = cwd.join("home");
1042    fs::create_dir_all(&home)?;
1043    let mut command = Command::new("yupana");
1044    command
1045        .args(args)
1046        .current_dir(cwd)
1047        .env("HOME", home)
1048        .env("XDG_STATE_HOME", state);
1049    let result = command
1050        .output()
1051        .context("run isolated Yupana verification")?;
1052    if !result.status.success() {
1053        bail!(
1054            "isolated Yupana verification failed ({}): {}",
1055            result.status,
1056            String::from_utf8_lossy(&result.stderr).trim()
1057        );
1058    }
1059    Ok(result)
1060}
1061
1062// Review and install the executable the user's shell actually selects. CARGO_HOME
1063// must not silently override a different dp on PATH (aegis-fnp1wp).
1064fn desire_path_program() -> Result<PathBuf> {
1065    if let Some(path) = env::var_os("PATH") {
1066        for directory in env::split_paths(&path) {
1067            let candidate = directory.join("dp");
1068            if candidate.is_file() {
1069                #[cfg(unix)]
1070                {
1071                    use std::os::unix::fs::PermissionsExt;
1072                    if candidate.metadata()?.permissions().mode() & 0o111 == 0 {
1073                        continue;
1074                    }
1075                }
1076                return fs::canonicalize(candidate).context("resolve Desire Path executable");
1077            }
1078        }
1079    }
1080    let home = env::var_os("HOME").context("HOME is required to install Desire Path")?;
1081    Ok(PathBuf::from(home).join(".local/bin/dp"))
1082}
1083
1084const DESIRE_PATH_REVISION: &str = "1f457ec2f30c4a0d47b6f4d1b4cc0f3078f39662";
1085const DESIRE_PATH_VERSION: &str = "v0.3.2";
1086
1087impl Adapter for DesirePath {
1088    fn name(&self) -> ToolName {
1089        ToolName::DesirePath
1090    }
1091
1092    fn desired_version(&self) -> String {
1093        format!("dp {DESIRE_PATH_VERSION} ({})", &DESIRE_PATH_REVISION[..7])
1094    }
1095
1096    fn is_current(&self, installed: &str) -> bool {
1097        let expected = self.desired_version();
1098        installed.trim_start_matches("dp ").trim_start_matches('v')
1099            == expected.trim_start_matches("dp ").trim_start_matches('v')
1100    }
1101
1102    fn install(&self) -> Result<()> {
1103        let executable = desire_path_program()?;
1104        let bin = executable
1105            .parent()
1106            .context("Desire Path needs an install directory")?;
1107        fs::create_dir_all(bin).with_context(|| format!("create {}", bin.display()))?;
1108        let ldflags = format!(
1109            "-X github.com/scbrown/desire-path/internal/cli.Version={DESIRE_PATH_VERSION} -X github.com/scbrown/desire-path/internal/cli.Commit={DESIRE_PATH_REVISION}"
1110        );
1111        let mut command = Command::new("go");
1112        command
1113            .args([
1114                "install",
1115                "-ldflags",
1116                &ldflags,
1117                &format!("github.com/scbrown/desire-path/cmd/dp@{DESIRE_PATH_REVISION}"),
1118            ])
1119            .env("GOBIN", bin);
1120        let result = command
1121            .output()
1122            .context("build pinned Desire Path revision")?;
1123        if !result.status.success() {
1124            bail!(
1125                "Desire Path install failed ({}): {}",
1126                result.status,
1127                String::from_utf8_lossy(&result.stderr).trim()
1128            );
1129        }
1130        Ok(())
1131    }
1132
1133    fn version(&self) -> Result<String> {
1134        let result = checked(desire_path_program()?, ["version"], None)?;
1135        let version = String::from_utf8_lossy(&result.stdout).trim().to_owned();
1136        // Identity only, like every other adapter: `is_current` and package
1137        // `standing` judge it against the pin. Refusing here made a brew-owned
1138        // dp that is AHEAD of the pin unreadable, so the member failed (aegis-fv4).
1139        if version.is_empty() {
1140            bail!("dp version returned no version");
1141        }
1142        Ok(version)
1143    }
1144
1145    fn verify(&self) -> Result<()> {
1146        let dp = desire_path_program()?;
1147        let root = tempfile::tempdir().context("create Desire Path verification directory")?;
1148        let db = root.path().join("desires.db");
1149        let marker = "caboodle_desire_path_marker";
1150        let before = checked(
1151            &dp,
1152            [
1153                "--db",
1154                db.to_str().context("Desire Path DB path is not UTF-8")?,
1155                "--json",
1156                "list",
1157            ],
1158            Some(root.path()),
1159        )?;
1160        if String::from_utf8_lossy(&before.stdout).contains(marker) {
1161            bail!("Desire Path negative control unexpectedly found the marker");
1162        }
1163        let mut child = Command::new(&dp)
1164            .args([
1165                "--db",
1166                db.to_str().unwrap(),
1167                "ingest",
1168                "--source",
1169                "claude-code",
1170            ])
1171            .current_dir(root.path())
1172            .stdin(std::process::Stdio::piped())
1173            .stdout(std::process::Stdio::piped())
1174            .stderr(std::process::Stdio::piped())
1175            .spawn()
1176            .context("start Desire Path ingest verification")?;
1177        use std::io::Write as _;
1178        child
1179            .stdin
1180            .take()
1181            .context("open Desire Path verification stdin")?
1182            .write_all(
1183                format!(r#"{{"tool_name":"{marker}","error":"fixture failure"}}"#).as_bytes(),
1184            )?;
1185        let ingested = child.wait_with_output()?;
1186        if !ingested.status.success() {
1187            bail!(
1188                "Desire Path ingest verification failed ({}): {}",
1189                ingested.status,
1190                String::from_utf8_lossy(&ingested.stderr).trim()
1191            );
1192        }
1193        let after = checked(
1194            &dp,
1195            ["--db", db.to_str().unwrap(), "--json", "list"],
1196            Some(root.path()),
1197        )?;
1198        if !String::from_utf8_lossy(&after.stdout).contains(marker) {
1199            bail!("Desire Path ingested the fixture but its reader path did not return it");
1200        }
1201        Ok(())
1202    }
1203}
1204
1205fn bobbin_result_count(stdout: &[u8]) -> Result<u64> {
1206    let response: serde_json::Value =
1207        serde_json::from_slice(stdout).context("parse bobbin search JSON")?;
1208    response["count"]
1209        .as_u64()
1210        .context("bobbin search JSON omitted numeric count")
1211}
1212
1213fn bobbin_release_target() -> Result<&'static str> {
1214    match (env::consts::ARCH, env::consts::OS) {
1215        ("x86_64", "linux") => Ok("x86_64-unknown-linux-gnu"),
1216        ("aarch64", "linux") => Ok("aarch64-unknown-linux-gnu"),
1217        ("x86_64", "macos") => Ok("x86_64-apple-darwin"),
1218        ("aarch64", "macos") => Ok("aarch64-apple-darwin"),
1219        (arch, os) => bail!("bobbin has no CABOODLE release target for {arch}-{os}"),
1220    }
1221}
1222
1223fn install_bobbin_release() -> Result<()> {
1224    let target = bobbin_release_target()?;
1225    let archive = format!("bobbin-v{BOBBIN_VERSION}-{target}.tar.gz");
1226    let base = format!("https://github.com/scbrown/bobbin/releases/download/v{BOBBIN_VERSION}");
1227    let download = tempfile::tempdir().context("create bobbin download directory")?;
1228    let archive_path = download.path().join(&archive);
1229    let sums_path = download.path().join("SHA256SUMS.txt");
1230    download_https(&format!("{base}/{archive}"), &archive_path)?;
1231    download_https(&format!("{base}/SHA256SUMS.txt"), &sums_path)?;
1232    verify_checksum(&archive_path, &sums_path)?;
1233
1234    let home = env::var_os("HOME").context("HOME is required to install bobbin")?;
1235    let install_root = PathBuf::from(&home)
1236        .join(".local/share/caboodle/bobbin")
1237        .join(format!("v{BOBBIN_VERSION}"));
1238    fs::create_dir_all(&install_root)
1239        .with_context(|| format!("create bobbin install root {}", install_root.display()))?;
1240    checked(
1241        "tar",
1242        [
1243            OsStr::new("-xzf"),
1244            archive_path.as_os_str(),
1245            OsStr::new("--strip-components=1"),
1246            OsStr::new("-C"),
1247            install_root.as_os_str(),
1248        ],
1249        None,
1250    )?;
1251
1252    let bin_dir = env::var_os("CARGO_HOME")
1253        .map(PathBuf::from)
1254        .unwrap_or_else(|| PathBuf::from(home).join(".cargo"))
1255        .join("bin");
1256    fs::create_dir_all(&bin_dir)
1257        .with_context(|| format!("create binary directory {}", bin_dir.display()))?;
1258    let link = bin_dir.join("bobbin");
1259    let target = install_root.join("bobbin");
1260    ensure_bobbin_link(&link, &target)?;
1261    Ok(())
1262}
1263
1264fn ensure_bobbin_link(link: &Path, target: &Path) -> Result<()> {
1265    if !target.is_file() {
1266        bail!("Bobbin release archive omitted {}", target.display());
1267    }
1268    match link.symlink_metadata() {
1269        Ok(metadata)
1270            if metadata.file_type().is_symlink()
1271                && fs::read_link(link).ok().as_deref() == Some(target) =>
1272        {
1273            return Ok(());
1274        }
1275        // A DANGLING symlink is caboodle's own debris, not a user's install, and
1276        // refusing to repair it deadlocks the tool against itself.
1277        //
1278        // Measured on caboodle CI, red on main 2026-08-31 -> 2026-09-02, one job,
1279        // every run:
1280        //
1281        //     bobbin: not installed (run bobbin --version:
1282        //             No such file or directory (os error 2)); installing
1283        //     Error: bobbin install step
1284        //     Caused by: refusing to replace existing
1285        //                /home/runner/.cargo/bin/bobbin; remove or repair it explicitly
1286        //
1287        // Those two lines are the SAME fact seen from two sides. Executing a
1288        // symlink whose target is gone fails with ENOENT, which is exactly why the
1289        // probe a moment earlier concluded bobbin was not installed — so the
1290        // installer refuses to repair the very condition that summoned it. The
1291        // state is then permanent: never detected as present, never allowed to be
1292        // written. Nothing a re-run can do changes it.
1293        //
1294        // The refusal is right for anything a person could still be using — a real
1295        // binary, a directory, a symlink that resolves. A dangling link is none of
1296        // those: following it is an error for every reader, so there is no working
1297        // installation here to protect.
1298        Ok(metadata) if metadata.file_type().is_symlink() && !link.exists() => {
1299            fs::remove_file(link).with_context(|| format!("remove dangling {}", link.display()))?;
1300        }
1301        Ok(_) => {
1302            bail!(
1303                "refusing to replace existing {}; remove or repair it explicitly",
1304                link.display()
1305            );
1306        }
1307        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1308        Err(error) => return Err(error).with_context(|| format!("inspect {}", link.display())),
1309    }
1310    #[cfg(unix)]
1311    std::os::unix::fs::symlink(target, link)
1312        .with_context(|| format!("link bobbin into {}", link.display()))?;
1313    #[cfg(not(unix))]
1314    bail!("bobbin release installation currently requires a Unix host");
1315    Ok(())
1316}
1317
1318const CAMAYOC_REVISION: &str = "f443bf19974d06ab662c8ae3deeccfbb1e082f48";
1319const CAMAYOC_ARCHIVE_SHA256: &str =
1320    "67b1945615d811ac3c0251412d6b5d619f00d58a7ca87785474c0f1db46ba544";
1321
1322pub(crate) fn camayoc_root() -> Result<PathBuf> {
1323    if let Some(path) = env::var_os("CABOODLE_CAMAYOC_ROOT") {
1324        return Ok(PathBuf::from(path));
1325    }
1326    let home = env::var_os("HOME").context("HOME is required to install Camayoc")?;
1327    Ok(PathBuf::from(home)
1328        .join(".local/share/caboodle/camayoc")
1329        .join(CAMAYOC_REVISION))
1330}
1331
1332fn install_camayoc_bundle() -> Result<()> {
1333    let root = camayoc_root()?;
1334    if root.join("REVISION").exists() {
1335        return Ok(());
1336    }
1337    let download = tempfile::tempdir().context("create Camayoc download directory")?;
1338    let archive = download.path().join("camayoc.tar.gz");
1339    download_https(
1340        &format!("https://github.com/scbrown/camayoc/archive/{CAMAYOC_REVISION}.tar.gz"),
1341        &archive,
1342    )?;
1343    let digest = checked("sha256sum", [archive.as_os_str()], None)?;
1344    let digest = String::from_utf8_lossy(&digest.stdout);
1345    if digest.split_whitespace().next() != Some(CAMAYOC_ARCHIVE_SHA256) {
1346        bail!("Camayoc archive checksum mismatch");
1347    }
1348    fs::create_dir_all(&root)
1349        .with_context(|| format!("create Camayoc install root {}", root.display()))?;
1350    checked(
1351        "tar",
1352        [
1353            OsStr::new("-xzf"),
1354            archive.as_os_str(),
1355            OsStr::new("--strip-components=1"),
1356            OsStr::new("-C"),
1357            root.as_os_str(),
1358        ],
1359        None,
1360    )?;
1361    fs::write(root.join("REVISION"), format!("{CAMAYOC_REVISION}\n"))
1362        .context("write installed Camayoc revision")?;
1363    Ok(())
1364}
1365
1366fn verify_camayoc_first_ingest(root: &Path, server: &str) -> Result<()> {
1367    let namespace = camayoc_aegis_namespace(&root.join("ontology/core.ttl"))?;
1368    let control = "caboodle-camayoc-control-must-stay-absent";
1369    let marker = "caboodle-camayoc-first-ingest-v1";
1370
1371    if label_count(server, &namespace, control)? != 0 {
1372        bail!("Camayoc negative control unexpectedly exists");
1373    }
1374
1375    let turtle = format!(
1376        "@prefix aegis: <{namespace}> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
1377         aegis:{marker} a aegis:Verification ; rdfs:label \"{marker}\" ; \
1378         aegis:sourceKind \"observed\" ; aegis:falsifier \"the marker remains absent after ingest\" .\n"
1379    );
1380    let payload = json!({
1381        "turtle": turtle,
1382        "actor": "caboodle",
1383        "source": "caboodle Camayoc first-ingest verification"
1384    });
1385    if label_count(server, &namespace, marker)? == 0 {
1386        let first = curl_json(&format!("{server}/knot"), &payload)?;
1387        if first.get("count").and_then(Value::as_u64).unwrap_or(0) == 0 {
1388            bail!("Camayoc first ingest wrote no triples");
1389        }
1390    }
1391    if label_count(server, &namespace, marker)? == 0 {
1392        bail!("Camayoc first ingest was not retrievable");
1393    }
1394    curl_json(&format!("{server}/knot"), &payload)?;
1395    if label_count(server, &namespace, marker)? != 1 {
1396        bail!("Camayoc idempotent replay wrote duplicate triples");
1397    }
1398    Ok(())
1399}
1400
1401pub(crate) fn camayoc_aegis_namespace(path: &Path) -> Result<String> {
1402    let body = fs::read_to_string(path)
1403        .with_context(|| format!("read Camayoc ontology {}", path.display()))?;
1404    body.lines()
1405        .find_map(|line| {
1406            line.trim()
1407                .strip_prefix("@prefix aegis: <")
1408                .and_then(|rest| rest.strip_suffix("> ."))
1409                .map(str::to_owned)
1410        })
1411        .context("Camayoc ontology does not declare the aegis namespace")
1412}
1413
1414fn label_count(server: &str, namespace: &str, label: &str) -> Result<u64> {
1415    let query = format!(
1416        "SELECT ?s WHERE {{ ?s <http://www.w3.org/2000/01/rdf-schema#label> \"{label}\" . FILTER(STRSTARTS(STR(?s), \"{namespace}\")) }}"
1417    );
1418    let response = curl_json(&format!("{server}/query"), &json!({"query": query}))?;
1419    response["count"]
1420        .as_u64()
1421        .context("Quipu query response omitted numeric count")
1422}
1423
1424pub(crate) fn curl_json(url: &str, payload: &Value) -> Result<Value> {
1425    curl_json_request(url, Some(payload.to_string()))
1426}
1427
1428fn curl_get_json(url: &str) -> Result<Value> {
1429    curl_json_request(url, None)
1430}
1431
1432/// The curl config line carrying `QUIPU_AUTH_TOKEN`, in a temp file so the token
1433/// never reaches argv. curl parses a quoted config value with backslash
1434/// escapes, so `\` and `"` are escaped; a newline would end the line and let
1435/// the token inject a second directive, so it is refused rather than escaped.
1436fn quipu_auth_config() -> Result<Option<tempfile::NamedTempFile>> {
1437    crate::quipu_auth::config()
1438}
1439
1440pub(crate) fn curl_auth_header_line(token: &str) -> Result<String> {
1441    if token.contains(['\n', '\r']) {
1442        bail!("QUIPU_AUTH_TOKEN contains a line break; refusing to pass it to curl");
1443    }
1444    let escaped = token.replace('\\', "\\\\").replace('"', "\\\"");
1445    Ok(format!("header = \"Authorization: Bearer {escaped}\"\n"))
1446}
1447
1448/// POST an empty body to `/episode` with the caller's token and return the HTTP
1449/// status and body. Quipu checks the bearer before it parses the episode, so an
1450/// accepted token yields a validation error (400) and NOTHING is written, while a
1451/// missing or rejected token yields 401. That makes it an authenticated no-op:
1452/// the only way to prove the write path without writing (aegis-0c1qdi). The
1453/// token travels in a curl config file, never on the command line.
1454pub(crate) fn quipu_write_probe(server: &str) -> Result<(u16, String, bool)> {
1455    let body_file = tempfile::NamedTempFile::new().context("create write-probe body file")?;
1456    let auth = quipu_auth_config()?;
1457    let mut args: Vec<OsString> = [
1458        "--silent",
1459        "--max-time",
1460        "5",
1461        "--request",
1462        "POST",
1463        "--header",
1464        "Content-Type: application/json",
1465        "--data",
1466        "{}",
1467        "--write-out",
1468        "%{http_code}",
1469        "--output",
1470    ]
1471    .iter()
1472    .map(OsString::from)
1473    .collect();
1474    args.push(body_file.path().as_os_str().to_owned());
1475    if let Some(file) = &auth {
1476        args.push(OsString::from("--config"));
1477        args.push(file.path().as_os_str().to_owned());
1478    }
1479    args.push(OsString::from(format!(
1480        "{}/episode",
1481        server.trim_end_matches('/')
1482    )));
1483    let result = output("curl", args, None)?;
1484    let code = String::from_utf8_lossy(&result.stdout)
1485        .trim()
1486        .parse::<u16>()
1487        .unwrap_or(0);
1488    let body = fs::read_to_string(body_file.path()).unwrap_or_default();
1489    Ok((code, body, auth.is_some()))
1490}
1491
1492fn curl_json_request(url: &str, body: Option<String>) -> Result<Value> {
1493    let auth = quipu_auth_config()?;
1494    let mut args = vec![
1495        OsString::from("--fail"),
1496        OsString::from("--silent"),
1497        OsString::from("--show-error"),
1498    ];
1499    if let Some(body) = body {
1500        args.extend([
1501            OsString::from("--request"),
1502            OsString::from("POST"),
1503            OsString::from("--header"),
1504            OsString::from("Content-Type: application/json"),
1505            OsString::from("--data"),
1506            OsString::from(body),
1507        ]);
1508    }
1509    if let Some(file) = &auth {
1510        args.push(OsString::from("--config"));
1511        args.push(file.path().as_os_str().to_owned());
1512    }
1513    args.push(OsString::from(url));
1514    let result = checked("curl", args, None)?;
1515    serde_json::from_slice(&result.stdout)
1516        .with_context(|| format!("parse JSON response from {url}"))
1517}
1518
1519pub(crate) fn download_https(url: &str, destination: &Path) -> Result<()> {
1520    // Retry only the GET, never its checksum or installation step. A blanket
1521    // curl --retry-all-errors would also retry a missing asset or bad local path.
1522    const ATTEMPTS: u32 = 3;
1523    for attempt in 1..=ATTEMPTS {
1524        // A failed/partial response must not become the next attempt's input,
1525        // or replace a previously downloaded artifact when retries run out.
1526        let pending = tempfile::NamedTempFile::new_in(
1527            destination
1528                .parent()
1529                .filter(|p| !p.as_os_str().is_empty())
1530                .unwrap_or(Path::new(".")),
1531        )
1532        .context("create pending download")?;
1533        let result = output(
1534            "curl",
1535            [
1536                OsStr::new("--disable"),
1537                OsStr::new("--fail"),
1538                OsStr::new("--location"),
1539                OsStr::new("--silent"),
1540                OsStr::new("--show-error"),
1541                OsStr::new("--proto"),
1542                OsStr::new("=https"),
1543                OsStr::new("--tlsv1.2"),
1544                OsStr::new("--connect-timeout"),
1545                OsStr::new("10"),
1546                OsStr::new("--max-time"),
1547                OsStr::new("120"),
1548                OsStr::new("--write-out"),
1549                OsStr::new("%{http_code}"),
1550                OsStr::new("--output"),
1551                pending.path().as_os_str(),
1552                OsStr::new(url),
1553            ],
1554            None,
1555        )?;
1556        if result.status.success() {
1557            pending
1558                .persist(destination)
1559                .context("publish downloaded artifact")?;
1560            if attempt > 1 {
1561                eprintln!("download {url}: succeeded on attempt {attempt}/{ATTEMPTS}");
1562            }
1563            return Ok(());
1564        }
1565        let http = String::from_utf8_lossy(&result.stdout)
1566            .trim()
1567            .parse::<u16>()
1568            .unwrap_or(0);
1569        let detail = format!(
1570            "download {url}: attempt {attempt}/{ATTEMPTS}, curl {}, HTTP {http:03}: {}",
1571            result.status,
1572            String::from_utf8_lossy(&result.stderr).trim()
1573        );
1574        if !retryable_download(result.status.code(), http) {
1575            bail!("{detail}; terminal failure (not retried)");
1576        }
1577        if attempt == ATTEMPTS {
1578            bail!("{detail}; retry limit exhausted");
1579        }
1580        let delay = 1 << (attempt - 1);
1581        eprintln!("{detail}; retrying in {delay}s");
1582        drop(pending);
1583        std::thread::sleep(std::time::Duration::from_secs(delay));
1584    }
1585    unreachable!("every final attempt returns")
1586}
1587
1588fn retryable_download(code: Option<i32>, http: u16) -> bool {
1589    // An observed client error wins even when reading its body timed out.
1590    if (400..500).contains(&http) {
1591        return false;
1592    }
1593    match code {
1594        Some(22) => (500..600).contains(&http),
1595        // curl transport errors: proxy/DNS resolution, connect, partial body,
1596        // timeout, empty reply, send/receive and HTTP/2 stream failures.
1597        Some(5 | 6 | 7 | 18 | 28 | 52 | 55 | 56 | 92) => true,
1598        // Certificate, local I/O, invocation and unknown errors stay fatal.
1599        _ => false,
1600    }
1601}
1602
1603fn verify_checksum(archive: &Path, sums: &Path) -> Result<()> {
1604    let filename = archive
1605        .file_name()
1606        .and_then(OsStr::to_str)
1607        .context("bobbin archive filename is not UTF-8")?;
1608    let expected = fs::read_to_string(sums)
1609        .context("read Bobbin checksum manifest")?
1610        .lines()
1611        .find_map(|line| {
1612            let mut fields = line.split_whitespace();
1613            let digest = fields.next()?;
1614            let listed = fields.next()?.trim_start_matches('*');
1615            (listed == filename).then(|| digest.to_owned())
1616        })
1617        .with_context(|| format!("checksum manifest does not cover {filename}"))?;
1618    let actual = checked("sha256sum", [archive.as_os_str()], None)?;
1619    let actual = String::from_utf8_lossy(&actual.stdout)
1620        .split_whitespace()
1621        .next()
1622        .context("sha256sum returned no digest")?
1623        .to_owned();
1624    if actual != expected {
1625        bail!("checksum mismatch for {filename}");
1626    }
1627    Ok(())
1628}
1629
1630#[cfg(test)]
1631mod yupana_release_tests {
1632    use super::*;
1633
1634    #[test]
1635    fn published_platforms_resolve_and_missing_platforms_refuse() {
1636        for (arch, os, target) in [
1637            ("x86_64", "linux", "x86_64-linux-gnu"),
1638            ("aarch64", "macos", "aarch64-apple-darwin"),
1639            ("x86_64", "macos", "x86_64-apple-darwin"),
1640        ] {
1641            assert_eq!(yupana_release_for(arch, os).unwrap().0, target);
1642        }
1643        assert!(yupana_release_for("aarch64", "linux").is_err());
1644        assert!(yupana_release_for("x86_64", "windows").is_err());
1645        assert!(!prerequisites(ToolName::Yupana, QuipuFlavor::Release).contains(&"sha256sum"));
1646    }
1647
1648    #[test]
1649    fn checksum_refuses_corruption_without_an_external_hash_command() {
1650        let root = tempfile::tempdir().unwrap();
1651        let file = root.path().join("archive");
1652        fs::write(&file, b"abc").unwrap();
1653        let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1654        verify_yupana_checksum(&file, expected).unwrap();
1655        fs::write(&file, b"abd").unwrap();
1656        assert!(verify_yupana_checksum(&file, expected).is_err());
1657    }
1658
1659    #[test]
1660    #[ignore = "downloads all three published archives; explicit release acceptance"]
1661    fn real_yupana_archives_match_pins_and_unpack() {
1662        for (arch, os) in [
1663            ("x86_64", "linux"),
1664            ("aarch64", "macos"),
1665            ("x86_64", "macos"),
1666        ] {
1667            let (target, checksum) = yupana_release_for(arch, os).unwrap();
1668            let root = tempfile::tempdir().unwrap();
1669            fetch_yupana_release(target, checksum, root.path()).unwrap();
1670            assert!(root.path().join("yupana").is_file());
1671            assert_eq!(
1672                fs::read_link(root.path().join("hank")).unwrap(),
1673                Path::new("yupana")
1674            );
1675            eprintln!("verified published {target}: {checksum}");
1676        }
1677    }
1678}
1679
1680#[cfg(test)]
1681mod flavor_tests {
1682    use super::{
1683        fetch_quipu_release, quipu_install_args, quipu_release_target, quipu_release_target_for,
1684        require_compiled_feature,
1685    };
1686    use crate::model::QuipuFlavor;
1687    use serde_json::json;
1688
1689    #[test]
1690    fn install_args_add_only_the_lancedb_feature_to_the_reviewed_pin() {
1691        let release = quipu_install_args(QuipuFlavor::Release);
1692        let lancedb = quipu_install_args(QuipuFlavor::Lancedb);
1693        assert_eq!(release[6], "full");
1694        assert_eq!(lancedb[6], "full,lancedb");
1695        // Everything but the feature list must be identical, so the lancedb
1696        // flavor stays pinned to the same reviewed revision.
1697        assert_eq!(release[..6], lancedb[..6]);
1698        assert_eq!(release[7..], lancedb[7..]);
1699        assert!(release.contains(&"--locked"));
1700    }
1701
1702    #[test]
1703    fn default_quipu_release_has_a_supported_host_artifact() {
1704        if (std::env::consts::ARCH, std::env::consts::OS) == ("x86_64", "linux") {
1705            assert_eq!(quipu_release_target().unwrap(), "x86_64-unknown-linux-gnu");
1706        }
1707    }
1708
1709    #[test]
1710    fn quipu_release_supports_published_linux_and_macos_architectures() {
1711        for (arch, os, target) in [
1712            ("x86_64", "linux", "x86_64-unknown-linux-gnu"),
1713            ("aarch64", "linux", "aarch64-unknown-linux-gnu"),
1714            ("x86_64", "macos", "x86_64-apple-darwin"),
1715            ("aarch64", "macos", "aarch64-apple-darwin"),
1716        ] {
1717            assert_eq!(quipu_release_target_for(arch, os).unwrap(), target);
1718        }
1719        assert!(quipu_release_target_for("x86_64", "windows").is_err());
1720    }
1721
1722    #[test]
1723    #[ignore = "downloads published artifacts; explicit release acceptance only"]
1724    fn real_aarch64_quipu_release_resolves_and_verifies() {
1725        for os in ["macos", "linux"] {
1726            let target = quipu_release_target_for("aarch64", os).unwrap();
1727            let download = tempfile::tempdir().unwrap();
1728            let unpacked = fetch_quipu_release(target, download.path()).unwrap();
1729            assert!(unpacked.join("quipu").is_file());
1730            assert!(unpacked.join("quipu-server").is_file());
1731            eprintln!("verified published Quipu checksums and both binaries for {target}");
1732        }
1733    }
1734
1735    #[test]
1736    fn compile_map_proof_requires_a_true_lancedb_entry() {
1737        require_compiled_feature(&json!({"features": {"lancedb": true}}), "lancedb").unwrap();
1738
1739        let absent = require_compiled_feature(&json!({"features": {"onnx": true}}), "lancedb")
1740            .unwrap_err()
1741            .to_string();
1742        assert!(absent.contains("compiled without it"), "{absent}");
1743
1744        let disabled =
1745            require_compiled_feature(&json!({"features": {"lancedb": false}}), "lancedb")
1746                .unwrap_err()
1747                .to_string();
1748        assert!(disabled.contains("compiled without it"), "{disabled}");
1749
1750        // A server too old to report the map proves nothing; that is a
1751        // refusal, not a pass.
1752        let unmapped = require_compiled_feature(&json!({"version": "0.3.27"}), "lancedb")
1753            .unwrap_err()
1754            .to_string();
1755        assert!(
1756            unmapped.contains("no per-feature compile map"),
1757            "{unmapped}"
1758        );
1759    }
1760}
1761
1762#[cfg(all(test, unix))]
1763mod tests {
1764    use super::ensure_bobbin_link;
1765    use std::{fs, os::unix::fs::symlink};
1766
1767    #[test]
1768    fn failed_commands_preserve_both_diagnostic_streams_and_status() {
1769        for script in [
1770            "printf 'bootstrap rejected'; exit 2",
1771            "printf 'bootstrap rejected' >&2; exit 2",
1772            "printf 'probe started'; printf 'bootstrap rejected' >&2; exit 2",
1773            "exit 2",
1774        ] {
1775            let error = super::checked("sh", ["-c", script], None)
1776                .unwrap_err()
1777                .to_string();
1778            assert!(error.contains("exit status: 2"), "{error}");
1779            assert!(error.contains("stdout:\n"), "{error}");
1780            assert!(error.contains("stderr:\n"), "{error}");
1781            if script.contains("bootstrap rejected") {
1782                assert!(error.contains("bootstrap rejected"), "{error}");
1783            }
1784            if script.contains("probe started") {
1785                assert!(error.contains("probe started"), "{error}");
1786            }
1787        }
1788        let output = super::checked("sh", ["-c", "printf 'successful output'"], None).unwrap();
1789        assert_eq!(output.stdout, b"successful output");
1790    }
1791
1792    #[test]
1793    fn bobbin_link_accepts_its_restored_cache_entry_but_refuses_foreign_paths() {
1794        let root = tempfile::tempdir().unwrap();
1795        let target = root.path().join("release/bobbin");
1796        fs::create_dir_all(target.parent().unwrap()).unwrap();
1797        fs::write(&target, "binary fixture").unwrap();
1798
1799        let link = root.path().join("bin/bobbin");
1800        fs::create_dir_all(link.parent().unwrap()).unwrap();
1801        symlink(&target, &link).unwrap();
1802        ensure_bobbin_link(&link, &target).unwrap();
1803        assert_eq!(fs::read_link(&link).unwrap(), target);
1804
1805        let foreign = root.path().join("foreign");
1806        fs::write(&foreign, "do not replace").unwrap();
1807        fs::remove_file(&link).unwrap();
1808        symlink(&foreign, &link).unwrap();
1809        let error = ensure_bobbin_link(&link, &target).unwrap_err().to_string();
1810        assert!(error.contains("refusing to replace existing"));
1811        assert_eq!(fs::read_link(&link).unwrap(), foreign);
1812    }
1813
1814    #[test]
1815    fn bobbin_link_repairs_its_own_dangling_link_but_still_refuses_a_real_file() {
1816        // The CI deadlock, both arms. A dangling link makes `bobbin --version`
1817        // fail with ENOENT, so the probe reports "not installed" and calls the
1818        // installer -- which then refused to touch it. Never present, never
1819        // writable, and no re-run could break the tie.
1820        let root = tempfile::tempdir().unwrap();
1821        let target = root.path().join("release/bobbin");
1822        fs::create_dir_all(target.parent().unwrap()).unwrap();
1823        fs::write(&target, "binary fixture").unwrap();
1824        let link = root.path().join("bin/bobbin");
1825        fs::create_dir_all(link.parent().unwrap()).unwrap();
1826
1827        symlink(root.path().join("gone/bobbin"), &link).unwrap();
1828        assert!(link.symlink_metadata().is_ok(), "the link itself exists");
1829        assert!(
1830            !link.exists(),
1831            "but it resolves to nothing -- this is the ENOENT"
1832        );
1833        ensure_bobbin_link(&link, &target).unwrap();
1834        assert_eq!(fs::read_link(&link).unwrap(), target);
1835
1836        // and the repair must not have widened into "replace whatever is there":
1837        // a REGULAR FILE is somebody's binary and is still refused.
1838        fs::remove_file(&link).unwrap();
1839        fs::write(&link, "a real bobbin somebody installed").unwrap();
1840        let error = ensure_bobbin_link(&link, &target).unwrap_err().to_string();
1841        assert!(error.contains("refusing to replace existing"));
1842        assert_eq!(
1843            fs::read_to_string(&link).unwrap(),
1844            "a real bobbin somebody installed"
1845        );
1846    }
1847}
1848
1849#[cfg(test)]
1850mod auth_config_tests {
1851    use super::curl_auth_header_line;
1852
1853    #[test]
1854    fn curl_auth_line_escapes_quotes_and_refuses_line_breaks() {
1855        assert_eq!(
1856            curl_auth_header_line("abc").unwrap(),
1857            "header = \"Authorization: Bearer abc\"\n"
1858        );
1859        assert_eq!(
1860            curl_auth_header_line(r#"a"b\c"#).unwrap(),
1861            "header = \"Authorization: Bearer a\\\"b\\\\c\"\n"
1862        );
1863        // A newline would end the config line and let the token add a directive.
1864        assert!(curl_auth_header_line("tok\nurl = \"http://elsewhere\"").is_err());
1865        assert!(curl_auth_header_line("tok\r").is_err());
1866    }
1867}
1868
1869/// Verify one embedded release member without installing it.
1870pub fn verify_member(name: &str) -> Result<()> {
1871    let member = crate::members::get(name).context("unknown reviewed member")?;
1872    manifest::verify_release_member(member)
1873}
1874
1875#[cfg(all(test, unix))]
1876mod quipu_alias_layout_tests {
1877    use super::verify_quipu_alias;
1878    use std::os::unix::fs::{symlink, PermissionsExt};
1879    use std::{fs, path::Path};
1880
1881    fn script(path: &Path, version: &str) {
1882        fs::write(path, format!("#!/bin/sh\necho '{version}'\n")).unwrap();
1883        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1884    }
1885
1886    #[test]
1887    fn package_managed_layout_passes_with_qp_beside_the_resolved_quipu() {
1888        // Homebrew: bin/quipu and bin/qp link into the Cellar, where the
1889        // formula owns the relative qp -> quipu.
1890        let root = tempfile::tempdir().unwrap();
1891        let cellar = root.path().join("Cellar/quipu/1.0/bin");
1892        let bin = root.path().join("bin");
1893        fs::create_dir_all(&cellar).unwrap();
1894        fs::create_dir(&bin).unwrap();
1895        script(&cellar.join("quipu"), "quipu 1.0");
1896        symlink("quipu", cellar.join("qp")).unwrap();
1897        symlink("../Cellar/quipu/1.0/bin/quipu", bin.join("quipu")).unwrap();
1898        symlink("../Cellar/quipu/1.0/bin/qp", bin.join("qp")).unwrap();
1899        verify_quipu_alias(&bin.join("quipu")).unwrap();
1900    }
1901
1902    #[test]
1903    fn a_qp_for_another_program_or_no_qp_still_fails() {
1904        let root = tempfile::tempdir().unwrap();
1905        let bin = root.path().join("bin");
1906        fs::create_dir(&bin).unwrap();
1907        script(&bin.join("quipu"), "quipu 1.0");
1908        let err = verify_quipu_alias(&bin.join("quipu")).unwrap_err();
1909        assert!(err.to_string().contains("requires relative qp -> quipu"));
1910        // A relative link exists beside the resolved file, but the qp on PATH
1911        // is a different program: refuse.
1912        let cellar = root.path().join("Cellar/bin");
1913        fs::create_dir_all(&cellar).unwrap();
1914        script(&cellar.join("quipu"), "quipu 1.0");
1915        symlink("quipu", cellar.join("qp")).unwrap();
1916        script(&root.path().join("other"), "quipu 1.0");
1917        let pkg = root.path().join("pkgbin");
1918        fs::create_dir(&pkg).unwrap();
1919        symlink("../Cellar/bin/quipu", pkg.join("quipu")).unwrap();
1920        symlink("../other", pkg.join("qp")).unwrap();
1921        let err = verify_quipu_alias(&pkg.join("quipu")).unwrap_err();
1922        assert!(err.to_string().contains("same executable"));
1923    }
1924
1925    #[test]
1926    fn owned_layout_with_qp_beside_quipu_still_passes() {
1927        let root = tempfile::tempdir().unwrap();
1928        script(&root.path().join("quipu"), "quipu 1.0");
1929        symlink("quipu", root.path().join("qp")).unwrap();
1930        verify_quipu_alias(&root.path().join("quipu")).unwrap();
1931    }
1932}