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