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