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