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