Skip to main content

vsh/
artifact.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4
5use vsh_monty::ExecutionStats;
6use vsh_policy::{RiskFlag, RiskManifest, RiskMetrics, read_set_digest, write_set_digest};
7use vsh_types::{
8    ContentVersion, DiffDigest, DiffEntry, DiffKind, DirectoryDigest, FileStamp, IntentDigest,
9    NodeKind, NodeState, PlatformFileId, PolicyDigest, ProgramDigest, ReadSetDigest,
10    RuntimeConfigDigest, SnapshotId, TransactionBinding, VPath, WriteSetDigest,
11};
12use vsh_vfs::{
13    CanonicalDiff, Effect, EffectEvent, EffectOrigin, ReadObservation, WritePrecondition,
14};
15
16use crate::runtime::{ArtifactLimits, Receipt, RuntimeDecision, StageTimings};
17
18const ARTIFACT_MAGIC_V1: &[u8; 8] = b"VSHPND01";
19const ARTIFACT_MAGIC_V2: &[u8; 8] = b"VSHPND02";
20
21#[derive(Clone)]
22pub(crate) struct ReviewEvidence {
23    pub(crate) intent: Option<String>,
24    pub(crate) metrics: RiskMetrics,
25    pub(crate) effects: Vec<EffectEvent>,
26    pub(crate) complete: bool,
27    pub(crate) truncated: bool,
28}
29
30#[derive(Clone)]
31pub(crate) struct PendingTransaction {
32    pub(crate) binding: TransactionBinding,
33    pub(crate) diff: CanonicalDiff,
34    pub(crate) read_set: BTreeMap<VPath, ReadObservation>,
35    pub(crate) write_set: BTreeMap<VPath, WritePrecondition>,
36    pub(crate) review: ReviewEvidence,
37    pub(crate) receipt: Receipt,
38}
39
40pub(crate) fn encode_pending(
41    artifact: &PendingTransaction,
42    limits: ArtifactLimits,
43) -> Result<Vec<u8>, ArtifactError> {
44    let mut output = Encoder::new(limits.max_bytes);
45    output.extend_from_slice(ARTIFACT_MAGIC_V2)?;
46    encode_binding(&artifact.binding, &mut output)?;
47    encode_review_evidence(&artifact.review, limits, &mut output)?;
48    output.push(u8::from(!artifact.receipt.changes.is_empty()))?;
49    encode_decision(&artifact.receipt.decision, &mut output)?;
50
51    let value_len =
52        postcard::experimental::serialized_size(&artifact.receipt.value).map_err(|source| {
53            ArtifactError::ValueCodec {
54                operation: "size",
55                detail: source.to_string(),
56            }
57        })?;
58    if value_len > limits.max_value_bytes {
59        return Err(ArtifactError::Limit {
60            field: "result value",
61            observed: value_len,
62            maximum: limits.max_value_bytes,
63        });
64    }
65    let mut value = Vec::new();
66    value
67        .try_reserve_exact(value_len)
68        .map_err(|source| ArtifactError::Allocation {
69            field: "result value",
70            requested: value_len,
71            detail: source.to_string(),
72        })?;
73    value.resize(value_len, 0);
74    postcard::to_slice(&artifact.receipt.value, value.as_mut_slice()).map_err(|source| {
75        ArtifactError::ValueCodec {
76            operation: "encode",
77            detail: source.to_string(),
78        }
79    })?;
80    encode_bytes(&value, &mut output)?;
81    if artifact.receipt.stdout.len() > limits.max_stdout_bytes {
82        return Err(ArtifactError::Limit {
83            field: "stdout",
84            observed: artifact.receipt.stdout.len(),
85            maximum: limits.max_stdout_bytes,
86        });
87    }
88    encode_bytes(artifact.receipt.stdout.as_bytes(), &mut output)?;
89    encode_execution_stats(artifact.receipt.execution, &mut output)?;
90    encode_timings(artifact.receipt.timings, &mut output)?;
91
92    if artifact.diff.entries().len() > limits.max_entries {
93        return Err(ArtifactError::Limit {
94            field: "diff entries",
95            observed: artifact.diff.entries().len(),
96            maximum: limits.max_entries,
97        });
98    }
99    encode_len(artifact.diff.entries().len(), &mut output)?;
100    for entry in artifact.diff.entries() {
101        encode_path(&entry.path, limits, &mut output)?;
102        encode_optional_state(entry.before, &mut output)?;
103        encode_optional_state(entry.after, &mut output)?;
104        output.push(diff_kind_tag(entry.kind))?;
105    }
106
107    if artifact.read_set.len() > limits.max_dependencies {
108        return Err(ArtifactError::Limit {
109            field: "read dependencies",
110            observed: artifact.read_set.len(),
111            maximum: limits.max_dependencies,
112        });
113    }
114    encode_len(artifact.read_set.len(), &mut output)?;
115    for (path, observation) in &artifact.read_set {
116        encode_path(path, limits, &mut output)?;
117        match observation.metadata {
118            None => output.push(0)?,
119            Some(None) => output.push(1)?,
120            Some(Some(state)) => {
121                output.push(2)?;
122                encode_state(state, &mut output)?;
123            }
124        }
125        encode_optional_digest(
126            observation.content.map(|value| *value.as_bytes()),
127            &mut output,
128        )?;
129        encode_optional_digest(
130            observation.directory.map(|value| *value.as_bytes()),
131            &mut output,
132        )?;
133    }
134
135    if artifact.write_set.len() > limits.max_dependencies {
136        return Err(ArtifactError::Limit {
137            field: "write dependencies",
138            observed: artifact.write_set.len(),
139            maximum: limits.max_dependencies,
140        });
141    }
142    encode_len(artifact.write_set.len(), &mut output)?;
143    for (path, precondition) in &artifact.write_set {
144        encode_path(path, limits, &mut output)?;
145        encode_optional_state(precondition.expected, &mut output)?;
146    }
147
148    Ok(output.finish())
149}
150
151pub(crate) fn decode_pending(
152    bytes: &[u8],
153    limits: ArtifactLimits,
154) -> Result<PendingTransaction, ArtifactError> {
155    if bytes.len() > limits.max_bytes {
156        return Err(ArtifactError::Limit {
157            field: "pending artifact",
158            observed: bytes.len(),
159            maximum: limits.max_bytes,
160        });
161    }
162    let mut decoder = Decoder::new(bytes);
163    let magic = decoder.take(ARTIFACT_MAGIC_V2.len())?;
164    let has_review_evidence = if magic == ARTIFACT_MAGIC_V2 {
165        true
166    } else if magic == ARTIFACT_MAGIC_V1 {
167        false
168    } else {
169        return Err(decoder.corrupt("invalid pending-artifact header"));
170    };
171    let binding = decode_binding(&mut decoder)?;
172    let review = has_review_evidence
173        .then(|| decode_review_evidence(&mut decoder, limits))
174        .transpose()?;
175    let full_detail = match decoder.byte()? {
176        0 => false,
177        1 => true,
178        _ => return Err(decoder.corrupt("invalid receipt-detail tag")),
179    };
180    let decision = decode_decision(&mut decoder)?;
181    let value_bytes = decoder.length_prefixed(limits.max_value_bytes, "result value")?;
182    let value = postcard::from_bytes(value_bytes).map_err(|source| ArtifactError::ValueCodec {
183        operation: "decode",
184        detail: source.to_string(),
185    })?;
186    let stdout = decoder.string(limits.max_stdout_bytes, "stdout")?;
187    let execution = decode_execution_stats(&mut decoder)?;
188    let timings = decode_timings(&mut decoder)?;
189
190    let diff = decode_diff(&mut decoder, limits)?;
191    let read_set = decode_read_set(&mut decoder, limits)?;
192    let write_set = decode_write_set(&mut decoder, limits)?;
193    decoder.finish()?;
194
195    if binding.diff != diff.digest()
196        || binding.read_set != read_set_digest(&read_set)
197        || binding.write_set != write_set_digest(&write_set)
198    {
199        return Err(ArtifactError::BindingMismatch);
200    }
201    let state = match &decision {
202        RuntimeDecision::AutoApproved => vsh_types::TransactionState::AutoApproved,
203        RuntimeDecision::PendingApproval(_) => vsh_types::TransactionState::PendingApproval,
204        RuntimeDecision::Denied(_) => return Err(decoder.corrupt("denied artifact is pending")),
205    };
206    let review = review.unwrap_or_else(|| ReviewEvidence {
207        intent: None,
208        metrics: match &decision {
209            RuntimeDecision::PendingApproval(manifest) => manifest.metrics,
210            RuntimeDecision::AutoApproved | RuntimeDecision::Denied(_) => RiskMetrics::default(),
211        },
212        effects: Vec::new(),
213        complete: false,
214        truncated: false,
215    });
216    let changes = if full_detail {
217        diff.entries().to_vec()
218    } else {
219        Vec::new()
220    };
221    let receipt = Receipt {
222        transaction: binding.transaction_id(),
223        base_snapshot: binding.base_snapshot,
224        state,
225        decision,
226        diff: diff.digest(),
227        changed_paths: diff.entries().len(),
228        changes,
229        value,
230        stdout,
231        execution,
232        timings,
233        commit: None,
234    };
235    Ok(PendingTransaction {
236        binding,
237        diff,
238        read_set,
239        write_set,
240        review,
241        receipt,
242    })
243}
244
245fn encode_review_evidence(
246    review: &ReviewEvidence,
247    limits: ArtifactLimits,
248    output: &mut Encoder,
249) -> Result<(), ArtifactError> {
250    match &review.intent {
251        None => output.push(0)?,
252        Some(intent) => {
253            if intent.len() > limits.max_intent_bytes {
254                return Err(ArtifactError::Limit {
255                    field: "intent",
256                    observed: intent.len(),
257                    maximum: limits.max_intent_bytes,
258                });
259            }
260            output.push(1)?;
261            encode_bytes(intent.as_bytes(), output)?;
262        }
263    }
264    encode_risk_metrics(review.metrics, output)?;
265    if review.effects.len() > limits.max_effects {
266        return Err(ArtifactError::Limit {
267            field: "review effects",
268            observed: review.effects.len(),
269            maximum: limits.max_effects,
270        });
271    }
272    encode_len(review.effects.len(), output)?;
273    for event in &review.effects {
274        output.extend_from_slice(&event.sequence.to_le_bytes())?;
275        output.push(effect_origin_tag(event.origin)?)?;
276        encode_effect(&event.effect, limits, output)?;
277    }
278    output.push(u8::from(review.complete))?;
279    output.push(u8::from(review.truncated))
280}
281
282fn decode_review_evidence(
283    decoder: &mut Decoder<'_>,
284    limits: ArtifactLimits,
285) -> Result<ReviewEvidence, ArtifactError> {
286    let intent = match decoder.byte()? {
287        0 => None,
288        1 => Some(decoder.string(limits.max_intent_bytes, "intent")?),
289        _ => return Err(decoder.corrupt("unknown optional-intent tag")),
290    };
291    let metrics = decode_risk_metrics(decoder)?;
292    let count = decoder.length(limits.max_effects, "review effects")?;
293    let mut effects = Vec::with_capacity(count);
294    for _ in 0..count {
295        let sequence = decoder.u64()?;
296        let origin = decode_effect_origin(decoder.byte()?)
297            .ok_or_else(|| decoder.corrupt("unknown effect-origin tag"))?;
298        let effect = decode_effect(decoder, limits)?;
299        effects.push(EffectEvent {
300            sequence,
301            origin,
302            effect,
303        });
304    }
305    if !effects
306        .windows(2)
307        .all(|pair| pair[0].sequence < pair[1].sequence)
308    {
309        return Err(decoder.corrupt("effect sequences are not strictly increasing"));
310    }
311    let complete = decode_bool(decoder, "evidence-complete")?;
312    let truncated = decode_bool(decoder, "evidence-truncated")?;
313    Ok(ReviewEvidence {
314        intent,
315        metrics,
316        effects,
317        complete,
318        truncated,
319    })
320}
321
322fn encode_effect(
323    effect: &Effect,
324    limits: ArtifactLimits,
325    output: &mut Encoder,
326) -> Result<(), ArtifactError> {
327    match effect {
328        Effect::MetadataRead { path, state } => {
329            output.push(1)?;
330            encode_path(path, limits, output)?;
331            encode_optional_state(*state, output)?;
332        }
333        Effect::ContentRead { path, blob } => {
334            output.push(2)?;
335            encode_path(path, limits, output)?;
336            output.extend_from_slice(blob.as_bytes())?;
337        }
338        Effect::DirectoryRead { path, digest } => {
339            output.push(3)?;
340            encode_path(path, limits, output)?;
341            output.extend_from_slice(digest.as_bytes())?;
342        }
343        Effect::Create { path, after } => {
344            output.push(4)?;
345            encode_path(path, limits, output)?;
346            encode_state(*after, output)?;
347        }
348        Effect::ModifyContent {
349            path,
350            before,
351            after,
352        } => {
353            output.push(5)?;
354            encode_path(path, limits, output)?;
355            encode_state(*before, output)?;
356            encode_state(*after, output)?;
357        }
358        Effect::Delete { path, before } => {
359            output.push(6)?;
360            encode_path(path, limits, output)?;
361            encode_state(*before, output)?;
362        }
363        Effect::Rename {
364            from,
365            to,
366            before,
367            after,
368        } => {
369            output.push(7)?;
370            encode_path(from, limits, output)?;
371            encode_path(to, limits, output)?;
372            encode_state(*before, output)?;
373            encode_state(*after, output)?;
374        }
375        _ => {
376            return Err(ArtifactError::Unsupported {
377                reason: "unknown effect variant",
378            });
379        }
380    }
381    Ok(())
382}
383
384fn decode_effect(
385    decoder: &mut Decoder<'_>,
386    limits: ArtifactLimits,
387) -> Result<Effect, ArtifactError> {
388    match decoder.byte()? {
389        1 => Ok(Effect::MetadataRead {
390            path: decoder.path(limits)?,
391            state: decode_optional_state(decoder)?,
392        }),
393        2 => Ok(Effect::ContentRead {
394            path: decoder.path(limits)?,
395            blob: vsh_types::BlobId::from_bytes(decoder.digest()?),
396        }),
397        3 => Ok(Effect::DirectoryRead {
398            path: decoder.path(limits)?,
399            digest: DirectoryDigest::from_bytes(decoder.digest()?),
400        }),
401        4 => Ok(Effect::Create {
402            path: decoder.path(limits)?,
403            after: decode_state(decoder)?,
404        }),
405        5 => Ok(Effect::ModifyContent {
406            path: decoder.path(limits)?,
407            before: decode_state(decoder)?,
408            after: decode_state(decoder)?,
409        }),
410        6 => Ok(Effect::Delete {
411            path: decoder.path(limits)?,
412            before: decode_state(decoder)?,
413        }),
414        7 => Ok(Effect::Rename {
415            from: decoder.path(limits)?,
416            to: decoder.path(limits)?,
417            before: decode_state(decoder)?,
418            after: decode_state(decoder)?,
419        }),
420        _ => Err(decoder.corrupt("unknown effect tag")),
421    }
422}
423
424fn effect_origin_tag(origin: EffectOrigin) -> Result<u8, ArtifactError> {
425    match origin {
426        EffectOrigin::VirtualFs => Ok(1),
427        EffectOrigin::MontyOsCall => Ok(2),
428        EffectOrigin::MontyToolCall => Ok(3),
429        _ => Err(ArtifactError::Unsupported {
430            reason: "unknown effect origin",
431        }),
432    }
433}
434
435const fn decode_effect_origin(tag: u8) -> Option<EffectOrigin> {
436    match tag {
437        1 => Some(EffectOrigin::VirtualFs),
438        2 => Some(EffectOrigin::MontyOsCall),
439        3 => Some(EffectOrigin::MontyToolCall),
440        _ => None,
441    }
442}
443
444fn decode_bool(decoder: &mut Decoder<'_>, field: &'static str) -> Result<bool, ArtifactError> {
445    match decoder.byte()? {
446        0 => Ok(false),
447        1 => Ok(true),
448        _ => Err(decoder.corrupt(field)),
449    }
450}
451
452fn encode_binding(binding: &TransactionBinding, output: &mut Encoder) -> Result<(), ArtifactError> {
453    output.extend_from_slice(binding.base_snapshot.as_bytes())?;
454    output.extend_from_slice(binding.diff.as_bytes())?;
455    output.extend_from_slice(binding.read_set.as_bytes())?;
456    output.extend_from_slice(binding.write_set.as_bytes())?;
457    output.extend_from_slice(binding.program.as_bytes())?;
458    output.extend_from_slice(binding.policy.as_bytes())?;
459    output.extend_from_slice(binding.runtime_config.as_bytes())?;
460    encode_optional_digest(binding.intent.map(|value| *value.as_bytes()), output)
461}
462
463fn decode_diff(
464    decoder: &mut Decoder<'_>,
465    limits: ArtifactLimits,
466) -> Result<CanonicalDiff, ArtifactError> {
467    let entry_count = decoder.length(limits.max_entries, "diff entries")?;
468    let mut entries = Vec::with_capacity(entry_count);
469    for _ in 0..entry_count {
470        let path = decoder.path(limits)?;
471        let before = decode_optional_state(decoder)?;
472        let after = decode_optional_state(decoder)?;
473        let kind = decode_diff_kind(decoder.byte()?)
474            .ok_or_else(|| decoder.corrupt("unknown diff-kind tag"))?;
475        entries.push(DiffEntry {
476            path,
477            before,
478            after,
479            kind,
480        });
481    }
482    CanonicalDiff::from_entries(entries)
483        .map_err(|_| decoder.corrupt("decoded diff is not canonical"))
484}
485
486fn decode_read_set(
487    decoder: &mut Decoder<'_>,
488    limits: ArtifactLimits,
489) -> Result<BTreeMap<VPath, ReadObservation>, ArtifactError> {
490    let count = decoder.length(limits.max_dependencies, "read dependencies")?;
491    let mut read_set = BTreeMap::new();
492    for _ in 0..count {
493        let path = decoder.path(limits)?;
494        let metadata = match decoder.byte()? {
495            0 => None,
496            1 => Some(None),
497            2 => Some(Some(decode_state(decoder)?)),
498            _ => return Err(decoder.corrupt("unknown metadata-observation tag")),
499        };
500        let content = decode_optional_digest(decoder)?.map(vsh_types::BlobId::from_bytes);
501        let directory = decode_optional_digest(decoder)?.map(DirectoryDigest::from_bytes);
502        if read_set
503            .insert(
504                path,
505                ReadObservation {
506                    metadata,
507                    content,
508                    directory,
509                },
510            )
511            .is_some()
512        {
513            return Err(decoder.corrupt("duplicate read dependency"));
514        }
515    }
516    Ok(read_set)
517}
518
519fn decode_write_set(
520    decoder: &mut Decoder<'_>,
521    limits: ArtifactLimits,
522) -> Result<BTreeMap<VPath, WritePrecondition>, ArtifactError> {
523    let count = decoder.length(limits.max_dependencies, "write dependencies")?;
524    let mut write_set = BTreeMap::new();
525    for _ in 0..count {
526        let path = decoder.path(limits)?;
527        let expected = decode_optional_state(decoder)?;
528        if write_set
529            .insert(path, WritePrecondition { expected })
530            .is_some()
531        {
532            return Err(decoder.corrupt("duplicate write dependency"));
533        }
534    }
535    Ok(write_set)
536}
537
538fn decode_binding(decoder: &mut Decoder<'_>) -> Result<TransactionBinding, ArtifactError> {
539    Ok(TransactionBinding {
540        base_snapshot: SnapshotId::from_bytes(decoder.digest()?),
541        diff: DiffDigest::from_bytes(decoder.digest()?),
542        read_set: ReadSetDigest::from_bytes(decoder.digest()?),
543        write_set: WriteSetDigest::from_bytes(decoder.digest()?),
544        program: ProgramDigest::from_bytes(decoder.digest()?),
545        policy: PolicyDigest::from_bytes(decoder.digest()?),
546        runtime_config: RuntimeConfigDigest::from_bytes(decoder.digest()?),
547        intent: decode_optional_digest(decoder)?.map(IntentDigest::from_bytes),
548    })
549}
550
551fn encode_decision(decision: &RuntimeDecision, output: &mut Encoder) -> Result<(), ArtifactError> {
552    match decision {
553        RuntimeDecision::AutoApproved => output.push(1)?,
554        RuntimeDecision::PendingApproval(manifest) => {
555            output.push(2)?;
556            encode_risk_manifest(manifest, output)?;
557        }
558        RuntimeDecision::Denied(_) => {
559            return Err(ArtifactError::Unsupported {
560                reason: "denied transactions cannot be pending",
561            });
562        }
563    }
564    Ok(())
565}
566
567fn decode_decision(decoder: &mut Decoder<'_>) -> Result<RuntimeDecision, ArtifactError> {
568    match decoder.byte()? {
569        1 => Ok(RuntimeDecision::AutoApproved),
570        2 => decode_risk_manifest(decoder).map(RuntimeDecision::PendingApproval),
571        _ => Err(decoder.corrupt("unknown runtime-decision tag")),
572    }
573}
574
575fn encode_risk_manifest(
576    manifest: &RiskManifest,
577    output: &mut Encoder,
578) -> Result<(), ArtifactError> {
579    encode_risk_metrics(manifest.metrics, output)?;
580    encode_len(manifest.flags.len(), output)?;
581    for flag in &manifest.flags {
582        output.push(risk_flag_tag(*flag))?;
583    }
584    output.extend_from_slice(manifest.policy.as_bytes())?;
585    Ok(())
586}
587
588fn decode_risk_manifest(decoder: &mut Decoder<'_>) -> Result<RiskManifest, ArtifactError> {
589    let metrics = decode_risk_metrics(decoder)?;
590    let count = decoder.length(32, "risk flags")?;
591    let mut flags = Vec::with_capacity(count);
592    for _ in 0..count {
593        flags.push(
594            decode_risk_flag(decoder.byte()?)
595                .ok_or_else(|| decoder.corrupt("unknown risk-flag tag"))?,
596        );
597    }
598    if !flags.windows(2).all(|pair| pair[0] < pair[1]) {
599        return Err(decoder.corrupt("risk flags are not strictly ordered"));
600    }
601    Ok(RiskManifest {
602        metrics,
603        flags,
604        policy: PolicyDigest::from_bytes(decoder.digest()?),
605    })
606}
607
608fn encode_risk_metrics(metrics: RiskMetrics, output: &mut Encoder) -> Result<(), ArtifactError> {
609    encode_len(metrics.touched_paths, output)?;
610    encode_len(metrics.created_paths, output)?;
611    encode_len(metrics.modified_paths, output)?;
612    encode_len(metrics.deleted_paths, output)?;
613    encode_len(metrics.renamed_paths, output)?;
614    output.extend_from_slice(&metrics.changed_bytes.to_le_bytes())?;
615    output.extend_from_slice(&metrics.delete_ratio_bps.to_le_bytes())?;
616    encode_len(metrics.executable_changes, output)?;
617    encode_len(metrics.symlink_changes, output)
618}
619
620fn decode_risk_metrics(decoder: &mut Decoder<'_>) -> Result<RiskMetrics, ArtifactError> {
621    Ok(RiskMetrics {
622        touched_paths: decoder.usize()?,
623        created_paths: decoder.usize()?,
624        modified_paths: decoder.usize()?,
625        deleted_paths: decoder.usize()?,
626        renamed_paths: decoder.usize()?,
627        changed_bytes: decoder.u64()?,
628        delete_ratio_bps: decoder.u16()?,
629        executable_changes: decoder.usize()?,
630        symlink_changes: decoder.usize()?,
631    })
632}
633
634fn encode_execution_stats(
635    stats: ExecutionStats,
636    output: &mut Encoder,
637) -> Result<(), ArtifactError> {
638    output.extend_from_slice(&stats.os_calls.to_le_bytes())?;
639    output.extend_from_slice(&stats.read_bytes.to_le_bytes())?;
640    output.extend_from_slice(&stats.write_bytes.to_le_bytes())?;
641    output.extend_from_slice(&stats.directory_entries.to_le_bytes())?;
642    encode_len(stats.output_bytes, output)?;
643    output.extend_from_slice(&stats.denied_accesses.to_le_bytes())?;
644    output.extend_from_slice(&stats.result_bytes.to_le_bytes())?;
645    Ok(())
646}
647
648fn decode_execution_stats(decoder: &mut Decoder<'_>) -> Result<ExecutionStats, ArtifactError> {
649    Ok(ExecutionStats {
650        os_calls: decoder.u64()?,
651        read_bytes: decoder.u64()?,
652        write_bytes: decoder.u64()?,
653        directory_entries: decoder.u64()?,
654        output_bytes: decoder.usize()?,
655        denied_accesses: decoder.u64()?,
656        result_bytes: decoder.u64()?,
657    })
658}
659
660fn encode_timings(timings: StageTimings, output: &mut Encoder) -> Result<(), ArtifactError> {
661    output.extend_from_slice(&timings.snapshot_ns.to_le_bytes())?;
662    output.extend_from_slice(&timings.execute_ns.to_le_bytes())?;
663    output.extend_from_slice(&timings.diff_ns.to_le_bytes())?;
664    output.extend_from_slice(&timings.policy_ns.to_le_bytes())?;
665    output.extend_from_slice(&timings.bind_and_store_ns.to_le_bytes())?;
666    output.extend_from_slice(&timings.commit_ns.to_le_bytes())?;
667    output.extend_from_slice(&timings.total_ns.to_le_bytes())
668}
669
670fn decode_timings(decoder: &mut Decoder<'_>) -> Result<StageTimings, ArtifactError> {
671    Ok(StageTimings {
672        snapshot_ns: decoder.u64()?,
673        execute_ns: decoder.u64()?,
674        diff_ns: decoder.u64()?,
675        policy_ns: decoder.u64()?,
676        bind_and_store_ns: decoder.u64()?,
677        commit_ns: decoder.u64()?,
678        total_ns: decoder.u64()?,
679    })
680}
681
682fn encode_path(
683    path: &VPath,
684    limits: ArtifactLimits,
685    output: &mut Encoder,
686) -> Result<(), ArtifactError> {
687    if path.as_str().len() > limits.max_path_bytes {
688        return Err(ArtifactError::Limit {
689            field: "path",
690            observed: path.as_str().len(),
691            maximum: limits.max_path_bytes,
692        });
693    }
694    encode_bytes(path.as_str().as_bytes(), output)
695}
696
697fn encode_optional_state(
698    state: Option<NodeState>,
699    output: &mut Encoder,
700) -> Result<(), ArtifactError> {
701    match state {
702        None => output.push(0)?,
703        Some(state) => {
704            output.push(1)?;
705            encode_state(state, output)?;
706        }
707    }
708    Ok(())
709}
710
711fn decode_optional_state(decoder: &mut Decoder<'_>) -> Result<Option<NodeState>, ArtifactError> {
712    match decoder.byte()? {
713        0 => Ok(None),
714        1 => decode_state(decoder).map(Some),
715        _ => Err(decoder.corrupt("unknown optional-state tag")),
716    }
717}
718
719fn encode_state(state: NodeState, output: &mut Encoder) -> Result<(), ArtifactError> {
720    output.push(node_kind_tag(state.kind()))?;
721    output.extend_from_slice(&state.size().to_le_bytes())?;
722    output.extend_from_slice(&state.mode().to_le_bytes())?;
723    match state.content() {
724        None => output.push(0)?,
725        Some(ContentVersion::Blob(blob)) => {
726            output.push(1)?;
727            output.extend_from_slice(blob.as_bytes())?;
728        }
729        Some(ContentVersion::Stamp(stamp)) => {
730            output.push(2)?;
731            encode_stamp(stamp, output)?;
732        }
733        Some(_) => {
734            return Err(ArtifactError::Unsupported {
735                reason: "unknown node content version",
736            });
737        }
738    }
739    Ok(())
740}
741
742fn decode_state(decoder: &mut Decoder<'_>) -> Result<NodeState, ArtifactError> {
743    let kind = decode_node_kind(decoder.byte()?)
744        .ok_or_else(|| decoder.corrupt("unknown node-kind tag"))?;
745    let size = decoder.u64()?;
746    let mode = decoder.u32()?;
747    let state = match decoder.byte()? {
748        0 if kind == NodeKind::Directory && size == 0 => NodeState::directory(mode),
749        1 if kind == NodeKind::File => {
750            NodeState::file(vsh_types::BlobId::from_bytes(decoder.digest()?), size, mode)
751        }
752        1 if kind == NodeKind::Symlink => {
753            NodeState::symlink(vsh_types::BlobId::from_bytes(decoder.digest()?), size, mode)
754        }
755        2 => {
756            let stamp = decode_stamp(decoder)?;
757            if stamp.kind != kind || stamp.size != size || stamp.mode != mode {
758                return Err(decoder.corrupt("node state and metadata stamp disagree"));
759            }
760            NodeState::from_stamp(stamp)
761        }
762        _ => return Err(decoder.corrupt("invalid node content encoding")),
763    };
764    Ok(state)
765}
766
767fn encode_stamp(stamp: FileStamp, output: &mut Encoder) -> Result<(), ArtifactError> {
768    output.push(node_kind_tag(stamp.kind))?;
769    output.extend_from_slice(&stamp.size.to_le_bytes())?;
770    output.extend_from_slice(&stamp.mode.to_le_bytes())?;
771    output.extend_from_slice(&stamp.mtime_ns.to_le_bytes())?;
772    match stamp.ctime_ns {
773        None => output.push(0)?,
774        Some(value) => {
775            output.push(1)?;
776            output.extend_from_slice(&value.to_le_bytes())?;
777        }
778    }
779    output.extend_from_slice(&stamp.file_id.high.to_le_bytes())?;
780    output.extend_from_slice(&stamp.file_id.low.to_le_bytes())
781}
782
783fn decode_stamp(decoder: &mut Decoder<'_>) -> Result<FileStamp, ArtifactError> {
784    let kind = decode_node_kind(decoder.byte()?)
785        .ok_or_else(|| decoder.corrupt("unknown stamp node-kind tag"))?;
786    let size = decoder.u64()?;
787    let mode = decoder.u32()?;
788    let mtime_ns = decoder.i128()?;
789    let ctime_ns = match decoder.byte()? {
790        0 => None,
791        1 => Some(decoder.i128()?),
792        _ => return Err(decoder.corrupt("unknown optional ctime tag")),
793    };
794    Ok(FileStamp {
795        kind,
796        size,
797        mode,
798        mtime_ns,
799        ctime_ns,
800        file_id: PlatformFileId {
801            high: decoder.u64()?,
802            low: decoder.u64()?,
803        },
804    })
805}
806
807fn encode_optional_digest(
808    value: Option<[u8; 32]>,
809    output: &mut Encoder,
810) -> Result<(), ArtifactError> {
811    match value {
812        None => output.push(0),
813        Some(bytes) => {
814            output.push(1)?;
815            output.extend_from_slice(&bytes)
816        }
817    }
818}
819
820fn decode_optional_digest(decoder: &mut Decoder<'_>) -> Result<Option<[u8; 32]>, ArtifactError> {
821    match decoder.byte()? {
822        0 => Ok(None),
823        1 => decoder.digest().map(Some),
824        _ => Err(decoder.corrupt("unknown optional-digest tag")),
825    }
826}
827
828fn encode_bytes(bytes: &[u8], output: &mut Encoder) -> Result<(), ArtifactError> {
829    encode_len(bytes.len(), output)?;
830    output.extend_from_slice(bytes)
831}
832
833fn encode_len(value: usize, output: &mut Encoder) -> Result<(), ArtifactError> {
834    let value = u64::try_from(value).map_err(|_| ArtifactError::Unsupported {
835        reason: "host length cannot be encoded",
836    })?;
837    output.extend_from_slice(&value.to_le_bytes())
838}
839
840const fn node_kind_tag(kind: NodeKind) -> u8 {
841    match kind {
842        NodeKind::File => 1,
843        NodeKind::Directory => 2,
844        NodeKind::Symlink => 3,
845    }
846}
847
848const fn decode_node_kind(tag: u8) -> Option<NodeKind> {
849    match tag {
850        1 => Some(NodeKind::File),
851        2 => Some(NodeKind::Directory),
852        3 => Some(NodeKind::Symlink),
853        _ => None,
854    }
855}
856
857const fn diff_kind_tag(kind: DiffKind) -> u8 {
858    match kind {
859        DiffKind::Create => 1,
860        DiffKind::Delete => 2,
861        DiffKind::Modify => 3,
862        DiffKind::MetadataChange => 4,
863    }
864}
865
866const fn decode_diff_kind(tag: u8) -> Option<DiffKind> {
867    match tag {
868        1 => Some(DiffKind::Create),
869        2 => Some(DiffKind::Delete),
870        3 => Some(DiffKind::Modify),
871        4 => Some(DiffKind::MetadataChange),
872        _ => None,
873    }
874}
875
876const fn risk_flag_tag(flag: RiskFlag) -> u8 {
877    match flag {
878        RiskFlag::Mutation => 1,
879        RiskFlag::Deletion => 2,
880        RiskFlag::Rename => 3,
881        RiskFlag::ExecutableChange => 4,
882        RiskFlag::SymlinkChange => 5,
883        RiskFlag::LargeTouchedSet => 6,
884        RiskFlag::LargeByteChange => 7,
885    }
886}
887
888const fn decode_risk_flag(tag: u8) -> Option<RiskFlag> {
889    match tag {
890        1 => Some(RiskFlag::Mutation),
891        2 => Some(RiskFlag::Deletion),
892        3 => Some(RiskFlag::Rename),
893        4 => Some(RiskFlag::ExecutableChange),
894        5 => Some(RiskFlag::SymlinkChange),
895        6 => Some(RiskFlag::LargeTouchedSet),
896        7 => Some(RiskFlag::LargeByteChange),
897        _ => None,
898    }
899}
900
901struct Encoder {
902    bytes: Vec<u8>,
903    maximum: usize,
904}
905
906impl Encoder {
907    const fn new(maximum: usize) -> Self {
908        Self {
909            bytes: Vec::new(),
910            maximum,
911        }
912    }
913
914    fn push(&mut self, byte: u8) -> Result<(), ArtifactError> {
915        self.extend_from_slice(&[byte])
916    }
917
918    fn extend_from_slice(&mut self, bytes: &[u8]) -> Result<(), ArtifactError> {
919        let required = self
920            .bytes
921            .len()
922            .checked_add(bytes.len())
923            .ok_or(ArtifactError::Limit {
924                field: "pending artifact",
925                observed: usize::MAX,
926                maximum: self.maximum,
927            })?;
928        if required > self.maximum {
929            return Err(ArtifactError::Limit {
930                field: "pending artifact",
931                observed: required,
932                maximum: self.maximum,
933            });
934        }
935        if required > self.bytes.capacity() {
936            let doubled = self.bytes.capacity().max(2_048).saturating_mul(2);
937            let target = doubled.max(required).min(self.maximum);
938            self.bytes
939                .try_reserve_exact(target.saturating_sub(self.bytes.len()))
940                .map_err(|source| ArtifactError::Allocation {
941                    field: "pending artifact",
942                    requested: target,
943                    detail: source.to_string(),
944                })?;
945        }
946        self.bytes.extend_from_slice(bytes);
947        Ok(())
948    }
949
950    fn finish(self) -> Vec<u8> {
951        self.bytes
952    }
953}
954
955struct Decoder<'a> {
956    bytes: &'a [u8],
957    offset: usize,
958}
959
960impl<'a> Decoder<'a> {
961    const fn new(bytes: &'a [u8]) -> Self {
962        Self { bytes, offset: 0 }
963    }
964
965    fn take(&mut self, count: usize) -> Result<&'a [u8], ArtifactError> {
966        let end = self
967            .offset
968            .checked_add(count)
969            .ok_or_else(|| self.corrupt("artifact offset overflow"))?;
970        let value = self
971            .bytes
972            .get(self.offset..end)
973            .ok_or_else(|| self.corrupt("truncated pending artifact"))?;
974        self.offset = end;
975        Ok(value)
976    }
977
978    fn byte(&mut self) -> Result<u8, ArtifactError> {
979        Ok(self.take(1)?[0])
980    }
981
982    fn u16(&mut self) -> Result<u16, ArtifactError> {
983        Ok(u16::from_le_bytes(self.array()?))
984    }
985
986    fn u32(&mut self) -> Result<u32, ArtifactError> {
987        Ok(u32::from_le_bytes(self.array()?))
988    }
989
990    fn u64(&mut self) -> Result<u64, ArtifactError> {
991        Ok(u64::from_le_bytes(self.array()?))
992    }
993
994    fn i128(&mut self) -> Result<i128, ArtifactError> {
995        Ok(i128::from_le_bytes(self.array()?))
996    }
997
998    fn usize(&mut self) -> Result<usize, ArtifactError> {
999        usize::try_from(self.u64()?).map_err(|_| self.corrupt("length does not fit this host"))
1000    }
1001
1002    fn length(&mut self, maximum: usize, field: &'static str) -> Result<usize, ArtifactError> {
1003        let value = self.usize()?;
1004        if value > maximum {
1005            return Err(ArtifactError::Limit {
1006                field,
1007                observed: value,
1008                maximum,
1009            });
1010        }
1011        Ok(value)
1012    }
1013
1014    fn length_prefixed(
1015        &mut self,
1016        maximum: usize,
1017        field: &'static str,
1018    ) -> Result<&'a [u8], ArtifactError> {
1019        let length = self.length(maximum, field)?;
1020        self.take(length)
1021    }
1022
1023    fn string(&mut self, maximum: usize, field: &'static str) -> Result<String, ArtifactError> {
1024        let bytes = self.length_prefixed(maximum, field)?;
1025        std::str::from_utf8(bytes)
1026            .map(str::to_owned)
1027            .map_err(|_| self.corrupt("artifact string is not UTF-8"))
1028    }
1029
1030    fn path(&mut self, limits: ArtifactLimits) -> Result<VPath, ArtifactError> {
1031        let value = self.string(limits.max_path_bytes, "path")?;
1032        VPath::parse(&value).map_err(|_| self.corrupt("artifact path is invalid"))
1033    }
1034
1035    fn digest(&mut self) -> Result<[u8; 32], ArtifactError> {
1036        self.array()
1037    }
1038
1039    fn array<const N: usize>(&mut self) -> Result<[u8; N], ArtifactError> {
1040        let mut output = [0_u8; N];
1041        output.copy_from_slice(self.take(N)?);
1042        Ok(output)
1043    }
1044
1045    fn finish(&self) -> Result<(), ArtifactError> {
1046        if self.offset == self.bytes.len() {
1047            Ok(())
1048        } else {
1049            Err(self.corrupt("trailing pending-artifact bytes"))
1050        }
1051    }
1052
1053    const fn corrupt(&self, reason: &'static str) -> ArtifactError {
1054        ArtifactError::Corrupt {
1055            offset: self.offset,
1056            reason,
1057        }
1058    }
1059}
1060
1061/// Durable pending-artifact encoding or validation failure.
1062#[derive(Debug)]
1063pub enum ArtifactError {
1064    /// One bounded field or complete artifact exceeded configured limits.
1065    Limit {
1066        /// Bounded field.
1067        field: &'static str,
1068        /// Observed units.
1069        observed: usize,
1070        /// Maximum accepted units.
1071        maximum: usize,
1072    },
1073    /// A bounded allocation failed before any unbounded growth was attempted.
1074    Allocation {
1075        /// Buffer being allocated.
1076        field: &'static str,
1077        /// Requested byte capacity.
1078        requested: usize,
1079        /// Allocator failure detail.
1080        detail: String,
1081    },
1082    /// The content-addressed artifact is malformed.
1083    Corrupt {
1084        /// Byte offset nearest the violation.
1085        offset: usize,
1086        /// Stable validation reason.
1087        reason: &'static str,
1088    },
1089    /// The serialized Monty result could not be encoded or decoded.
1090    ValueCodec {
1091        /// Codec direction.
1092        operation: &'static str,
1093        /// Postcard error detail.
1094        detail: String,
1095    },
1096    /// A future non-exhaustive value cannot be represented safely by this codec.
1097    Unsupported {
1098        /// Stable rejection reason.
1099        reason: &'static str,
1100    },
1101    /// Recomputed diff/dependency identities do not match the transaction binding.
1102    BindingMismatch,
1103}
1104
1105impl fmt::Display for ArtifactError {
1106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1107        match self {
1108            Self::Limit {
1109                field,
1110                observed,
1111                maximum,
1112            } => write!(
1113                formatter,
1114                "pending artifact {field} is {observed}; maximum is {maximum}"
1115            ),
1116            Self::Allocation {
1117                field,
1118                requested,
1119                detail,
1120            } => write!(
1121                formatter,
1122                "cannot allocate {requested} bytes for pending artifact {field}: {detail}"
1123            ),
1124            Self::Corrupt { offset, reason } => {
1125                write!(
1126                    formatter,
1127                    "pending artifact is corrupt at byte {offset}: {reason}"
1128                )
1129            }
1130            Self::ValueCodec { operation, detail } => {
1131                write!(
1132                    formatter,
1133                    "cannot {operation} pending result value: {detail}"
1134                )
1135            }
1136            Self::Unsupported { reason } => {
1137                write!(
1138                    formatter,
1139                    "pending artifact contains an unsupported value: {reason}"
1140                )
1141            }
1142            Self::BindingMismatch => formatter.write_str(
1143                "pending artifact diff or dependencies do not match its transaction binding",
1144            ),
1145        }
1146    }
1147}
1148
1149impl Error for ArtifactError {}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154    use vsh_monty::MontyObject;
1155    use vsh_types::{BlobId, TransactionState};
1156
1157    fn fixture() -> PendingTransaction {
1158        let path = VPath::parse("result.txt").unwrap();
1159        let state = NodeState::file(BlobId::digest(b"result"), 6, 0o644);
1160        let diff = CanonicalDiff::from_entries(vec![DiffEntry {
1161            path: path.clone(),
1162            before: None,
1163            after: Some(state),
1164            kind: DiffKind::Create,
1165        }])
1166        .unwrap();
1167        let read_set = BTreeMap::new();
1168        let write_set = BTreeMap::from([(path, WritePrecondition { expected: None })]);
1169        let binding = TransactionBinding {
1170            base_snapshot: SnapshotId::from_bytes([1; 32]),
1171            diff: diff.digest(),
1172            read_set: read_set_digest(&read_set),
1173            write_set: write_set_digest(&write_set),
1174            program: ProgramDigest::digest_source("artifact-test"),
1175            policy: PolicyDigest::digest_canonical(b"artifact-policy"),
1176            runtime_config: RuntimeConfigDigest::digest_canonical(b"artifact-runtime"),
1177            intent: Some(IntentDigest::digest_text("create result")),
1178        };
1179        let receipt = Receipt {
1180            transaction: binding.transaction_id(),
1181            base_snapshot: binding.base_snapshot,
1182            state: TransactionState::AutoApproved,
1183            decision: RuntimeDecision::AutoApproved,
1184            diff: diff.digest(),
1185            changed_paths: 1,
1186            changes: diff.entries().to_vec(),
1187            value: MontyObject::Int(42),
1188            stdout: "ok\n".to_owned(),
1189            execution: ExecutionStats {
1190                os_calls: 1,
1191                write_bytes: 6,
1192                output_bytes: 3,
1193                result_bytes: 8,
1194                ..ExecutionStats::default()
1195            },
1196            timings: StageTimings {
1197                total_ns: 123,
1198                ..StageTimings::default()
1199            },
1200            commit: None,
1201        };
1202        PendingTransaction {
1203            binding,
1204            diff,
1205            read_set,
1206            write_set,
1207            review: ReviewEvidence {
1208                intent: Some("create result".to_owned()),
1209                metrics: RiskMetrics {
1210                    touched_paths: 1,
1211                    created_paths: 1,
1212                    changed_bytes: 6,
1213                    ..RiskMetrics::default()
1214                },
1215                effects: vec![EffectEvent {
1216                    sequence: 1,
1217                    origin: EffectOrigin::MontyOsCall,
1218                    effect: Effect::Create {
1219                        path: VPath::parse("result.txt").unwrap(),
1220                        after: state,
1221                    },
1222                }],
1223                complete: true,
1224                truncated: false,
1225            },
1226            receipt,
1227        }
1228    }
1229
1230    #[test]
1231    fn pending_artifact_round_trip_preserves_exact_binding_and_receipt() {
1232        let artifact = fixture();
1233        let bytes = encode_pending(&artifact, ArtifactLimits::default()).unwrap();
1234        let decoded = decode_pending(&bytes, ArtifactLimits::default()).unwrap();
1235
1236        assert_eq!(decoded.binding, artifact.binding);
1237        assert_eq!(decoded.diff, artifact.diff);
1238        assert_eq!(decoded.read_set, artifact.read_set);
1239        assert_eq!(decoded.write_set, artifact.write_set);
1240        assert_eq!(decoded.review.intent, artifact.review.intent);
1241        assert_eq!(decoded.review.metrics, artifact.review.metrics);
1242        assert_eq!(decoded.review.effects, artifact.review.effects);
1243        assert!(decoded.review.complete);
1244        assert!(!decoded.review.truncated);
1245        assert_eq!(decoded.receipt.transaction, artifact.receipt.transaction);
1246        assert_eq!(decoded.receipt.changes, artifact.receipt.changes);
1247        assert_eq!(decoded.receipt.value, MontyObject::Int(42));
1248        assert_eq!(decoded.receipt.stdout, "ok\n");
1249        assert_eq!(decoded.receipt.execution, artifact.receipt.execution);
1250        assert_eq!(decoded.receipt.timings, artifact.receipt.timings);
1251    }
1252
1253    #[test]
1254    fn version_one_artifact_decodes_with_incomplete_review_evidence() {
1255        let artifact = fixture();
1256        let limits = ArtifactLimits::default();
1257        let current = encode_pending(&artifact, limits).unwrap();
1258
1259        let mut binding = Encoder::new(limits.max_bytes);
1260        encode_binding(&artifact.binding, &mut binding).unwrap();
1261        let binding_len = binding.finish().len();
1262        let mut review = Encoder::new(limits.max_bytes);
1263        encode_review_evidence(&artifact.review, limits, &mut review).unwrap();
1264        let review_len = review.finish().len();
1265        let body = &current[ARTIFACT_MAGIC_V2.len()..];
1266        let mut legacy = Vec::with_capacity(current.len() - review_len);
1267        legacy.extend_from_slice(ARTIFACT_MAGIC_V1);
1268        legacy.extend_from_slice(&body[..binding_len]);
1269        legacy.extend_from_slice(&body[binding_len + review_len..]);
1270
1271        let decoded = decode_pending(&legacy, limits).unwrap();
1272        assert_eq!(decoded.binding, artifact.binding);
1273        assert!(!decoded.review.complete);
1274        assert!(!decoded.review.truncated);
1275        assert!(decoded.review.intent.is_none());
1276        assert!(decoded.review.effects.is_empty());
1277    }
1278
1279    #[test]
1280    fn pending_artifact_rejects_tampered_binding_and_trailing_bytes() {
1281        let mut bytes = encode_pending(&fixture(), ArtifactLimits::default()).unwrap();
1282        bytes[ARTIFACT_MAGIC_V2.len() + 32] ^= 0x80;
1283        assert!(matches!(
1284            decode_pending(&bytes, ArtifactLimits::default()),
1285            Err(ArtifactError::BindingMismatch)
1286        ));
1287
1288        let mut bytes = encode_pending(&fixture(), ArtifactLimits::default()).unwrap();
1289        bytes.push(0);
1290        assert!(matches!(
1291            decode_pending(&bytes, ArtifactLimits::default()),
1292            Err(ArtifactError::Corrupt {
1293                reason: "trailing pending-artifact bytes",
1294                ..
1295            })
1296        ));
1297    }
1298
1299    #[test]
1300    fn pending_artifact_limits_apply_before_unbounded_materialization() {
1301        let artifact = fixture();
1302        let limits = ArtifactLimits {
1303            max_value_bytes: 0,
1304            ..ArtifactLimits::default()
1305        };
1306        assert!(matches!(
1307            encode_pending(&artifact, limits),
1308            Err(ArtifactError::Limit {
1309                field: "result value",
1310                maximum: 0,
1311                ..
1312            })
1313        ));
1314
1315        let bytes = encode_pending(&artifact, ArtifactLimits::default()).unwrap();
1316        let limits = ArtifactLimits {
1317            max_bytes: bytes.len() - 1,
1318            ..ArtifactLimits::default()
1319        };
1320        assert!(matches!(
1321            encode_pending(&artifact, limits),
1322            Err(ArtifactError::Limit {
1323                field: "pending artifact",
1324                ..
1325            })
1326        ));
1327        assert!(matches!(
1328            decode_pending(&bytes, limits),
1329            Err(ArtifactError::Limit {
1330                field: "pending artifact",
1331                ..
1332            })
1333        ));
1334    }
1335
1336    #[test]
1337    fn artifact_error_messages_identify_each_failure_class() {
1338        let errors = [
1339            ArtifactError::Limit {
1340                field: "value",
1341                observed: 2,
1342                maximum: 1,
1343            },
1344            ArtifactError::Allocation {
1345                field: "value",
1346                requested: 2,
1347                detail: "test".to_owned(),
1348            },
1349            ArtifactError::Corrupt {
1350                offset: 1,
1351                reason: "test",
1352            },
1353            ArtifactError::ValueCodec {
1354                operation: "decode",
1355                detail: "test".to_owned(),
1356            },
1357            ArtifactError::Unsupported { reason: "test" },
1358            ArtifactError::BindingMismatch,
1359        ];
1360        let messages = errors.map(|error| error.to_string());
1361
1362        assert!(messages.iter().all(|message| !message.is_empty()));
1363        assert_eq!(
1364            messages
1365                .iter()
1366                .collect::<std::collections::BTreeSet<_>>()
1367                .len(),
1368            messages.len()
1369        );
1370    }
1371}