Skip to main content

artifactize_core/
cache.rs

1//! Fingerprints, reuse keys, execution ownership, reuse, and end-of-review rechecks.
2
3mod 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
28/// Keep retained per-file provenance small; the content digest remains complete
29/// even when the optional explain-changes manifest is too large to store.
30const MANIFEST_BYTES: usize = 64 * 1024;
31/// Keep change explanations readable in CLI output; the complete path list remains in Changes.
32const MAX_SUMMARY_PATHS: usize = 10;
33/// Fingerprint scripts receive version 1's Artifact identity stdin envelope. Its
34/// explicit format version lets owner scripts distinguish future envelopes, not DB schemas.
35const FINGERPRINT_INPUT_VERSION: u32 = 1;
36/// Keep per-file change explanations compact with 8 digest bytes (16 hex characters).
37/// This diagnostic prefix is not identity: artifactsum/reuse keys retain full SHA-256.
38const MANIFEST_DIGEST_PREFIX_BYTES: usize = 8;
39/// Domain-separated kind-aware key format; changes invalidate persisted cache keys.
40const KIND_KEY_FORMAT_PREFIX: &[u8] = b"artifactize-kind-key-v1\n";
41/// Domain-separate and version the reuse-key hash input, including its terminating newline.
42/// Changing these bytes invalidates existing reuse keys; this is not a config/session/DB version.
43const REUSE_KEY_FORMAT_PREFIX: &str = "artifactize-key-v2\n";
44
45/// Hash the evaluation criteria: what is asked and how the answer is judged, never how the eval
46/// is executed. Execution options (backend, model, reasoning, limits, the profile variant),
47/// the eval's id and title, and tool views stay out.
48pub 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/// A prepared fingerprint; artifactsum also carries its manifest.
77#[derive(Debug, Clone)]
78pub struct PreparedFingerprint {
79    pub value: crate::types::Fingerprint,
80    pub manifest: Option<Manifest>,
81}
82
83/// What artifactsum covered, saved with executions to explain later changes.
84/// A file map that would exceed 64 KiB is omitted; the fingerprint still covers it.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Manifest {
87    /// SHA-256 over every input path and file digest: the fingerprint without its prefix.
88    pub inputs: crate::types::Sha256Digest,
89    /// Owner-relative path to the first 16 hex digits of its SHA-256.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub files: Option<BTreeMap<String, crate::types::DigestPrefix>>,
92}
93
94/// An eval's reuse key: `hash(evaluation criteria, sorted (name, fingerprint) of the Artifacts it
95/// depends on)`.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Key {
98    pub value: crate::types::ReuseKey,
99    pub eval_def_hash: crate::types::DefinitionHash,
100    /// Each Artifact the eval depends on, its target included, with its fingerprint.
101    pub fingerprints: BTreeMap<crate::types::ArtifactName, crate::types::Fingerprint>,
102    pub artifact_kinds: BTreeMap<crate::types::ArtifactName, crate::config::ArtifactKind>,
103}
104
105/// Why an eval has no reuse key: an Artifact it depends on declares `fingerprint: false`.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum Unkeyed {
108    /// Dependency Evals are always derived, never cached.
109    Derived,
110    /// The eval's own target.
111    Target,
112    /// A mount, child or referenced Artifact.
113    Dependency(ArtifactName),
114}
115
116/// Why the current key differs from an earlier cached result for the same eval and Eval
117/// definition.
118#[derive(Debug, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Changes {
121    pub since_run_id: crate::types::RunId,
122    /// The target's own files: `path` changed, `+path` added, `-path` removed; absent when
123    /// not comparable.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub files: Option<Vec<String>>,
126    /// Dependency Artifacts: `name` changed, `+name` added, `-name` removed; absent when none
127    /// changed or not comparable.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub dependencies: Option<Vec<String>>,
130    pub summary: String,
131}
132
133/// The Artifacts an eval depends on: its target, the target's mounts and child Artifacts, and
134/// the Artifacts the eval names in its instruction or runtime args. Connections further away
135/// count only through how the developer defines fingerprints.
136pub 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
145/// Only executable Evals need fingerprints; a derived verdict never runs a fingerprint
146/// script solely for its own request. Restrict preparation to the required key inputs.
147pub(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
162/// The reuse key of an Eval definition hash over these fingerprints.
163pub 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    // Names are identifiers and fingerprints never contain spaces or line breaks.
170    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
178/// Scope kinds distinguish file/folder observation without coupling reuse to repo paths.
179pub 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
196/// The eval's reuse key from prepared fingerprints. Without a fingerprint on every Artifact
197/// it depends on, the eval has no key, so it is never reused or published.
198pub 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
229/// Keys of every eval whose dependency Artifacts were all prepared, by eval id.
230pub 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/// How many fingerprints a process computes at once: one bound shared by a Run's
242/// preparation and its end-of-review rechecks, or by `status`.
243#[derive(Debug, Clone)]
244pub struct Parallelism {
245    slots: Arc<Semaphore>,
246    limit: usize,
247}
248
249impl Parallelism {
250    /// At most `limit` fingerprints at a time; at least one.
251    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    /// The default bound: the CPUs available to this process.
260    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
269/// Every file visible to a review must still be its declared regular target.
270pub(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
278/// Prepare only the selected dependency closure, once per Artifact, computing up to the
279/// parallelism's limit of fingerprints at a time. The result does not depend on completion
280/// order: a failure reports the first failing Artifact in input order, and fingerprints after
281/// it are cancelled because they cannot change which failure that is.
282pub 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                // A slot can be waited for long; a cancelled fingerprint never starts.
307                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
337/// Give a request another execution's result. A request that waited for this very
338/// execution (a live owner it joined, or the Human wait it followed) joined it; any other
339/// found a completed record.
340pub 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
368/// Recompute the eval's key after a review: every Artifact it depends on is fingerprinted
369/// again, so a change to any of them during the review is detected.
370pub 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
423/// The built-in hash of the Artifact's own input files, nothing else.
424async 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
464/// Explain a changed key against an earlier cached execution of the same Eval definition:
465/// which of the target's files changed, and which dependency Artifacts' fingerprints changed.
466pub 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(&current.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;