Skip to main content

artifactize_core/
cache.rs

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