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