use std::{
env,
ffi::OsStr,
fs,
path::{Path, PathBuf},
};
use anyhow::{bail, Context, Result};
use crate::adapter::{checked, download_https};
pub const QUECHUA_VERSION: &str = "0.1.0";
pub const QUECHUA_SHA256: &str = "eb0d7a10c86fcd9e5ad0a5f08a27bd6370f9439a951a9ea690a5919e4064d9bd";
const NS: &str = "https://scbrown.github.io/quechua/ns";
const KNOWN_TERM: &str = "WorkflowRun";
fn asset() -> String {
format!("quechua-ns-v{QUECHUA_VERSION}.ttl")
}
pub fn cached_path() -> Result<PathBuf> {
if let Some(path) = env::var_os("CABOODLE_QUECHUA_FILE") {
return Ok(PathBuf::from(path));
}
let home = env::var_os("HOME").context("HOME is required for the Quechua vocabulary")?;
Ok(PathBuf::from(home)
.join(".local/share/caboodle/vocabulary")
.join(asset()))
}
fn sha256_file(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
pub fn ensure_cached() -> Result<PathBuf> {
let path = cached_path()?;
if !path.exists() {
let dir = path.parent().context("vocabulary cache has no parent")?;
fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let staged = tempfile::NamedTempFile::new_in(dir)?;
download_https(
&format!(
"https://github.com/scbrown/quechua/releases/download/v{QUECHUA_VERSION}/{}",
asset()
),
staged.path(),
)?;
if sha256_file(staged.path())? != QUECHUA_SHA256 {
bail!("Quechua v{QUECHUA_VERSION} release checksum mismatch; nothing cached");
}
staged
.persist(&path)
.map_err(|e| e.error)
.with_context(|| format!("cache {}", path.display()))?;
}
if sha256_file(&path)? != QUECHUA_SHA256 {
bail!(
"{} does not hash to the reviewed Quechua v{QUECHUA_VERSION} digest; refusing it",
path.display()
);
}
Ok(path)
}
pub fn load(db: &Path) -> Result<()> {
load_at(db, Path::new("quipu"))
}
fn load_at(db: &Path, client: &Path) -> Result<()> {
let file = ensure_cached()?;
checked(
client,
[
OsStr::new("knot"),
file.as_os_str(),
OsStr::new("--db"),
db.as_os_str(),
],
None,
)
.with_context(|| format!("load Quechua v{QUECHUA_VERSION} into {}", db.display()))?;
Ok(())
}
fn ask(db: &Path, query: &str, client: &Path) -> Result<bool> {
let out = checked(
client,
[
OsStr::new("read"),
OsStr::new(query),
OsStr::new("--db"),
db.as_os_str(),
],
None,
)?;
match String::from_utf8_lossy(&out.stdout).trim() {
"true" => Ok(true),
"false" => Ok(false),
other => bail!("unexpected ASK answer from quipu: {other:?}"),
}
}
fn declared(db: &Path, term: &str, client: &Path) -> Result<bool> {
ask(
db,
&format!(
"ASK {{ {{ <{NS}#{term}> a <http://www.w3.org/2000/01/rdf-schema#Class> }} UNION \
{{ <{NS}#{term}> a <http://www.w3.org/1999/02/22-rdf-syntax-ns#Property> }} }}"
),
client,
)
}
pub fn verify() -> Result<()> {
verify_at(Path::new("quipu"))
}
pub(crate) fn verify_at(client: &Path) -> Result<()> {
let root = tempfile::tempdir().context("create Quechua verification directory")?;
let db = root.path().join("vocabulary.db");
if declared(&db, KNOWN_TERM, client).unwrap_or(false) {
bail!("Quechua control failed: an empty store already declares {KNOWN_TERM}");
}
load_at(&db, client)?;
let version = ask(
&db,
&format!(
"ASK {{ <{NS}> <http://www.w3.org/2002/07/owl#versionInfo> \"{QUECHUA_VERSION}\" }}"
),
client,
)?;
if !version {
bail!("the loaded Quechua file does not declare owl:versionInfo {QUECHUA_VERSION}");
}
if !declared(&db, KNOWN_TERM, client)? {
bail!("Quechua v{QUECHUA_VERSION} loaded but quechua:{KNOWN_TERM} does not resolve");
}
let absent = "TamboVerifyAbsentTerm";
if declared(&db, absent, client)? {
bail!("Quechua control failed: an undeclared term resolved");
}
Ok(())
}