Skip to main content

agent_migrate_core/
bundle.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use crate::{BundleRelativePath, FileKind, FileRecord, Manifest, SchemaVersion};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Bundle {
12    manifest: Manifest,
13    content: BTreeMap<BundleRelativePath, Vec<u8>>,
14}
15
16impl Bundle {
17    /// Restores a Bundle from an existing manifest and content map.
18    ///
19    /// # Errors
20    ///
21    /// Returns an error when the manifest and content do not match.
22    pub fn from_parts(
23        manifest: Manifest,
24        content: BTreeMap<BundleRelativePath, Vec<u8>>,
25    ) -> Result<Self, BundleError> {
26        let bundle = Self { manifest, content };
27        bundle.verify()?;
28        Ok(bundle)
29    }
30
31    /// Builds a verified Bundle and computes every content hash.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error for unsupported schemas or an inconsistent file set.
36    pub fn from_files(
37        mut manifest: Manifest,
38        files: BTreeMap<BundleRelativePath, (FileKind, Vec<u8>)>,
39    ) -> Result<Self, BundleError> {
40        SchemaVersion::new(manifest.schema.major(), manifest.schema.minor())?;
41        let mut content = BTreeMap::new();
42        for (path, (kind, bytes)) in files {
43            manifest.files.insert(
44                path.clone(),
45                FileRecord {
46                    kind,
47                    mode: 0o600,
48                    sha256: digest(&bytes),
49                },
50            );
51            content.insert(path, bytes);
52        }
53        let bundle = Self { manifest, content };
54        bundle.verify()?;
55        Ok(bundle)
56    }
57
58    /// Builds a verified Bundle with portable file modes.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error when the schema or file set is invalid.
63    pub fn from_files_with_modes(
64        mut manifest: Manifest,
65        files: BTreeMap<BundleRelativePath, (FileKind, u32, Vec<u8>)>,
66    ) -> Result<Self, BundleError> {
67        SchemaVersion::new(manifest.schema.major(), manifest.schema.minor())?;
68        let mut content = BTreeMap::new();
69        for (path, (kind, mode, bytes)) in files {
70            manifest.files.insert(
71                path.clone(),
72                FileRecord {
73                    kind,
74                    mode: mode & 0o777,
75                    sha256: digest(&bytes),
76                },
77            );
78            content.insert(path, bytes);
79        }
80        let bundle = Self { manifest, content };
81        bundle.verify()?;
82        Ok(bundle)
83    }
84
85    #[must_use]
86    pub fn manifest(&self) -> &Manifest {
87        &self.manifest
88    }
89
90    #[must_use]
91    pub fn files(&self) -> &BTreeMap<BundleRelativePath, Vec<u8>> {
92        &self.content
93    }
94
95    #[must_use]
96    pub fn content_hash(&self) -> String {
97        let mut canonical = Vec::new();
98        for (path, content) in &self.content {
99            canonical.extend_from_slice(path.to_string().as_bytes());
100            canonical.push(0);
101            canonical.extend_from_slice(content);
102            canonical.push(0xff);
103        }
104        digest(&canonical)
105    }
106
107    /// Verifies the schema, declared files, and SHA-256 hashes.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when schema or file integrity checks fail.
112    pub fn verify(&self) -> Result<(), BundleError> {
113        SchemaVersion::new(self.manifest.schema.major(), self.manifest.schema.minor())?;
114        for (path, record) in &self.manifest.files {
115            let bytes = self
116                .content
117                .get(path)
118                .ok_or_else(|| BundleError::MissingFile(path.clone()))?;
119            if digest(bytes) != record.sha256 {
120                return Err(BundleError::HashMismatch { path: path.clone() });
121            }
122        }
123        if let Some(path) = self
124            .content
125            .keys()
126            .find(|path| !self.manifest.files.contains_key(*path))
127        {
128            return Err(BundleError::UnexpectedFile(path.clone()));
129        }
130        Ok(())
131    }
132
133    /// Serializes this verified Bundle to deterministic YAML bytes.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error when verification or serialization fails.
138    pub fn to_yaml_bytes(&self) -> Result<Vec<u8>, BundleError> {
139        self.verify()?;
140        let content = self
141            .content
142            .iter()
143            .map(|(path, bytes)| (path.clone(), hex::encode(bytes)))
144            .collect();
145        let wire = BundleWire {
146            manifest: self.manifest.clone(),
147            content,
148        };
149        Ok(serde_yaml::to_string(&wire)?.into_bytes())
150    }
151
152    /// Parses and verifies a Bundle from YAML bytes.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error for malformed YAML, unsupported schemas, invalid hex, or hash mismatch.
157    pub fn from_yaml_bytes(input: &[u8]) -> Result<Self, BundleError> {
158        let wire: BundleWire = serde_yaml::from_slice(input)?;
159        SchemaVersion::new(wire.manifest.schema.major(), wire.manifest.schema.minor())?;
160        let content = wire
161            .content
162            .into_iter()
163            .map(|(path, encoded)| {
164                hex::decode(encoded)
165                    .map(|bytes| (path, bytes))
166                    .map_err(BundleError::InvalidHex)
167            })
168            .collect::<Result<BTreeMap<_, _>, _>>()?;
169        let bundle = Self {
170            manifest: wire.manifest,
171            content,
172        };
173        bundle.verify()?;
174        Ok(bundle)
175    }
176}
177
178#[derive(Debug, Serialize, Deserialize)]
179struct BundleWire {
180    manifest: Manifest,
181    content: BTreeMap<BundleRelativePath, String>,
182}
183
184fn digest(bytes: &[u8]) -> String {
185    hex::encode(Sha256::digest(bytes))
186}
187
188#[derive(Debug, Error)]
189pub enum BundleError {
190    #[error("unsupported bundle schema major: {0}")]
191    UnsupportedMajor(u16),
192    #[error("bundle file is missing: {0}")]
193    MissingFile(BundleRelativePath),
194    #[error("bundle contains undeclared file: {0}")]
195    UnexpectedFile(BundleRelativePath),
196    #[error("bundle hash mismatch: {path}")]
197    HashMismatch { path: BundleRelativePath },
198    #[error("bundle content is not valid hexadecimal")]
199    InvalidHex(#[source] hex::FromHexError),
200    #[error("bundle path is invalid")]
201    Path(#[from] crate::BundlePathError),
202    #[error("bundle YAML is invalid")]
203    Yaml(#[from] serde_yaml::Error),
204    #[error("bundle directory already exists: {0}")]
205    OutputExists(PathBuf),
206    #[error("bundle directory contains an unsafe symbolic link: {0}")]
207    DirectorySymlink(PathBuf),
208    #[error("bundle directory contains an undeclared file: {0}")]
209    UnexpectedDiskFile(PathBuf),
210    #[error("bundle secret reference file does not match the manifest")]
211    SecretReferenceMismatch,
212    #[error("bundle file mode does not match the manifest: {0}")]
213    ModeMismatch(PathBuf),
214    #[error("bundle filesystem operation failed at {path}")]
215    Io {
216        path: PathBuf,
217        #[source]
218        source: std::io::Error,
219    },
220}