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