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