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