Skip to main content

agent_migrate_core/
bundle_directory.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4use std::str::FromStr;
5
6use walkdir::WalkDir;
7
8use crate::{Bundle, BundleError, BundleRelativePath, Manifest, SecretRef};
9
10const MANIFEST: &str = "manifest.yaml";
11const SECRETS: &str = "secrets.ref.yaml";
12
13impl Bundle {
14    /// Writes a verified Bundle as an inspectable directory.
15    ///
16    /// # Errors
17    ///
18    /// Returns an error for an existing output, invalid Bundle, or filesystem failure.
19    pub fn write_directory(&self, output: &Path) -> Result<(), BundleError> {
20        self.verify()?;
21        if output.exists() {
22            return Err(BundleError::OutputExists(output.to_path_buf()));
23        }
24        let parent = usable_parent(output);
25        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
26        let temporary = tempfile::tempdir_in(parent).map_err(|source| io_error(parent, source))?;
27        write_file(
28            &temporary.path().join(MANIFEST),
29            serde_yaml::to_string(self.manifest())?.as_bytes(),
30            0o600,
31        )?;
32        write_file(
33            &temporary.path().join(SECRETS),
34            serde_yaml::to_string(&self.manifest().secret_references)?.as_bytes(),
35            0o600,
36        )?;
37        for (path, content) in self.files() {
38            let record = self
39                .manifest()
40                .files
41                .get(path)
42                .ok_or_else(|| BundleError::MissingFile(path.clone()))?;
43            write_file(&temporary.path().join(path.as_path()), content, record.mode)?;
44        }
45        let staged = temporary.keep();
46        fs::rename(&staged, output).map_err(|source| io_error(output, source))
47    }
48
49    /// Loads and verifies an inspectable Bundle directory.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error for unsafe paths, undeclared files, invalid metadata, or hash mismatch.
54    pub fn from_directory(input: &Path) -> Result<Self, BundleError> {
55        reject_root_symlink(input)?;
56        let manifest: Manifest = serde_yaml::from_slice(&read(&input.join(MANIFEST))?)?;
57        let references: Vec<SecretRef> = serde_yaml::from_slice(&read(&input.join(SECRETS))?)?;
58        if references != manifest.secret_references {
59            return Err(BundleError::SecretReferenceMismatch);
60        }
61        let mut content = BTreeMap::new();
62        for (path, record) in &manifest.files {
63            let disk_path = input.join(path.as_path());
64            reject_components(input, path.as_path())?;
65            verify_mode(&disk_path, record.mode)?;
66            content.insert(path.clone(), read(&disk_path)?);
67        }
68        reject_undeclared(input, &manifest)?;
69        Self::from_parts(manifest, content)
70    }
71}
72
73fn write_file(path: &Path, content: &[u8], mode: u32) -> Result<(), BundleError> {
74    let parent = path
75        .parent()
76        .ok_or_else(|| io_error(path, std::io::Error::other("missing parent")))?;
77    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
78    fs::write(path, content).map_err(|source| io_error(path, source))?;
79    set_mode(path, mode)
80}
81
82fn read(path: &Path) -> Result<Vec<u8>, BundleError> {
83    fs::read(path).map_err(|source| io_error(path, source))
84}
85
86fn reject_root_symlink(input: &Path) -> Result<(), BundleError> {
87    let metadata = fs::symlink_metadata(input).map_err(|source| io_error(input, source))?;
88    if metadata.file_type().is_symlink() {
89        return Err(BundleError::DirectorySymlink(input.to_path_buf()));
90    }
91    Ok(())
92}
93
94fn reject_components(root: &Path, relative: &Path) -> Result<(), BundleError> {
95    let mut current = root.to_path_buf();
96    for component in relative.components() {
97        current.push(component);
98        let metadata =
99            fs::symlink_metadata(&current).map_err(|source| io_error(&current, source))?;
100        if metadata.file_type().is_symlink() {
101            return Err(BundleError::DirectorySymlink(current));
102        }
103    }
104    Ok(())
105}
106
107fn reject_undeclared(root: &Path, manifest: &Manifest) -> Result<(), BundleError> {
108    for item in WalkDir::new(root).follow_links(false).min_depth(1) {
109        let entry = item.map_err(|error| {
110            let path = error
111                .path()
112                .map_or_else(|| root.to_path_buf(), Path::to_path_buf);
113            io_error(&path, std::io::Error::other(error.to_string()))
114        })?;
115        if entry.file_type().is_symlink() {
116            return Err(BundleError::DirectorySymlink(entry.path().to_path_buf()));
117        }
118        if !entry.file_type().is_file() {
119            continue;
120        }
121        let relative = entry
122            .path()
123            .strip_prefix(root)
124            .map_err(|_| BundleError::UnexpectedDiskFile(entry.path().to_path_buf()))?;
125        if matches!(relative.to_str(), Some(MANIFEST | SECRETS)) {
126            continue;
127        }
128        let relative = BundleRelativePath::from_str(
129            relative
130                .to_str()
131                .ok_or_else(|| BundleError::UnexpectedDiskFile(relative.to_path_buf()))?,
132        )?;
133        if !manifest.files.contains_key(&relative) {
134            return Err(BundleError::UnexpectedDiskFile(entry.path().to_path_buf()));
135        }
136    }
137    Ok(())
138}
139
140fn usable_parent(path: &Path) -> &Path {
141    path.parent()
142        .filter(|parent| !parent.as_os_str().is_empty())
143        .unwrap_or_else(|| Path::new("."))
144}
145
146fn io_error(path: &Path, source: std::io::Error) -> BundleError {
147    BundleError::Io {
148        path: path.to_path_buf(),
149        source,
150    }
151}
152
153#[cfg(unix)]
154fn set_mode(path: &Path, mode: u32) -> Result<(), BundleError> {
155    use std::os::unix::fs::PermissionsExt;
156
157    fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))
158        .map_err(|source| io_error(path, source))
159}
160
161#[cfg(not(unix))]
162fn set_mode(_path: &Path, _mode: u32) -> Result<(), BundleError> {
163    Ok(())
164}
165
166#[cfg(unix)]
167fn verify_mode(path: &Path, expected: u32) -> Result<(), BundleError> {
168    use std::os::unix::fs::PermissionsExt;
169
170    let actual = fs::metadata(path)
171        .map_err(|source| io_error(path, source))?
172        .permissions()
173        .mode()
174        & 0o777;
175    if actual != expected & 0o777 {
176        return Err(BundleError::ModeMismatch(path.to_path_buf()));
177    }
178    Ok(())
179}
180
181#[cfg(not(unix))]
182fn verify_mode(_path: &Path, _expected: u32) -> Result<(), BundleError> {
183    Ok(())
184}