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