1use crate::pack_trust::{self, Repo, TrustConfig};
3use anyhow::{bail, Context, Result};
4use fs2::FileExt;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::{
8 collections::BTreeMap,
9 fs,
10 io::{Read, Write},
11 path::{Path, PathBuf},
12 process::Command,
13};
14
15const MAX_ARCHIVES: usize = 64;
16const MAX_BYTES: u64 = 256 * 1024 * 1024;
17const AUTHORITY_KEY: &str = "_caboodle-authority";
18
19pub struct Options<'a> {
20 pub repo: &'a Path,
21 pub db: Option<&'a Path>,
22 pub shapes_from: Option<&'a Path>,
23 pub trust_file: Option<&'a Path>,
24 pub monorepo: bool,
25}
26
27#[derive(Debug, Serialize)]
28pub struct Report {
29 pub outcome: &'static str,
30 pub packs: usize,
31 pub current: Option<serde_json::Value>,
32}
33
34#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36struct Stamp {
37 bytes: u64,
38 modified: String,
39}
40
41#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct Receipt {
44 version: u8,
45 #[serde(default, skip_serializing_if = "is_false")]
46 monorepo: bool,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 selected: Vec<String>,
49 repo: String,
50 db: PathBuf,
51 hashes: BTreeMap<String, String>,
52 authority: String,
53 database: Option<Stamp>,
54 wal: Option<Stamp>,
55}
56
57fn is_false(value: &bool) -> bool {
58 !value
59}
60
61pub(crate) fn git_command(root: &Path) -> Command {
62 let mut command = Command::new("git");
63 command.arg("-C").arg(root);
64 for key in [
66 "GIT_DIR",
67 "GIT_WORK_TREE",
68 "GIT_COMMON_DIR",
69 "GIT_INDEX_FILE",
70 ] {
71 command.env_remove(key);
72 }
73 command
74}
75
76pub(crate) fn git(root: &Path, args: &[&str]) -> Result<String> {
77 let output = git_command(root).args(args).output()?;
78 if !output.status.success() {
79 bail!(
80 "git {} failed: {}",
81 args.join(" "),
82 String::from_utf8_lossy(&output.stderr)
83 );
84 }
85 Ok(String::from_utf8(output.stdout)?
86 .trim_end_matches(['\r', '\n'])
87 .into())
88}
89
90fn sidecar(db: &Path, suffix: &str) -> Result<PathBuf> {
91 let mut name = db
92 .file_name()
93 .context("database needs a file name")?
94 .to_os_string();
95 name.push(suffix);
96 Ok(db.with_file_name(name))
97}
98
99fn stamp(path: &Path) -> Result<Option<Stamp>> {
100 match fs::symlink_metadata(path) {
101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
102 Err(e) => Err(e.into()),
103 Ok(m) => {
104 if !m.is_file() || m.file_type().is_symlink() {
105 bail!("expected a regular file: {}", path.display());
106 }
107 Ok(Some(Stamp {
108 bytes: m.len(),
109 modified: format!("{:?}", m.modified()?),
110 }))
111 }
112 }
113}
114
115fn regular_directory(path: &Path) -> Result<bool> {
116 match fs::symlink_metadata(path) {
117 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
118 Err(e) => Err(e.into()),
119 Ok(m) if m.is_dir() && !m.file_type().is_symlink() => Ok(true),
120 Ok(_) => bail!(
121 "pendant directory must not be a symlink: {}",
122 path.display()
123 ),
124 }
125}
126
127fn discover(root: &Path) -> Result<Vec<PathBuf>> {
128 let quipu = root.join(".quipu");
129 let packs = quipu.join("packs");
130 if !regular_directory(&quipu)? || !regular_directory(&packs)? {
131 return Ok(Vec::new());
132 }
133 let mut found = Vec::new();
134 for entry in fs::read_dir(&packs)? {
135 let entry = entry?;
136 let name = entry.file_name();
137 let name = name.to_str().context("pendant file name must be UTF-8")?;
138 if !name.ends_with(".pendant.tar.gz") {
139 continue;
140 }
141 if name.len() > 200
142 || !name
143 .chars()
144 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
145 {
146 bail!("invalid pendant file name: {name}");
147 }
148 if !entry.file_type()?.is_file() {
149 bail!("pendant must be a regular file, not a symlink: {name}");
150 }
151 found.push(entry.path());
152 if found.len() > MAX_ARCHIVES {
153 bail!("more than {MAX_ARCHIVES} pendants in {}", packs.display());
154 }
155 }
156 found.sort();
157 Ok(found)
158}
159
160fn freeze(source: &Path, target: &Path, budget: &mut u64) -> Result<String> {
162 if !fs::symlink_metadata(source)?.is_file() {
163 bail!("not a regular pendant: {}", source.display());
164 }
165 let mut input = fs::File::open(source)?;
166 let mut output = fs::OpenOptions::new()
167 .write(true)
168 .create_new(true)
169 .open(target)?;
170 let mut hash = Sha256::new();
171 let mut buffer = [0u8; 65536];
172 loop {
173 let n = input.read(&mut buffer)?;
174 if n == 0 {
175 break;
176 }
177 *budget = budget
178 .checked_sub(n as u64)
179 .context("pendant bytes exceed the 256 MiB sync limit")?;
180 output.write_all(&buffer[..n])?;
181 hash.update(&buffer[..n]);
182 }
183 output.sync_all()?;
184 Ok(format!("{:x}", hash.finalize()))
185}
186
187pub fn sync(options: &Options<'_>) -> Result<Report> {
188 let root = PathBuf::from(git(options.repo, &["rev-parse", "--show-toplevel"])?);
189 let directory = PathBuf::from(git(&root, &["rev-parse", "--absolute-git-dir"])?);
190 let default_db = directory.join("caboodle-pendants/store.db");
191 let db = options.db.unwrap_or(&default_db);
192 let db = if db.is_absolute() {
193 db.to_path_buf()
194 } else {
195 std::env::current_dir()?.join(db)
196 };
197 let receipt_path = sidecar(&db, ".caboodle-packs.json")?;
198 let packs = discover(&root)?;
199 if packs.is_empty() && !receipt_path.exists() {
200 return Ok(Report {
201 outcome: "no_packs",
202 packs: 0,
203 current: None,
204 });
205 }
206 let remote = git(&root, &["remote", "get-url", "origin"])?;
207 let repo = Repo::parse(&remote)?.key();
208 let authority = options.shapes_from.context(
209 "packs sync requires --shapes-from pointing to an operator-controlled authority pendant",
210 )?;
211 let trust_path = match options.trust_file {
212 Some(p) => p.to_path_buf(),
213 None => pack_trust::default_path()?,
214 };
215 let trust = TrustConfig::load(&trust_path)?;
216 let frozen = tempfile::tempdir()?;
217 let mut budget = MAX_BYTES;
218 let mut hashes = BTreeMap::new();
219 let mut inputs = Vec::new();
220 for path in &packs {
221 let name = path.file_name().unwrap().to_str().unwrap();
222 let target = frozen.path().join(name);
223 let hash = freeze(path, &target, &mut budget)?;
224 match trust.decide_pack(&remote, name, &hash) {
225 pack_trust::Decision::Allow { .. } => (),
226 pack_trust::Decision::Refuse { message } => bail!("{message}"),
227 }
228 hashes.insert(name.into(), hash);
229 inputs.push((name.to_owned(), target));
230 }
231 let all_inputs = inputs.clone();
232 if options.monorepo {
233 inputs = crate::pendant_projects::select(&root, inputs)?;
234 }
235 let loaded_packs = inputs.len();
236 fs::create_dir(frozen.path().join("operator"))?;
237 let authority_copy = frozen.path().join("operator/authority.pendant.tar.gz");
238 let authority_hash = freeze(authority, &authority_copy, &mut budget)?;
239 let parent = db.parent().context("database needs a parent directory")?;
240 fs::create_dir_all(parent)?;
241 let lock_path = sidecar(&db, ".caboodle-packs.lock")?;
242 if fs::symlink_metadata(&lock_path).is_ok_and(|m| !m.is_file() || m.file_type().is_symlink()) {
243 bail!("invalid pendant lock file");
244 }
245 let lock = fs::OpenOptions::new()
246 .create(true)
247 .truncate(false)
248 .read(true)
249 .write(true)
250 .open(lock_path)?;
251 lock.try_lock_exclusive()
252 .context("another pendant sync owns this database; no compose attempted")?;
253 let wal = sidecar(&db, "-wal")?;
254 let mut receipt = Receipt {
255 version: if options.monorepo { 2 } else { 1 },
256 monorepo: options.monorepo,
257 selected: if options.monorepo {
258 inputs.iter().map(|(key, _)| key.clone()).collect()
259 } else {
260 Vec::new()
261 },
262 repo: repo.clone(),
263 db: db.clone(),
264 hashes,
265 authority: authority_hash,
266 database: stamp(&db)?,
267 wal: stamp(&wal)?,
268 };
269 if receipt_path.exists() {
270 let prior: Receipt = serde_json::from_slice(&fs::read(&receipt_path)?)
271 .context("invalid pendant receipt; no compose attempted")?;
272 if !matches!(prior.version, 1 | 2) {
273 bail!("unsupported pendant receipt version; no compose attempted");
274 }
275 if receipt.database.is_some() && prior == receipt {
276 return Ok(Report {
277 outcome: "unchanged",
278 packs: loaded_packs,
279 current: None,
280 });
281 }
282 }
283 let program = std::env::var_os("QUIPU_BIN").unwrap_or_else(|| "quipu".into());
284 if options.monorepo {
285 let mut verify = Command::new(&program);
288 verify
289 .arg("compose")
290 .arg("--db")
291 .arg(frozen.path().join("verification.db"))
292 .arg("--shapes-from")
293 .arg(&authority_copy)
294 .arg(&authority_copy);
295 for (_, path) in &all_inputs {
296 verify.arg(path);
297 }
298 let checked = verify.output().context("verify all monorepo shares")?;
299 let report: serde_json::Value = serde_json::from_slice(&checked.stdout).unwrap_or_default();
300 if !checked.status.success() || report["outcome"] != "composed" {
301 bail!(
302 "monorepo share verification refused; CURRENT and receipt unchanged: {}",
303 String::from_utf8_lossy(&checked.stderr)
304 );
305 }
306 }
307 let mut command = Command::new(program);
308 command
309 .args(["compose", "--repo", &repo])
310 .arg("--db")
311 .arg(&db)
312 .args(["--shapes-from", AUTHORITY_KEY]);
313 command.arg(format!("{AUTHORITY_KEY}={}", authority_copy.display()));
314 for (key, path) in inputs {
315 command.arg(format!("{key}={}", path.display()));
316 }
317 let output = command
318 .output()
319 .context("run Quipu repository composition")?;
320 if !output.status.success() {
321 bail!(
322 "pendant composition refused; receipt unchanged: {}",
323 String::from_utf8_lossy(&output.stderr)
324 );
325 }
326 let result: serde_json::Value = serde_json::from_slice(&output.stdout)
327 .context("Quipu returned no verifiable repository composition receipt")?;
328 let expected = format!("urn:quipu:repo:{repo}:current");
329 if result["repo"] != repo
330 || result["composition"]["outcome"] != "composed"
331 || result["current"]["dataset"] != expected
332 {
333 bail!(
334 "Quipu did not attest the expected repository CURRENT composition; receipt unchanged"
335 );
336 }
337 receipt.database = stamp(&db)?;
338 receipt.wal = stamp(&wal)?;
339 if receipt.database.is_none() {
340 bail!("Quipu reported composition but the database is absent; receipt unchanged");
341 }
342 let mut file = tempfile::NamedTempFile::new_in(parent)?;
343 serde_json::to_writer_pretty(&mut file, &receipt)?;
344 file.as_file().sync_all()?;
345 file.persist(&receipt_path).context(
346 "composition succeeded but saving its receipt failed; inspect CURRENT before retrying",
347 )?;
348 let saved: Receipt = serde_json::from_slice(&fs::read(&receipt_path)?)?;
349 if saved != receipt {
350 bail!("pendant receipt read-back differs");
351 }
352 Ok(Report {
353 outcome: "composed",
354 packs: loaded_packs,
355 current: Some(result["current"].clone()),
356 })
357}