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#[derive(Debug)]
1063pub enum ArtifactError {
1064 Limit {
1066 field: &'static str,
1068 observed: usize,
1070 maximum: usize,
1072 },
1073 Allocation {
1075 field: &'static str,
1077 requested: usize,
1079 detail: String,
1081 },
1082 Corrupt {
1084 offset: usize,
1086 reason: &'static str,
1088 },
1089 ValueCodec {
1091 operation: &'static str,
1093 detail: String,
1095 },
1096 Unsupported {
1098 reason: &'static str,
1100 },
1101 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 = ¤t[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}