Skip to main content

vsh_commit/
plan.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4
5use vsh_policy::{read_set_digest, write_set_digest};
6use vsh_types::{
7    BlobId, ContentVersion, FileStamp, NodeKind, NodeState, PlatformFileId, SnapshotId,
8    TransactionBinding, TransactionId, VPath,
9};
10use vsh_vfs::{CanonicalDiff, ReadObservation, WritePrecondition};
11
12const PLAN_MAGIC: &[u8; 8] = b"VSHCMT01";
13
14/// Borrowed exact transaction artifact accepted by the trusted committer.
15pub struct CommitPlan<'a> {
16    binding: TransactionBinding,
17    diff: &'a CanonicalDiff,
18    read_set: &'a BTreeMap<VPath, ReadObservation>,
19    write_set: &'a BTreeMap<VPath, WritePrecondition>,
20}
21
22impl<'a> CommitPlan<'a> {
23    /// Validate that diff and dependency digests match the transaction binding.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error for digest mismatch, reserved paths, missing write
28    /// preconditions, or unmaterialized final content.
29    ///
30    /// # Panics
31    ///
32    /// Panics only if the compile-time trusted runtime-directory name stops satisfying
33    /// [`VPath`] rules.
34    pub fn new(
35        binding: &TransactionBinding,
36        diff: &'a CanonicalDiff,
37        read_set: &'a BTreeMap<VPath, ReadObservation>,
38        write_set: &'a BTreeMap<VPath, WritePrecondition>,
39    ) -> Result<Self, CommitPlanError> {
40        if binding.diff != diff.digest() {
41            return Err(CommitPlanError::DiffDigestMismatch);
42        }
43        if binding.read_set != read_set_digest(read_set) {
44            return Err(CommitPlanError::ReadSetDigestMismatch);
45        }
46        if binding.write_set != write_set_digest(write_set) {
47            return Err(CommitPlanError::WriteSetDigestMismatch);
48        }
49        let reserved = VPath::parse(crate::host::RUNTIME_DIRECTORY)
50            .expect("built-in runtime directory is a valid VPath");
51        for entry in diff.entries() {
52            if entry.path.is_root() {
53                return Err(CommitPlanError::RootMutation);
54            }
55            if entry.path.is_within(&reserved) {
56                return Err(CommitPlanError::ReservedPath {
57                    path: entry.path.clone(),
58                });
59            }
60            let parent = entry
61                .path
62                .parent()
63                .expect("non-root diff paths have a parent");
64            if read_set
65                .get(&parent)
66                .is_none_or(|observation| observation.metadata.is_none())
67            {
68                return Err(CommitPlanError::MissingParentDependency {
69                    path: entry.path.clone(),
70                    parent,
71                });
72            }
73            let Some(precondition) = write_set.get(&entry.path) else {
74                return Err(CommitPlanError::MissingWritePrecondition {
75                    path: entry.path.clone(),
76                });
77            };
78            if precondition.expected != entry.before {
79                // A lazily materialized before-state legitimately differs only by replacing
80                // its stamp with the exact blob. The precondition deliberately retains the
81                // stronger original host identity.
82                let materialized_stamp = matches!(
83                    (precondition.expected, entry.before),
84                    (
85                        Some(expected),
86                        Some(before)
87                    ) if matches!(expected.content(), Some(ContentVersion::Stamp(_)))
88                        && matches!(before.content(), Some(ContentVersion::Blob(_)))
89                        && expected.kind() == before.kind()
90                        && expected.size() == before.size()
91                        && expected.mode() == before.mode()
92                );
93                if !materialized_stamp {
94                    return Err(CommitPlanError::BeforeStateMismatch {
95                        path: entry.path.clone(),
96                    });
97                }
98            }
99            if let Some(after) = entry.after
100                && after.kind() != NodeKind::Directory
101                && !matches!(after.content(), Some(ContentVersion::Blob(_)))
102            {
103                return Err(CommitPlanError::UnmaterializedAfterState {
104                    path: entry.path.clone(),
105                });
106            }
107        }
108        Ok(Self {
109            binding: *binding,
110            diff,
111            read_set,
112            write_set,
113        })
114    }
115
116    #[must_use]
117    /// Return the transaction ID derived from the exact binding.
118    pub fn transaction(&self) -> TransactionId {
119        self.binding.transaction_id()
120    }
121
122    #[must_use]
123    /// Return the immutable base snapshot identity.
124    pub const fn base_snapshot(&self) -> SnapshotId {
125        self.binding.base_snapshot
126    }
127
128    #[must_use]
129    /// Return the complete identity binding.
130    pub const fn binding(&self) -> TransactionBinding {
131        self.binding
132    }
133
134    #[must_use]
135    /// Return the canonical final diff.
136    pub const fn diff(&self) -> &CanonicalDiff {
137        self.diff
138    }
139
140    #[must_use]
141    /// Return dependencies observed by virtual execution.
142    pub const fn read_set(&self) -> &BTreeMap<VPath, ReadObservation> {
143        self.read_set
144    }
145
146    #[must_use]
147    /// Return base preconditions for all virtual writes.
148    pub const fn write_set(&self) -> &BTreeMap<VPath, WritePrecondition> {
149        self.write_set
150    }
151}
152
153/// Invalid or unbounded immutable commit artifact.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum CommitPlanError {
156    /// Canonical diff digest does not match the transaction binding.
157    DiffDigestMismatch,
158    /// `ReadSet` digest does not match the transaction binding.
159    ReadSetDigestMismatch,
160    /// `WriteSet` digest does not match the transaction binding.
161    WriteSetDigestMismatch,
162    /// The diff attempts to replace the workspace root.
163    RootMutation,
164    /// The diff targets trusted runtime state.
165    ReservedPath {
166        /// Rejected path.
167        path: VPath,
168    },
169    /// A changed path lacks a base write precondition.
170    MissingWritePrecondition {
171        /// Affected path.
172        path: VPath,
173    },
174    /// A changed path lacks the parent-directory identity needed for safe `*at` access.
175    MissingParentDependency {
176        /// Changed path.
177        path: VPath,
178        /// Parent directory that was not recorded.
179        parent: VPath,
180    },
181    /// Diff and precondition disagree about base state.
182    BeforeStateMismatch {
183        /// Affected path.
184        path: VPath,
185    },
186    /// Final file or link content is not immutable and blob-backed.
187    UnmaterializedAfterState {
188        /// Affected path.
189        path: VPath,
190    },
191    /// Lowered operation count exceeds the configured maximum.
192    TooManyOperations {
193        /// Observed count.
194        observed: usize,
195        /// Configured maximum.
196        maximum: usize,
197    },
198    /// A normalized path exceeds the configured byte bound.
199    PathTooLong {
200        /// Rejected path.
201        path: VPath,
202        /// Configured maximum.
203        maximum: usize,
204    },
205    /// An operation count cannot fit the stable journal codec.
206    OperationCountOverflow,
207}
208
209impl fmt::Display for CommitPlanError {
210    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self {
212            Self::DiffDigestMismatch => {
213                formatter.write_str("commit diff digest does not match its transaction binding")
214            }
215            Self::ReadSetDigestMismatch => {
216                formatter.write_str("commit read-set digest does not match its transaction binding")
217            }
218            Self::WriteSetDigestMismatch => formatter
219                .write_str("commit write-set digest does not match its transaction binding"),
220            Self::RootMutation => formatter.write_str("the workspace root cannot be mutated"),
221            Self::ReservedPath { path } => {
222                write!(formatter, "commit targets reserved VSH path {path}")
223            }
224            Self::MissingWritePrecondition { path } => {
225                write!(formatter, "diff path {path} has no write precondition")
226            }
227            Self::MissingParentDependency { path, parent } => write!(
228                formatter,
229                "diff path {path} has no metadata dependency for parent {parent}"
230            ),
231            Self::BeforeStateMismatch { path } => write!(
232                formatter,
233                "diff before-state and write precondition disagree at {path}"
234            ),
235            Self::UnmaterializedAfterState { path } => {
236                write!(formatter, "commit after-state at {path} is not blob-backed")
237            }
238            Self::TooManyOperations { observed, maximum } => write!(
239                formatter,
240                "commit has {observed} operations; maximum is {maximum}"
241            ),
242            Self::PathTooLong { path, maximum } => write!(
243                formatter,
244                "commit path {path} exceeds the {maximum}-byte limit"
245            ),
246            Self::OperationCountOverflow => {
247                formatter.write_str("commit operation count cannot be encoded")
248            }
249        }
250    }
251}
252
253impl Error for CommitPlanError {}
254
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub(crate) enum Operation {
257    Quarantine {
258        path: VPath,
259        expected: NodeState,
260        slot: u32,
261    },
262    CreateDirectory {
263        path: VPath,
264        after: NodeState,
265    },
266    InstallFile {
267        path: VPath,
268        after: NodeState,
269        slot: u32,
270    },
271    InstallSymlink {
272        path: VPath,
273        after: NodeState,
274        slot: u32,
275    },
276    SetDirectoryMode {
277        path: VPath,
278        expected: NodeState,
279        after_mode: u32,
280    },
281}
282
283impl Operation {
284    pub(crate) fn path(&self) -> &VPath {
285        match self {
286            Self::Quarantine { path, .. }
287            | Self::CreateDirectory { path, .. }
288            | Self::InstallFile { path, .. }
289            | Self::InstallSymlink { path, .. }
290            | Self::SetDirectoryMode { path, .. } => path,
291        }
292    }
293}
294
295#[derive(Clone, Debug, Eq, PartialEq)]
296pub(crate) struct PreparedPlan {
297    pub(crate) transaction: TransactionId,
298    pub(crate) base_snapshot: SnapshotId,
299    pub(crate) operations: Vec<Operation>,
300    pub(crate) final_states: Vec<(VPath, Option<NodeState>)>,
301}
302
303impl PreparedPlan {
304    #[allow(clippy::too_many_lines)]
305    pub(crate) fn prepare(
306        plan: &CommitPlan<'_>,
307        max_operations: usize,
308        max_path_bytes: usize,
309    ) -> Result<Self, CommitPlanError> {
310        let mut destructive = Vec::new();
311        for entry in plan.diff.entries() {
312            let Some(before) = entry.before else {
313                continue;
314            };
315            let destructive_change = match entry.after {
316                None => true,
317                Some(after) if after.kind() != before.kind() => true,
318                Some(after) => matches!(after.kind(), NodeKind::File | NodeKind::Symlink),
319            };
320            if destructive_change {
321                destructive.push(entry.path.clone());
322            }
323        }
324        destructive.sort_unstable_by(|left, right| {
325            component_depth(left)
326                .cmp(&component_depth(right))
327                .then_with(|| left.cmp(right))
328        });
329        let mut destructive_roots = Vec::new();
330        for path in destructive {
331            if destructive_roots
332                .iter()
333                .any(|ancestor: &VPath| path.is_within(ancestor))
334            {
335                continue;
336            }
337            destructive_roots.push(path);
338        }
339
340        let mut operations = Vec::new();
341        let mut next_slot = 0_u32;
342        for path in &destructive_roots {
343            let expected = plan.write_set[path]
344                .expected
345                .expect("destructive diff entries have an existing precondition");
346            operations.push(Operation::Quarantine {
347                path: path.clone(),
348                expected,
349                slot: next_slot,
350            });
351            next_slot = next_slot
352                .checked_add(1)
353                .ok_or(CommitPlanError::OperationCountOverflow)?;
354        }
355
356        let mut directories = plan
357            .diff
358            .entries()
359            .iter()
360            .filter_map(|entry| {
361                let after = entry.after?;
362                let needs_create = after.kind() == NodeKind::Directory
363                    && entry
364                        .before
365                        .is_none_or(|before| before.kind() != NodeKind::Directory);
366                needs_create.then(|| (entry.path.clone(), NodeState::directory(after.mode())))
367            })
368            .collect::<Vec<_>>();
369        directories.sort_unstable_by(|left, right| {
370            component_depth(&left.0)
371                .cmp(&component_depth(&right.0))
372                .then_with(|| left.0.cmp(&right.0))
373        });
374        for (path, after) in directories {
375            operations.push(Operation::CreateDirectory { path, after });
376        }
377
378        for entry in plan.diff.entries() {
379            let Some(after) = entry.after else {
380                continue;
381            };
382            match after.kind() {
383                NodeKind::File => {
384                    operations.push(Operation::InstallFile {
385                        path: entry.path.clone(),
386                        after,
387                        slot: next_slot,
388                    });
389                    next_slot = next_slot
390                        .checked_add(1)
391                        .ok_or(CommitPlanError::OperationCountOverflow)?;
392                }
393                NodeKind::Symlink => {
394                    operations.push(Operation::InstallSymlink {
395                        path: entry.path.clone(),
396                        after,
397                        slot: next_slot,
398                    });
399                    next_slot = next_slot
400                        .checked_add(1)
401                        .ok_or(CommitPlanError::OperationCountOverflow)?;
402                }
403                NodeKind::Directory => {
404                    if let Some(before) = entry.before
405                        && before.kind() == NodeKind::Directory
406                        && before.mode() != after.mode()
407                    {
408                        operations.push(Operation::SetDirectoryMode {
409                            path: entry.path.clone(),
410                            expected: plan.write_set[&entry.path]
411                                .expected
412                                .expect("directory metadata change has a precondition"),
413                            after_mode: after.mode(),
414                        });
415                    }
416                }
417            }
418        }
419
420        if operations.len() > max_operations {
421            return Err(CommitPlanError::TooManyOperations {
422                observed: operations.len(),
423                maximum: max_operations,
424            });
425        }
426        for path in operations.iter().map(Operation::path) {
427            if path.as_str().len() > max_path_bytes {
428                return Err(CommitPlanError::PathTooLong {
429                    path: path.clone(),
430                    maximum: max_path_bytes,
431                });
432            }
433        }
434        let final_states = plan
435            .diff
436            .entries()
437            .iter()
438            .map(|entry| {
439                let after = entry.after.map(|state| {
440                    if state.kind() == NodeKind::Directory {
441                        NodeState::directory(state.mode())
442                    } else {
443                        state
444                    }
445                });
446                (entry.path.clone(), after)
447            })
448            .collect();
449        Ok(Self {
450            transaction: plan.transaction(),
451            base_snapshot: plan.base_snapshot(),
452            operations,
453            final_states,
454        })
455    }
456
457    pub(crate) fn encode(&self) -> Result<Vec<u8>, CommitPlanError> {
458        let operation_count = u32::try_from(self.operations.len())
459            .map_err(|_| CommitPlanError::OperationCountOverflow)?;
460        let final_count = u32::try_from(self.final_states.len())
461            .map_err(|_| CommitPlanError::OperationCountOverflow)?;
462        let mut output = Vec::new();
463        output.extend_from_slice(PLAN_MAGIC);
464        output.extend_from_slice(self.transaction.as_bytes());
465        output.extend_from_slice(self.base_snapshot.as_bytes());
466        output.extend_from_slice(&operation_count.to_le_bytes());
467        for operation in &self.operations {
468            encode_operation(operation, &mut output)?;
469        }
470        output.extend_from_slice(&final_count.to_le_bytes());
471        for (path, state) in &self.final_states {
472            encode_path(path, &mut output)?;
473            encode_optional_state(*state, &mut output);
474        }
475        let digest = plan_digest(&output);
476        output.extend_from_slice(&digest);
477        Ok(output)
478    }
479
480    pub(crate) fn decode(
481        bytes: &[u8],
482        max_operations: usize,
483        max_path_bytes: usize,
484    ) -> Result<Self, PlanDecodeError> {
485        if bytes.len() < PLAN_MAGIC.len() + 32 {
486            return Err(PlanDecodeError::Truncated);
487        }
488        let (payload, checksum) = bytes.split_at(bytes.len() - 32);
489        if plan_digest(payload).as_slice() != checksum {
490            return Err(PlanDecodeError::Checksum);
491        }
492        let mut reader = Reader::new(payload);
493        if reader.take(8)? != PLAN_MAGIC {
494            return Err(PlanDecodeError::Magic);
495        }
496        let transaction = TransactionId::from_bytes(reader.array()?);
497        let base_snapshot = SnapshotId::from_bytes(reader.array()?);
498        let operation_count = reader.u32()? as usize;
499        if operation_count > max_operations {
500            return Err(PlanDecodeError::Limit);
501        }
502        let mut operations = Vec::with_capacity(operation_count);
503        for _ in 0..operation_count {
504            operations.push(decode_operation(&mut reader, max_path_bytes)?);
505        }
506        let final_count = reader.u32()? as usize;
507        if final_count > max_operations {
508            return Err(PlanDecodeError::Limit);
509        }
510        let mut final_states = Vec::with_capacity(final_count);
511        for _ in 0..final_count {
512            let path = decode_path(&mut reader, max_path_bytes)?;
513            let state = decode_optional_state(&mut reader)?;
514            final_states.push((path, state));
515        }
516        if !reader.is_empty() {
517            return Err(PlanDecodeError::TrailingBytes);
518        }
519        Ok(Self {
520            transaction,
521            base_snapshot,
522            operations,
523            final_states,
524        })
525    }
526}
527
528fn component_depth(path: &VPath) -> usize {
529    if path.is_root() {
530        0
531    } else {
532        path.as_str().split('/').count()
533    }
534}
535
536fn slot_name(slot: u32) -> String {
537    format!("{slot:08x}")
538}
539
540pub(crate) fn stage_name(slot: u32) -> String {
541    slot_name(slot)
542}
543
544pub(crate) fn stage_link_name(slot: u32) -> String {
545    format!("{}.link", slot_name(slot))
546}
547
548pub(crate) fn quarantine_name(slot: u32) -> String {
549    slot_name(slot)
550}
551
552fn encode_operation(operation: &Operation, output: &mut Vec<u8>) -> Result<(), CommitPlanError> {
553    match operation {
554        Operation::Quarantine {
555            path,
556            expected,
557            slot,
558        } => {
559            output.push(1);
560            encode_path(path, output)?;
561            encode_state(*expected, output);
562            output.extend_from_slice(&slot.to_le_bytes());
563        }
564        Operation::CreateDirectory { path, after } => {
565            output.push(2);
566            encode_path(path, output)?;
567            encode_state(*after, output);
568        }
569        Operation::InstallFile { path, after, slot } => {
570            output.push(3);
571            encode_path(path, output)?;
572            encode_state(*after, output);
573            output.extend_from_slice(&slot.to_le_bytes());
574        }
575        Operation::InstallSymlink { path, after, slot } => {
576            output.push(4);
577            encode_path(path, output)?;
578            encode_state(*after, output);
579            output.extend_from_slice(&slot.to_le_bytes());
580        }
581        Operation::SetDirectoryMode {
582            path,
583            expected,
584            after_mode,
585        } => {
586            output.push(5);
587            encode_path(path, output)?;
588            encode_state(*expected, output);
589            output.extend_from_slice(&after_mode.to_le_bytes());
590        }
591    }
592    Ok(())
593}
594
595fn decode_operation(
596    reader: &mut Reader<'_>,
597    max_path: usize,
598) -> Result<Operation, PlanDecodeError> {
599    let tag = reader.u8()?;
600    let path = decode_path(reader, max_path)?;
601    match tag {
602        1 => Ok(Operation::Quarantine {
603            path,
604            expected: decode_state(reader)?,
605            slot: reader.u32()?,
606        }),
607        2 => Ok(Operation::CreateDirectory {
608            path,
609            after: decode_state(reader)?,
610        }),
611        3 => Ok(Operation::InstallFile {
612            path,
613            after: decode_state(reader)?,
614            slot: reader.u32()?,
615        }),
616        4 => Ok(Operation::InstallSymlink {
617            path,
618            after: decode_state(reader)?,
619            slot: reader.u32()?,
620        }),
621        5 => Ok(Operation::SetDirectoryMode {
622            path,
623            expected: decode_state(reader)?,
624            after_mode: reader.u32()?,
625        }),
626        _ => Err(PlanDecodeError::Tag),
627    }
628}
629
630fn encode_path(path: &VPath, output: &mut Vec<u8>) -> Result<(), CommitPlanError> {
631    let bytes = path.as_str().as_bytes();
632    let len = u32::try_from(bytes.len()).map_err(|_| CommitPlanError::PathTooLong {
633        path: path.clone(),
634        maximum: u32::MAX as usize,
635    })?;
636    output.extend_from_slice(&len.to_le_bytes());
637    output.extend_from_slice(bytes);
638    Ok(())
639}
640
641fn decode_path(reader: &mut Reader<'_>, max_path: usize) -> Result<VPath, PlanDecodeError> {
642    let len = reader.u32()? as usize;
643    if len > max_path {
644        return Err(PlanDecodeError::Limit);
645    }
646    let source = std::str::from_utf8(reader.take(len)?).map_err(|_| PlanDecodeError::Utf8)?;
647    VPath::parse(source).map_err(|_| PlanDecodeError::Path)
648}
649
650fn encode_optional_state(state: Option<NodeState>, output: &mut Vec<u8>) {
651    match state {
652        None => output.push(0),
653        Some(state) => {
654            output.push(1);
655            encode_state(state, output);
656        }
657    }
658}
659
660fn decode_optional_state(reader: &mut Reader<'_>) -> Result<Option<NodeState>, PlanDecodeError> {
661    match reader.u8()? {
662        0 => Ok(None),
663        1 => decode_state(reader).map(Some),
664        _ => Err(PlanDecodeError::Tag),
665    }
666}
667
668fn encode_state(state: NodeState, output: &mut Vec<u8>) {
669    output.push(state.kind().canonical_tag());
670    output.extend_from_slice(&state.size().to_le_bytes());
671    output.extend_from_slice(&state.mode().to_le_bytes());
672    match state.content() {
673        None => output.push(0),
674        Some(ContentVersion::Blob(blob)) => {
675            output.push(1);
676            output.extend_from_slice(blob.as_bytes());
677        }
678        Some(ContentVersion::Stamp(stamp)) => {
679            output.push(2);
680            encode_stamp(stamp, output);
681        }
682        Some(_) => unreachable!("all vsh-types content versions are explicitly encoded"),
683    }
684}
685
686fn decode_state(reader: &mut Reader<'_>) -> Result<NodeState, PlanDecodeError> {
687    let kind = decode_kind(reader.u8()?)?;
688    let size = reader.u64()?;
689    let mode = reader.u32()?;
690    match reader.u8()? {
691        0 if kind == NodeKind::Directory && size == 0 => Ok(NodeState::directory(mode)),
692        1 => {
693            let blob = BlobId::from_bytes(reader.array()?);
694            match kind {
695                NodeKind::File => Ok(NodeState::file(blob, size, mode)),
696                NodeKind::Symlink => Ok(NodeState::symlink(blob, size, mode)),
697                NodeKind::Directory => Err(PlanDecodeError::State),
698            }
699        }
700        2 => {
701            let stamp = decode_stamp(reader)?;
702            if stamp.kind != kind || stamp.size != size || stamp.mode != mode {
703                return Err(PlanDecodeError::State);
704            }
705            Ok(NodeState::from_stamp(stamp))
706        }
707        _ => Err(PlanDecodeError::State),
708    }
709}
710
711fn encode_stamp(stamp: FileStamp, output: &mut Vec<u8>) {
712    output.push(stamp.kind.canonical_tag());
713    output.extend_from_slice(&stamp.size.to_le_bytes());
714    output.extend_from_slice(&stamp.mode.to_le_bytes());
715    output.extend_from_slice(&stamp.mtime_ns.to_le_bytes());
716    match stamp.ctime_ns {
717        None => output.push(0),
718        Some(ctime) => {
719            output.push(1);
720            output.extend_from_slice(&ctime.to_le_bytes());
721        }
722    }
723    output.extend_from_slice(&stamp.file_id.high.to_le_bytes());
724    output.extend_from_slice(&stamp.file_id.low.to_le_bytes());
725}
726
727fn decode_stamp(reader: &mut Reader<'_>) -> Result<FileStamp, PlanDecodeError> {
728    let kind = decode_kind(reader.u8()?)?;
729    let size = reader.u64()?;
730    let mode = reader.u32()?;
731    let mtime_ns = reader.i128()?;
732    let ctime_ns = match reader.u8()? {
733        0 => None,
734        1 => Some(reader.i128()?),
735        _ => return Err(PlanDecodeError::Tag),
736    };
737    let high = reader.u64()?;
738    let low = reader.u64()?;
739    Ok(FileStamp {
740        kind,
741        size,
742        mode,
743        mtime_ns,
744        ctime_ns,
745        file_id: PlatformFileId { high, low },
746    })
747}
748
749fn decode_kind(tag: u8) -> Result<NodeKind, PlanDecodeError> {
750    match tag {
751        1 => Ok(NodeKind::File),
752        2 => Ok(NodeKind::Directory),
753        3 => Ok(NodeKind::Symlink),
754        _ => Err(PlanDecodeError::Tag),
755    }
756}
757
758fn plan_digest(bytes: &[u8]) -> [u8; 32] {
759    let mut hasher = blake3::Hasher::new();
760    hasher.update(b"vsh\0commit-plan-v1\0");
761    hasher.update(&(bytes.len() as u64).to_le_bytes());
762    hasher.update(bytes);
763    *hasher.finalize().as_bytes()
764}
765
766struct Reader<'a> {
767    bytes: &'a [u8],
768    offset: usize,
769}
770
771impl<'a> Reader<'a> {
772    const fn new(bytes: &'a [u8]) -> Self {
773        Self { bytes, offset: 0 }
774    }
775
776    fn take(&mut self, len: usize) -> Result<&'a [u8], PlanDecodeError> {
777        let end = self
778            .offset
779            .checked_add(len)
780            .ok_or(PlanDecodeError::Truncated)?;
781        let value = self
782            .bytes
783            .get(self.offset..end)
784            .ok_or(PlanDecodeError::Truncated)?;
785        self.offset = end;
786        Ok(value)
787    }
788
789    fn array<const N: usize>(&mut self) -> Result<[u8; N], PlanDecodeError> {
790        self.take(N)?
791            .try_into()
792            .map_err(|_| PlanDecodeError::Truncated)
793    }
794
795    fn u8(&mut self) -> Result<u8, PlanDecodeError> {
796        Ok(self.take(1)?[0])
797    }
798
799    fn u32(&mut self) -> Result<u32, PlanDecodeError> {
800        Ok(u32::from_le_bytes(self.array()?))
801    }
802
803    fn u64(&mut self) -> Result<u64, PlanDecodeError> {
804        Ok(u64::from_le_bytes(self.array()?))
805    }
806
807    fn i128(&mut self) -> Result<i128, PlanDecodeError> {
808        Ok(i128::from_le_bytes(self.array()?))
809    }
810
811    fn is_empty(&self) -> bool {
812        self.offset == self.bytes.len()
813    }
814}
815
816/// Durable commit-plan decoding failure.
817#[derive(Clone, Copy, Debug, Eq, PartialEq)]
818pub enum PlanDecodeError {
819    /// Input ended before a complete value.
820    Truncated,
821    /// Plan checksum does not match its bytes.
822    Checksum,
823    /// Plan header is unknown.
824    Magic,
825    /// A semantic tag is unknown.
826    Tag,
827    /// A path is not UTF-8.
828    Utf8,
829    /// A path violates [`VPath`] rules.
830    Path,
831    /// A node-state encoding is inconsistent.
832    State,
833    /// Decoded counts or path lengths exceed configured bounds.
834    Limit,
835    /// Bytes remain after the exact plan payload.
836    TrailingBytes,
837}
838
839impl fmt::Display for PlanDecodeError {
840    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
841        formatter.write_str(match self {
842            Self::Truncated => "commit plan is truncated",
843            Self::Checksum => "commit plan checksum mismatch",
844            Self::Magic => "commit plan has an unknown format",
845            Self::Tag => "commit plan contains an unknown tag",
846            Self::Utf8 => "commit plan path is not UTF-8",
847            Self::Path => "commit plan path is invalid",
848            Self::State => "commit plan node state is invalid",
849            Self::Limit => "commit plan exceeds configured bounds",
850            Self::TrailingBytes => "commit plan contains trailing bytes",
851        })
852    }
853}
854
855impl Error for PlanDecodeError {}