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