Skip to main content

caboodle/
adapter.rs

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