agent_migrate_core/
bundle_directory.rs1use 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 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 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(¤t).map_err(|source| io_error(¤t, 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}