1mod content;
4
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 path::Path,
8 sync::Arc,
9};
10
11use futures_util::{StreamExt, stream::FuturesUnordered};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use sha2::{Digest, Sha256};
15use tokio::sync::Semaphore;
16use tokio_util::sync::CancellationToken;
17
18pub use crate::store::history::{Entry, list, remove, show};
19pub(crate) use content::{HASH_BUFFER_BYTES, ignore_patterns};
20
21use crate::{
22 config::{ArtifactName, Eval, EvalDeclaration, Fingerprint, Profile, RepoConfig},
23 process, runtime, scope,
24 store::{Execution, Request},
25 workspace,
26};
27
28const MANIFEST_BYTES: usize = 64 * 1024;
31const MAX_SUMMARY_PATHS: usize = 10;
33const FINGERPRINT_INPUT_VERSION: u32 = 1;
36const MANIFEST_DIGEST_PREFIX_BYTES: usize = 8;
39const KIND_KEY_FORMAT_PREFIX: &[u8] = b"artifactize-kind-key-v1\n";
41const REUSE_KEY_FORMAT_PREFIX: &str = "artifactize-key-v2\n";
44
45pub fn eval_definition_hash(eval: &EvalDeclaration) -> crate::types::DefinitionHash {
49 let kind = match &eval.profile() {
50 Profile::Agent { .. } => "agent",
51 Profile::Human {} => "human",
52 Profile::Dependency { .. } => "dependency",
53 Profile::Runtime { .. } => "runtime",
54 };
55 let mut criteria = json!({
56 "kind": kind,
57 "payload": eval.payload(),
58 "passSchema": eval.pass_schema(),
59 "failSchema": eval.fail_schema(),
60 });
61 if let Profile::Runtime { command, args, .. } = &eval.profile() {
62 criteria["command"] = json!(command);
63 criteria["args"] = json!(args);
64 }
65 if let Profile::Dependency { depends_on } = &eval.profile() {
66 criteria["dependsOn"] = json!(depends_on);
67 }
68 criteria.sort_all_objects();
69 content::hex(&Sha256::digest(
70 serde_json::to_vec(&criteria).expect("evaluation criteria is JSON"),
71 ))
72 .parse()
73 .expect("a SHA-256 digest in hex is a definition hash")
74}
75
76#[derive(Debug, Clone)]
78pub struct PreparedFingerprint {
79 pub value: crate::types::Fingerprint,
80 pub manifest: Option<Manifest>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Manifest {
87 pub inputs: crate::types::Sha256Digest,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub files: Option<BTreeMap<String, crate::types::DigestPrefix>>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Key {
98 pub value: crate::types::ReuseKey,
99 pub eval_def_hash: crate::types::DefinitionHash,
100 pub fingerprints: BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
102 pub artifact_kinds: BTreeMap<crate::types::ArtifactName, crate::config::ArtifactKind>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum Unkeyed {
108 Derived,
110 Target,
112 Dependency(ArtifactName),
114}
115
116#[derive(Debug, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Changes {
121 pub since_run_id: crate::types::RunId,
122 #[serde(skip_serializing_if = "Option::is_none")]
125 pub files: Option<Vec<String>>,
126 #[serde(skip_serializing_if = "Option::is_none")]
129 pub dependencies: Option<Vec<String>>,
130 pub summary: String,
131}
132
133pub fn dependencies<'a>(config: &'a RepoConfig, eval: &'a Eval) -> BTreeSet<&'a ArtifactName> {
137 let target = &config.artifacts[&eval.target];
138 std::iter::once(&eval.target)
139 .chain(target.mounts.values())
140 .chain(target.children.values())
141 .chain(&eval.deps)
142 .collect()
143}
144
145pub(crate) fn fingerprint_targets<'a>(
148 config: &'a RepoConfig,
149 required: &BTreeSet<&'a ArtifactName>,
150) -> BTreeSet<&'a ArtifactName> {
151 config
152 .evals
153 .iter()
154 .filter(|eval| {
155 required.contains(&eval.target)
156 && !matches!(eval.declaration.profile(), Profile::Dependency { .. })
157 })
158 .flat_map(|eval| dependencies(config, eval))
159 .collect()
160}
161
162pub fn key(
164 eval_def_hash: &crate::types::DefinitionHash,
165 fingerprints: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
166) -> crate::types::ReuseKey {
167 let mut digest = Sha256::new();
168 digest.update(format!("{REUSE_KEY_FORMAT_PREFIX}eval {eval_def_hash}\n"));
169 for (name, fingerprint) in fingerprints {
171 digest.update(format!("artifact {name} {fingerprint}\n"));
172 }
173 content::hex(&digest.finalize())
174 .parse()
175 .expect("SHA-256 is a reuse key")
176}
177
178pub fn key_with_kinds(
180 eval_def_hash: &crate::types::DefinitionHash,
181 fingerprints: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
182 kinds: &BTreeMap<crate::types::ArtifactName, crate::config::ArtifactKind>,
183) -> crate::types::ReuseKey {
184 if kinds.is_empty() {
185 return key(eval_def_hash, fingerprints);
186 }
187 let mut digest = Sha256::new();
188 digest.update(KIND_KEY_FORMAT_PREFIX);
189 digest.update(key(eval_def_hash, fingerprints).as_bytes());
190 digest.update(serde_json::to_vec(kinds).expect("Artifact kinds are JSON"));
191 content::hex(&digest.finalize())
192 .parse()
193 .expect("SHA-256 is a reuse key")
194}
195
196pub fn eval_key(
199 config: &RepoConfig,
200 eval: &Eval,
201 fingerprints: &BTreeMap<&ArtifactName, PreparedFingerprint>,
202) -> Result<Key, Unkeyed> {
203 if matches!(eval.declaration.profile(), Profile::Dependency { .. }) {
204 return Err(Unkeyed::Derived);
205 }
206 if !fingerprints.contains_key(&eval.target) {
207 return Err(Unkeyed::Target);
208 }
209 let mut values: BTreeMap<ArtifactName, crate::types::Fingerprint> = BTreeMap::new();
210 for id in dependencies(config, eval) {
211 let fingerprint = fingerprints
212 .get(id)
213 .ok_or_else(|| Unkeyed::Dependency(id.clone()))?;
214 values.insert(id.clone(), fingerprint.value.clone());
215 }
216 let eval_def_hash = eval_definition_hash(&eval.declaration);
217 let artifact_kinds = values
218 .keys()
219 .map(|id| (id.clone(), config.artifacts[id].kind))
220 .collect();
221 Ok(Key {
222 value: key_with_kinds(&eval_def_hash, &values, &artifact_kinds),
223 eval_def_hash,
224 fingerprints: values,
225 artifact_kinds,
226 })
227}
228
229pub fn eval_keys<'a>(
231 config: &'a RepoConfig,
232 fingerprints: &BTreeMap<&ArtifactName, PreparedFingerprint>,
233) -> BTreeMap<&'a crate::types::EvalId, Key> {
234 config
235 .evals
236 .iter()
237 .filter_map(|eval| Some((&eval.id, eval_key(config, eval, fingerprints).ok()?)))
238 .collect()
239}
240
241#[derive(Debug, Clone)]
244pub struct Parallelism {
245 slots: Arc<Semaphore>,
246 limit: usize,
247}
248
249impl Parallelism {
250 pub fn new(limit: usize) -> Self {
252 let limit = limit.max(1);
253 Self {
254 slots: Arc::new(Semaphore::new(limit)),
255 limit,
256 }
257 }
258
259 pub fn available() -> usize {
261 std::thread::available_parallelism().map_or(1, usize::from)
262 }
263
264 pub fn limit(&self) -> usize {
265 self.limit
266 }
267}
268
269pub(crate) fn validate_file_inputs(config: &RepoConfig, eval: &Eval) -> Result<(), String> {
271 let scope = scope::eval_scope(config, eval).map_err(|error| error.to_string())?;
272 for artifact in scope.artifacts.values() {
273 scope::validate_file_target(&config.root, artifact).map_err(|error| error.to_string())?;
274 }
275 Ok(())
276}
277
278pub async fn prepare<'a>(
283 config: &RepoConfig,
284 artifacts: impl IntoIterator<Item = &'a ArtifactName>,
285 output_root: &Path,
286 parallelism: &Parallelism,
287 cancellation: CancellationToken,
288) -> Result<BTreeMap<&'a ArtifactName, PreparedFingerprint>, String> {
289 let artifacts: Vec<_> = artifacts.into_iter().collect();
290 for id in &artifacts {
291 scope::validate_file_target(&config.root, &config.artifacts[*id])
292 .map_err(|error| error.to_string())?;
293 }
294 let mut seen = BTreeSet::new();
295 let ids: Vec<&'a ArtifactName> = artifacts
296 .into_iter()
297 .filter(|id| config.artifacts[*id].fingerprint.is_some() && seen.insert(*id))
298 .collect();
299 let tokens: Vec<_> = ids.iter().map(|_| cancellation.child_token()).collect();
300 let mut running: FuturesUnordered<_> = ids
301 .iter()
302 .zip(&tokens)
303 .enumerate()
304 .map(|(index, (id, token))| async move {
305 let result = match parallelism.slots.acquire().await {
306 Ok(_slot) if !token.is_cancelled() => {
308 compute(config, id, output_root, token.clone()).await
309 }
310 _ => Err(process::Error::Cancelled.to_string()),
311 };
312 (index, result)
313 })
314 .collect();
315 let mut prepared = BTreeMap::new();
316 let mut failure: Option<(usize, String)> = None;
317 while let Some((index, result)) = running.next().await {
318 match result {
319 Ok(fingerprint) => {
320 prepared.insert(ids[index], fingerprint);
321 }
322 Err(error) if failure.as_ref().is_none_or(|(first, _)| index < *first) => {
323 for token in &tokens[index + 1..] {
324 token.cancel();
325 }
326 failure = Some((index, error));
327 }
328 Err(_) => {}
329 }
330 }
331 match failure {
332 Some((_, error)) => Err(error),
333 None => Ok(prepared),
334 }
335}
336
337pub fn reuse(request: &mut Request, execution: &Execution, completed_at: crate::types::Timestamp) {
341 request.joined = request.execution_id.as_ref() == Some(&execution.id);
342 request.state = match execution.state.clone() {
343 crate::store::ExecutionState::Completed { result, .. } => {
344 crate::store::RequestState::completed(result, completed_at)
345 }
346 crate::store::ExecutionState::Failed { error, code, .. } => {
347 crate::store::RequestState::failed(error, code, completed_at)
348 }
349 state => state.into(),
350 };
351 request.execution_id = Some(execution.id.clone());
352 request.profile = execution.profile.clone();
353 request.options = execution.options.clone();
354 request.provenance = Some(execution.provenance.clone());
355 request.usage = None;
356 request.reused_usage = execution.usage.clone();
357 request.producer = execution.producer.clone();
358 request.session = execution
359 .producer
360 .as_ref()
361 .and_then(|producer| producer.session.clone());
362 request.reviewer = execution.reviewer.clone();
363 request.origin = execution.origin.clone();
364 request.queue = None;
365 request.blocked_reason = None;
366}
367
368pub async fn recheck(
371 config: &RepoConfig,
372 eval: &Eval,
373 output_root: &Path,
374 parallelism: &Parallelism,
375 cancellation: CancellationToken,
376) -> Result<Option<crate::types::ReuseKey>, String> {
377 validate_file_inputs(config, eval)?;
378 let fingerprints = prepare(
379 config,
380 dependencies(config, eval),
381 output_root,
382 parallelism,
383 cancellation,
384 )
385 .await?;
386 validate_file_inputs(config, eval)?;
387 Ok(eval_key(config, eval, &fingerprints)
388 .ok()
389 .map(|key| key.value))
390}
391
392async fn compute(
393 config: &RepoConfig,
394 id: &ArtifactName,
395 output_root: &Path,
396 cancellation: CancellationToken,
397) -> Result<PreparedFingerprint, String> {
398 scope::validate_file_target(&config.root, &config.artifacts[id])
399 .map_err(|error| error.to_string())?;
400 let result = match &config.artifacts[id].fingerprint {
401 Some(Fingerprint::Graph) => Ok(PreparedFingerprint {
402 value: crate::graph_structure::read(&config.root)?.fingerprint(),
403 manifest: None,
404 }),
405 Some(Fingerprint::Artifactsum { files, ignore }) => {
406 content(config, id, files, ignore, &cancellation)
407 .await
408 .map_err(|error| format!("Artifactsum for Artifact {id} failed: {error}"))
409 }
410 Some(Fingerprint::Script { .. }) => Ok(PreparedFingerprint {
411 value: script(config, id, output_root, cancellation)
412 .await
413 .map_err(|error| format!("Fingerprint script for Artifact {id} failed: {error}"))?,
414 manifest: None,
415 }),
416 None => Err(format!("Artifact {id} declares fingerprint: false.")),
417 }?;
418 scope::validate_file_target(&config.root, &config.artifacts[id])
419 .map_err(|error| error.to_string())?;
420 Ok(result)
421}
422
423async fn content(
425 config: &RepoConfig,
426 id: &ArtifactName,
427 inputs: &[crate::config::LogicalPath],
428 ignore: &[String],
429 cancellation: &CancellationToken,
430) -> Result<PreparedFingerprint, String> {
431 let files = content::files(config, id, inputs, ignore, cancellation).await?;
432 let mut manifest = Manifest {
433 inputs: files.digest.clone(),
434 files: Some(
435 files
436 .files
437 .iter()
438 .map(|(path, file)| {
439 (
440 path.to_string(),
441 content::hex(&file.as_bytes()[..MANIFEST_DIGEST_PREFIX_BYTES])
442 .parse()
443 .expect("eight-byte SHA-256 prefix"),
444 )
445 })
446 .collect(),
447 ),
448 };
449 if serde_json::to_vec(&manifest)
450 .expect("manifest is JSON")
451 .len()
452 > MANIFEST_BYTES
453 {
454 manifest.files = None;
455 }
456 Ok(PreparedFingerprint {
457 value: format!("artifactsum:{}", files.digest)
458 .parse()
459 .expect("artifactsum digest is a fingerprint"),
460 manifest: Some(manifest),
461 })
462}
463
464pub fn changes(
467 previous: &Execution,
468 target: &ArtifactName,
469 current: &Key,
470 manifest: Option<&Manifest>,
471) -> Changes {
472 let mut parts = Vec::new();
473 let mut files = None;
474 if previous.fingerprints.get(target) != current.fingerprints.get(target) {
475 match previous.manifest.as_ref().zip(manifest) {
476 Some((old, new)) if old.inputs == new.inputs => files = Some(Vec::new()),
477 Some((old, new)) => {
478 files = old
479 .files
480 .as_ref()
481 .zip(new.files.as_ref())
482 .map(|(old, new)| diff(old, new));
483 match &files {
484 Some(files) if !files.is_empty() => {
485 let shown = files
486 .iter()
487 .take(MAX_SUMMARY_PATHS)
488 .cloned()
489 .collect::<Vec<_>>()
490 .join(", ");
491 parts.push(match files.len() {
492 0..=MAX_SUMMARY_PATHS => format!("changed: {shown}"),
493 total => {
494 format!("changed: {shown} and {} more", total - MAX_SUMMARY_PATHS)
495 }
496 });
497 }
498 _ => parts.push("inputs changed".to_owned()),
499 }
500 }
501 None => parts.push("fingerprint changed".to_owned()),
502 }
503 }
504 let dependencies = (!previous.fingerprints.is_empty())
505 .then(|| {
506 let others = |map: &BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>| {
507 map.iter()
508 .filter(|(name, _)| *name != target)
509 .map(|(name, value)| (name.clone(), value.clone()))
510 .collect::<BTreeMap<_, _>>()
511 };
512 diff(
513 &others(&previous.fingerprints),
514 &others(¤t.fingerprints),
515 )
516 })
517 .filter(|dependencies| !dependencies.is_empty());
518 for dependency in dependencies.iter().flatten() {
519 parts.push(if let Some(id) = dependency.strip_prefix('+') {
520 format!("dependency {id} added")
521 } else if let Some(id) = dependency.strip_prefix('-') {
522 format!("dependency {id} removed")
523 } else {
524 format!("dependency {dependency} changed")
525 });
526 }
527 if parts.is_empty() {
528 parts.push("fingerprint changed".to_owned());
529 }
530 Changes {
531 since_run_id: previous.provenance.run_id.clone(),
532 files,
533 dependencies,
534 summary: parts.join("; "),
535 }
536}
537
538fn diff<K: Ord + std::fmt::Display, T: PartialEq>(
539 old: &BTreeMap<K, T>,
540 new: &BTreeMap<K, T>,
541) -> Vec<String> {
542 old.keys()
543 .chain(new.keys())
544 .collect::<BTreeSet<_>>()
545 .into_iter()
546 .filter_map(|key| match (old.get(key), new.get(key)) {
547 (Some(old), Some(new)) if old != new => Some(key.to_string()),
548 (None, Some(_)) => Some(format!("+{key}")),
549 (Some(_), None) => Some(format!("-{key}")),
550 _ => None,
551 })
552 .collect()
553}
554
555async fn script(
556 config: &RepoConfig,
557 artifact_id: &ArtifactName,
558 output_root: &Path,
559 cancellation: CancellationToken,
560) -> Result<crate::types::Fingerprint, String> {
561 if cancellation.is_cancelled() {
562 return Err(process::Error::Cancelled.to_string());
563 }
564 let artifact = &config.artifacts[artifact_id];
565 let Some(Fingerprint::Script {
566 command,
567 args,
568 files,
569 timeout_ms,
570 }) = &artifact.fingerprint
571 else {
572 unreachable!("fingerprint script")
573 };
574 let cwd = scope::scoped_path(&config.root, artifact.folder()).map_err(|e| e.to_string())?;
575 for input in files {
576 scope::scoped_path(&cwd, Path::new(input)).map_err(|e| e.to_string())?;
577 }
578 let scope = scope::argv_scope(config, artifact_id, args).map_err(|e| e.to_string())?;
579 let program = scope::executable(&config.root, &scope, artifact_id, command)
580 .map_err(|error| format!("Fingerprint executable could not be spawned: {error}"))?;
581 let args = scope::resolve_argv(config, &scope, artifact_id, args).map_err(|e| e.to_string())?;
582 let input = json!({"version":FINGERPRINT_INPUT_VERSION,"artifactId":artifact_id});
583 let output_root =
584 workspace::prepare_directory(output_root, &config.root).map_err(|e| e.to_string())?;
585 let disposable = tempfile::Builder::new()
586 .prefix("fingerprint-")
587 .tempdir_in(output_root)
588 .map_err(|e| e.to_string())?;
589 let result = async {
590 let mut command = runtime::Command::prepare(
591 program,
592 args.iter().map(Into::into).collect(),
593 &config.root,
594 disposable.path(),
595 *timeout_ms,
596 )
597 .map_err(|e| e.to_string())?;
598 command.cwd = cwd;
599 let output = command
600 .output(input.to_string().into_bytes(), cancellation.clone())
601 .await
602 .map_err(|e| e.to_string())?;
603 if !output.status.success() {
604 return Err(format!(
605 "Fingerprint command exited with {}.",
606 crate::platform::exit_description(&output.status)
607 ));
608 }
609 if output.truncated {
610 return Err("Fingerprint command output exceeded its limit.".into());
611 }
612 validate_output(&output.stdout)
613 }
614 .await;
615 let cleaned = disposable.close();
616 if cancellation.is_cancelled() {
617 return Err(process::Error::Cancelled.to_string());
618 }
619 let value = result?;
620 cleaned.map_err(|_| "Fingerprint validation failed.".to_owned())?;
621 Ok(value)
622}
623
624fn validate_output(stdout: &[u8]) -> Result<crate::types::Fingerprint, String> {
625 let (value, endings) = crate::platform::fingerprint_line_ending(stdout);
626 if !(1..=crate::types::MAX_FINGERPRINT_BYTES).contains(&value.len())
627 || !value
628 .iter()
629 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
630 {
631 return Err(format!(
632 "stdout must contain 1–128 characters from [A-Za-z0-9._:-], with at most one trailing {endings}."
633 ));
634 }
635 String::from_utf8(value.to_vec())
636 .map_err(|error| error.to_string())?
637 .parse()
638}
639
640#[cfg(test)]
641mod tests;