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