1use std::collections::{BTreeMap, BTreeSet};
20use std::fs::{self, File, OpenOptions};
21use std::io::{BufRead, BufReader, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use choir_oplog::ContentHash;
26
27use crate::differential::{Calibration, DifferentialReport, Verdict};
28
29const FORMAT_VERSION: u64 = 1;
30static REPLACE_TEMP_ID: AtomicU64 = AtomicU64::new(0);
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CommandSpec {
35 pub program: String,
37 pub args: Vec<String>,
39 pub env: BTreeMap<String, String>,
43 pub timeout_seconds: Option<u64>,
47 pub snapshot_hash: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Revisions {
54 pub parent_a: String,
56 pub parent_b: String,
58 pub merged: String,
60}
61
62fn target_dir_stays_inside_worktree(value: &str) -> bool {
63 let path = Path::new(value);
64 !value.is_empty()
65 && !path.is_absolute()
66 && path.components().all(|component| {
67 matches!(
68 component,
69 std::path::Component::CurDir | std::path::Component::Normal(_)
70 )
71 })
72}
73
74fn cargo_target_dirs_are_isolated(program: &str, args: &[String]) -> bool {
75 if Path::new(program)
76 .file_stem()
77 .and_then(|name| name.to_str())
78 != Some("cargo")
79 {
80 return true;
81 }
82 args.iter().enumerate().all(|(index, arg)| {
83 if arg == "--target-dir" {
84 args.get(index + 1)
85 .is_some_and(|value| target_dir_stays_inside_worktree(value))
86 } else if let Some(value) = arg.strip_prefix("--target-dir=") {
87 target_dir_stays_inside_worktree(value)
88 } else {
89 true
90 }
91 })
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct RecordedObservation {
97 pub observation_id: u64,
99 pub report: DifferentialReport,
101 pub calibration: serde_json::Value,
103}
104
105pub fn load_command(path: &Path) -> Result<CommandSpec, String> {
124 let bytes = fs::read(path).map_err(|error| format!("read differential command: {error}"))?;
125 let value: serde_json::Value = serde_json::from_slice(&bytes)
126 .map_err(|error| format!("parse differential command: {error}"))?;
127 if value["format_version"].as_u64() != Some(FORMAT_VERSION) {
128 return Err("differential command needs format_version 1".to_string());
129 }
130 let program = value["program"]
131 .as_str()
132 .filter(|program| !program.is_empty())
133 .ok_or("differential command needs a non-empty program")?
134 .to_string();
135 let args = value["args"]
136 .as_array()
137 .ok_or("differential command args must be an array")?
138 .iter()
139 .map(|arg| {
140 arg.as_str()
141 .map(str::to_string)
142 .ok_or("differential command arguments must be strings".to_string())
143 })
144 .collect::<Result<Vec<_>, _>>()?;
145 let env = match &value["env"] {
146 serde_json::Value::Null => BTreeMap::new(),
147 serde_json::Value::Object(entries) => entries
148 .iter()
149 .map(|(name, value)| {
150 if name.is_empty() || name.contains(['=', '\0']) {
151 return Err(
152 "differential command env names must be non-empty and free of = and NUL"
153 .to_string(),
154 );
155 }
156 value
157 .as_str()
158 .map(|value| (name.clone(), value.to_string()))
159 .ok_or("differential command env values must be strings".to_string())
160 })
161 .collect::<Result<BTreeMap<_, _>, _>>()?,
162 _ => return Err("differential command env must be an object".to_string()),
163 };
164 let timeout_seconds = match &value["timeout_seconds"] {
165 serde_json::Value::Null => None,
166 value => Some(
167 value
168 .as_u64()
169 .filter(|seconds| *seconds > 0)
170 .ok_or("differential command timeout_seconds must be a positive integer")?,
171 ),
172 };
173 if !cargo_target_dirs_are_isolated(&program, &args) {
174 return Err("cargo target directory must stay inside each revision worktree".to_string());
175 }
176 Ok(CommandSpec {
177 program,
178 args,
179 env,
180 timeout_seconds,
181 snapshot_hash: ContentHash::blake3(&bytes).to_hex(),
182 })
183}
184
185#[must_use]
193pub fn effective_environment(declared: &BTreeMap<String, String>) -> BTreeMap<String, String> {
194 let mut env = BTreeMap::new();
195 for name in ["PATH", "HOME", "TMPDIR"] {
196 if let Ok(value) = std::env::var(name) {
197 env.insert(name.to_string(), value);
198 }
199 }
200 env.extend(declared.iter().map(|(k, v)| (k.clone(), v.clone())));
201 env
202}
203
204#[must_use]
210pub fn environment_hash(env: &BTreeMap<String, String>) -> String {
211 ContentHash::blake3(&serde_json::to_vec(env).expect("string map always encodes")).to_hex()
212}
213
214fn chmod(path: &Path, mode: u32) -> Result<(), String> {
215 #[cfg(unix)]
216 {
217 use std::os::unix::fs::PermissionsExt;
218 fs::set_permissions(path, fs::Permissions::from_mode(mode))
219 .map_err(|error| format!("set calibration permissions: {error}"))?;
220 }
221 #[cfg(not(unix))]
222 let _ = (path, mode);
223 Ok(())
224}
225
226fn secure_append(path: &Path) -> Result<File, String> {
227 let mut options = OpenOptions::new();
228 options.create(true).append(true);
229 #[cfg(unix)]
230 {
231 use std::os::unix::fs::OpenOptionsExt;
232 options.mode(0o600);
233 }
234 let file = options
235 .open(path)
236 .map_err(|error| format!("open calibration stream: {error}"))?;
237 chmod(path, 0o600)?;
238 Ok(file)
239}
240
241fn append_row(path: &Path, row: &serde_json::Value) -> Result<(), String> {
242 let mut file = secure_append(path)?;
243 serde_json::to_writer(&mut file, row)
244 .map_err(|error| format!("encode calibration row: {error}"))?;
245 file.write_all(b"\n")
246 .map_err(|error| format!("append calibration row: {error}"))?;
247 file.sync_all()
248 .map_err(|error| format!("sync calibration row: {error}"))
249}
250
251fn read_rows(path: &Path) -> Result<Vec<serde_json::Value>, String> {
252 let file = File::open(path).map_err(|error| format!("open calibration stream: {error}"))?;
253 BufReader::new(file)
254 .lines()
255 .enumerate()
256 .filter_map(|(index, line)| match line {
257 Ok(line) if line.trim().is_empty() => None,
258 other => Some((index, other)),
259 })
260 .map(|(index, line)| {
261 let line = line.map_err(|error| format!("read calibration row: {error}"))?;
262 serde_json::from_str(&line)
263 .map_err(|error| format!("parse calibration row {}: {error}", index + 1))
264 })
265 .collect()
266}
267
268fn state_paths(state: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
269 (
270 state.join("activation.json"),
271 state.join("observations.jsonl"),
272 state.join("adjudications.jsonl"),
273 state.join("receipt.json"),
274 )
275}
276
277fn is_canonical_git_oid(revision: &str) -> bool {
278 matches!(revision.len(), 40 | 64)
279 && revision
280 .bytes()
281 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
282}
283
284fn validate_revisions(revisions: &Revisions) -> Result<(), String> {
285 if [
286 revisions.parent_a.as_str(),
287 revisions.parent_b.as_str(),
288 revisions.merged.as_str(),
289 ]
290 .into_iter()
291 .all(is_canonical_git_oid)
292 {
293 Ok(())
294 } else {
295 Err("observation revisions must use canonical Git object ids".to_string())
296 }
297}
298
299fn write_new(path: &Path, body: &[u8]) -> Result<(), String> {
300 let mut options = OpenOptions::new();
301 options.write(true).create_new(true);
302 #[cfg(unix)]
303 {
304 use std::os::unix::fs::OpenOptionsExt;
305 options.mode(0o600);
306 }
307 let mut file = options
308 .open(path)
309 .map_err(|error| format!("create calibration file: {error}"))?;
310 file.write_all(body)
311 .map_err(|error| format!("write calibration file: {error}"))?;
312 file.sync_all()
313 .map_err(|error| format!("sync calibration file: {error}"))?;
314 chmod(path, 0o600)
315}
316
317fn prepare_state(
322 state: &Path,
323 command_hash: &str,
324 environment_hash: Option<&str>,
325) -> Result<(), String> {
326 fs::create_dir_all(state).map_err(|error| format!("create calibration directory: {error}"))?;
327 chmod(state, 0o700)?;
328 let (activation, observations, adjudications, _) = state_paths(state);
329 if activation.exists() {
330 let mut value: serde_json::Value = serde_json::from_slice(
331 &fs::read(&activation)
332 .map_err(|error| format!("read calibration activation: {error}"))?,
333 )
334 .map_err(|error| format!("parse calibration activation: {error}"))?;
335 if value["format_version"].as_u64() != Some(FORMAT_VERSION)
336 || value["command_snapshot_hash"].as_str() != Some(command_hash)
337 {
338 return Err("calibration state belongs to a different command snapshot".to_string());
339 }
340 if let Some(environment_hash) = environment_hash {
341 match value["environment_hash"].as_str() {
342 Some(recorded) if recorded == environment_hash => {}
343 Some(_) => {
344 return Err("calibration state belongs to a different environment".to_string());
345 }
346 None => {
351 value["environment_hash"] =
352 serde_json::Value::String(environment_hash.to_string());
353 let body = serde_json::to_vec(&value)
354 .map_err(|error| format!("encode calibration activation: {error}"))?;
355 replace_file(state, &activation, &body)?;
356 }
357 }
358 }
359 chmod(&activation, 0o600)?;
360 } else {
361 let mut fields = serde_json::json!({
362 "format_version": FORMAT_VERSION,
363 "command_snapshot_hash": command_hash,
364 });
365 if let Some(environment_hash) = environment_hash {
366 fields["environment_hash"] = serde_json::Value::String(environment_hash.to_string());
367 }
368 let body = serde_json::to_vec(&fields)
369 .map_err(|error| format!("encode calibration activation: {error}"))?;
370 write_new(&activation, &body)?;
371 }
372 drop(secure_append(&observations)?);
373 drop(secure_append(&adjudications)?);
374 File::open(state)
375 .and_then(|directory| directory.sync_all())
376 .map_err(|error| format!("sync calibration directory: {error}"))
377}
378
379#[derive(Debug)]
380struct Folded {
381 next_id: u64,
382 reports: BTreeMap<u64, DifferentialReport>,
383 adjudicated_ids: BTreeSet<u64>,
384 receipt: serde_json::Value,
385}
386
387fn fold(state: &Path, command_hash: &str) -> Result<Folded, String> {
388 let (activation_path, observations_path, adjudications_path, _) = state_paths(state);
389 let mut reports = BTreeMap::new();
390 let mut unique_merge_commits = BTreeSet::new();
391 let rows = read_rows(&observations_path)?;
392 for (index, row) in rows.iter().enumerate() {
393 if row["format_version"].as_u64() != Some(FORMAT_VERSION) {
394 return Err("observation row needs format_version 1".to_string());
395 }
396 let expected = u64::try_from(index + 1).map_err(|_| "too many observations")?;
397 if row["observation_id"].as_u64() != Some(expected) {
398 return Err("observation ids must be contiguous from 1".to_string());
399 }
400 for field in ["parent_a", "parent_b", "merged"] {
401 if row["revisions"][field]
402 .as_str()
403 .filter(|revision| is_canonical_git_oid(revision))
404 .is_none()
405 {
406 return Err("observation row needs three canonical Git object ids".to_string());
407 }
408 }
409 unique_merge_commits.insert(
410 row["revisions"]["merged"]
411 .as_str()
412 .expect("validated above")
413 .to_string(),
414 );
415 reports.insert(expected, DifferentialReport::from_json(&row["report"])?);
416 }
417
418 let mut adjudications = BTreeMap::new();
419 for row in read_rows(&adjudications_path)? {
420 if row["format_version"].as_u64() != Some(FORMAT_VERSION) {
421 return Err("adjudication row needs format_version 1".to_string());
422 }
423 let id = row["observation_id"]
424 .as_u64()
425 .ok_or("adjudication row needs an observation_id")?;
426 let real = row["real_interaction"]
427 .as_bool()
428 .ok_or("adjudication row needs boolean real_interaction")?;
429 if adjudications.insert(id, real).is_some() {
430 return Err("an observation may be adjudicated only once".to_string());
431 }
432 }
433
434 let adjudicated_ids = adjudications.keys().copied().collect();
435 let mut calibration = Calibration::default();
436 for (id, report) in &reports {
437 match (report.verdict, adjudications.remove(id)) {
438 (Verdict::InteractionFailure, Some(real)) => calibration.record(report, Some(real))?,
439 (Verdict::InteractionFailure, None) => calibration.record_pending(report)?,
440 (_, Some(_)) => {
441 return Err("only an interaction failure may be adjudicated".to_string());
442 }
443 (_, None) => calibration.record(report, None)?,
444 }
445 }
446 if !adjudications.is_empty() {
447 return Err("adjudication refers to an unknown observation".to_string());
448 }
449
450 let mut receipt = calibration.receipt();
451 let receipt_object = receipt
452 .as_object_mut()
453 .ok_or("calibration receipt must be an object")?;
454 receipt_object.insert(
455 "command_snapshot_hash".to_string(),
456 serde_json::Value::String(command_hash.to_string()),
457 );
458 let activation: serde_json::Value = serde_json::from_slice(
461 &fs::read(&activation_path)
462 .map_err(|error| format!("read calibration activation: {error}"))?,
463 )
464 .map_err(|error| format!("parse calibration activation: {error}"))?;
465 receipt_object.insert(
466 "environment_hash".to_string(),
467 activation["environment_hash"].clone(),
468 );
469 receipt_object.insert("observations".to_string(), serde_json::json!(reports.len()));
470 receipt_object.insert(
471 "unique_merge_commits".to_string(),
472 serde_json::json!(unique_merge_commits.len()),
473 );
474 receipt_object.insert(
475 "has_conclusive_observation".to_string(),
476 serde_json::json!(receipt_object["evaluated_merges"].as_u64().unwrap_or(0) != 0),
477 );
478 receipt_object.insert(
479 "all_flags_adjudicated".to_string(),
480 serde_json::json!(receipt_object["pending_interactions"].as_u64() == Some(0)),
481 );
482 Ok(Folded {
483 next_id: u64::try_from(reports.len())
484 .map_err(|_| "too many observations")?
485 .saturating_add(1),
486 reports,
487 adjudicated_ids,
488 receipt,
489 })
490}
491
492fn replace_file(state: &Path, path: &Path, body: &[u8]) -> Result<(), String> {
493 let temp = state.join(format!(
494 ".replace-{}-{}",
495 std::process::id(),
496 REPLACE_TEMP_ID.fetch_add(1, Ordering::Relaxed)
497 ));
498 write_new(&temp, body)?;
499 fs::rename(&temp, path).map_err(|error| format!("replace calibration file: {error}"))?;
500 chmod(path, 0o600)?;
501 File::open(state)
502 .and_then(|directory| directory.sync_all())
503 .map_err(|error| format!("sync calibration directory: {error}"))
504}
505
506fn write_receipt(state: &Path, receipt: &serde_json::Value) -> Result<(), String> {
507 let (_, _, _, receipt_path) = state_paths(state);
508 let mut body = serde_json::to_vec_pretty(receipt)
509 .map_err(|error| format!("encode calibration receipt: {error}"))?;
510 body.push(b'\n');
511 replace_file(state, &receipt_path, &body)
512}
513
514pub fn record_observation(
527 state: &Path,
528 command_hash: &str,
529 environment_hash: &str,
530 revisions: &Revisions,
531 report: &DifferentialReport,
532) -> Result<RecordedObservation, String> {
533 validate_revisions(revisions)?;
534 prepare_state(state, command_hash, Some(environment_hash))?;
535 let before = fold(state, command_hash)?;
536 let (_, observations, _, _) = state_paths(state);
537 let row = serde_json::json!({
538 "format_version": FORMAT_VERSION,
539 "observation_id": before.next_id,
540 "revisions": {
541 "parent_a": revisions.parent_a,
542 "parent_b": revisions.parent_b,
543 "merged": revisions.merged,
544 },
545 "report": report.to_json(),
546 });
547 append_row(&observations, &row)?;
548 let after = fold(state, command_hash)?;
549 write_receipt(state, &after.receipt)?;
550 Ok(RecordedObservation {
551 observation_id: before.next_id,
552 report: report.clone(),
553 calibration: after.receipt,
554 })
555}
556
557pub fn adjudicate(
565 state: &Path,
566 command_hash: &str,
567 observation_id: u64,
568 real_interaction: bool,
569) -> Result<serde_json::Value, String> {
570 prepare_state(state, command_hash, None)?;
571 let before = fold(state, command_hash)?;
572 match before.reports.get(&observation_id) {
573 Some(report) if report.verdict == Verdict::InteractionFailure => {}
574 Some(_) => return Err("only an interaction failure may be adjudicated".to_string()),
575 None => return Err("adjudication refers to an unknown observation".to_string()),
576 }
577 if before.adjudicated_ids.contains(&observation_id) {
578 return Err("an observation may be adjudicated only once".to_string());
579 }
580 let (_, _, adjudications, _) = state_paths(state);
581 let row = serde_json::json!({
582 "format_version": FORMAT_VERSION,
583 "observation_id": observation_id,
584 "real_interaction": real_interaction,
585 });
586 append_row(&adjudications, &row)?;
587 let after = fold(state, command_hash)?;
588 write_receipt(state, &after.receipt)?;
589 Ok(after.receipt)
590}
591
592pub fn refresh(state: &Path, command_hash: &str) -> Result<serde_json::Value, String> {
598 prepare_state(state, command_hash, None)?;
599 let folded = fold(state, command_hash)?;
600 write_receipt(state, &folded.receipt)?;
601 Ok(folded.receipt)
602}