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