1use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
7
8pub fn validate_source(source: &Path) -> Result<(), String> {
11 let metadata =
12 std::fs::metadata(source).map_err(|error| format!("{}: {error}", source.display()))?;
13 if !metadata.is_file() {
14 return Err(format!(
15 "{}: source is not an ordinary file",
16 source.display()
17 ));
18 }
19 Ok(())
20}
21
22pub fn validate_new_publication(base: &Path) -> Result<(), String> {
27 let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
28 let temporary = TemporaryOutput::new(
29 &base.join(format!(".aede-link-check-{}-{serial}", std::process::id())),
30 )?;
31 std::fs::write(temporary.path(), b"").map_err(|error| error.to_string())?;
32 std::fs::hard_link(temporary.path(), temporary.directory.join("published"))
33 .map_err(|error| new_publication_error(base, error))
34}
35
36fn new_publication_error(destination: &Path, error: std::io::Error) -> String {
37 match error.kind() {
38 std::io::ErrorKind::AlreadyExists => format!(
39 "{}: output appeared before publication; use --replace to refresh it",
40 destination.display()
41 ),
42 _ => format!(
43 "{}: cannot safely publish a new output (filesystem hard links required; --replace explicitly allows rename publication): {error}",
44 destination.display()
45 ),
46 }
47}
48
49pub fn validate_destination(base: &Path, relative: &Path) -> Result<(), String> {
57 if relative.as_os_str().is_empty()
58 || relative
59 .components()
60 .any(|part| !matches!(part, Component::Normal(_)))
61 {
62 return Err(format!(
63 "{}: output must be a relative file path",
64 relative.display()
65 ));
66 }
67 let mut path = base.to_path_buf();
68 let count = relative.components().count();
69 for (index, part) in relative.components().enumerate() {
70 path.push(part.as_os_str());
71 match std::fs::symlink_metadata(&path) {
72 Ok(meta) if meta.file_type().is_symlink() => {
73 return Err(format!(
74 "{}: symbolic links are refused in a copy destination",
75 path.display()
76 ));
77 }
78 Ok(meta) if index + 1 < count && !meta.is_dir() => {
79 return Err(format!("{}: output parent is not a folder", path.display()));
80 }
81 Ok(meta) if index + 1 == count && !meta.is_file() => {
82 return Err(format!(
83 "{}: output is not an ordinary file",
84 path.display()
85 ));
86 }
87 Ok(_) => {}
88 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
89 Err(error) => return Err(format!("{}: {error}", path.display())),
90 }
91 }
92 Ok(())
93}
94
95pub fn validate_filesystem_names(base: &Path, plan: &super::Plan) -> Result<(), String> {
102 use std::collections::BTreeSet;
103 let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
104 let temporary = TemporaryOutput::new(
105 &base.join(format!(".aede-names-check-{}-{serial}", std::process::id())),
106 )?;
107 let tree = temporary.directory.join("tree");
108 std::fs::create_dir(&tree).map_err(|error| error.to_string())?;
109 let mut directories = BTreeSet::new();
110 for item in &plan.items {
111 validate_destination(base, &item.relative)?;
112 let parts: Vec<_> = item.relative.components().collect();
113 let mut relative = PathBuf::new();
114 for component in &parts[..parts.len() - 1] {
115 relative.push(component.as_os_str());
116 if directories.insert(relative.clone()) {
117 std::fs::create_dir(tree.join(&relative)).map_err(|error| format!(
118 "{}: destination aliases a different planned folder or refuses its name: {error}", relative.display()
119 ))?;
120 }
121 }
122 std::fs::OpenOptions::new()
123 .write(true)
124 .create_new(true)
125 .open(tree.join(&item.relative))
126 .map_err(|error| {
127 format!(
128 "{}: destination aliases another planned output or refuses its name: {error}",
129 item.relative.display()
130 )
131 })?;
132 }
133 Ok(())
134}
135
136pub(super) fn regular_destination(destination: &Path) -> Result<Option<std::fs::Metadata>, String> {
137 match std::fs::symlink_metadata(destination) {
138 Ok(meta) if !meta.is_file() || meta.file_type().is_symlink() => Err(format!(
139 "{}: refusing to replace or follow a non-regular destination",
140 destination.display()
141 )),
142 Ok(meta) => Ok(Some(meta)),
143 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
144 Err(error) => Err(format!("{}: {error}", destination.display())),
145 }
146}
147
148pub struct TemporaryOutput {
154 directory: PathBuf,
155 path: PathBuf,
156 destination: PathBuf,
157}
158
159impl TemporaryOutput {
160 pub fn new(destination: &Path) -> Result<Self, String> {
165 regular_destination(destination)?;
166 let parent = destination
167 .parent()
168 .filter(|path| !path.as_os_str().is_empty())
169 .unwrap_or_else(|| Path::new("."));
170 std::fs::create_dir_all(parent)
171 .map_err(|error| format!("{}: {error}", parent.display()))?;
172 let parent = parent
173 .canonicalize()
174 .map_err(|error| format!("{}: {error}", parent.display()))?;
175 let name = destination
176 .file_name()
177 .ok_or_else(|| "output has no file name".to_string())?;
178 let destination = parent.join(name);
179 let clock = std::time::SystemTime::now()
180 .duration_since(std::time::UNIX_EPOCH)
181 .unwrap_or_default()
182 .as_nanos();
183 for _ in 0..100 {
184 let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
185 let directory = parent.join(format!(
186 ".aede-partial-{}-{clock}-{serial}",
187 std::process::id()
188 ));
189 let mut builder = std::fs::DirBuilder::new();
190 #[cfg(unix)]
191 {
192 use std::os::unix::fs::DirBuilderExt;
193 builder.mode(0o700);
194 }
195 match builder.create(&directory) {
196 Ok(()) => {
197 let mut path = directory.join("output");
198 if let Some(extension) = destination.extension() {
199 path.set_extension(extension);
200 }
201 return Ok(Self {
202 directory,
203 path,
204 destination,
205 });
206 }
207 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
208 Err(error) => return Err(format!("{}: {error}", directory.display())),
209 }
210 }
211 Err("could not reserve a unique temporary output".into())
212 }
213
214 pub fn path(&self) -> &Path {
216 &self.path
217 }
218
219 pub fn publish(self) -> Result<(), String> {
223 regular_destination(&self.destination)?;
224 std::fs::rename(&self.path, &self.destination)
225 .map_err(|error| format!("{}: {error}", self.destination.display()))
226 }
227
228 pub fn publish_new(self) -> Result<(), String> {
235 std::fs::hard_link(&self.path, &self.destination)
236 .map_err(|error| new_publication_error(&self.destination, error))?;
237 Ok(())
238 }
239}
240
241impl Drop for TemporaryOutput {
242 fn drop(&mut self) {
243 #[cfg(windows)]
248 let restore = std::fs::File::open(&self.path).ok().and_then(|file| {
249 let permissions = file.metadata().ok()?.permissions();
250 if !permissions.readonly() {
251 return None;
252 }
253 let mut writable = permissions.clone();
254 writable.set_readonly(false);
255 file.set_permissions(writable).ok()?;
256 Some((file, permissions))
257 });
258 let _ = std::fs::remove_dir_all(&self.directory);
259 #[cfg(windows)]
260 if let Some((file, permissions)) = restore {
261 let _ = file.set_permissions(permissions);
262 }
263 }
264}
265
266pub fn files_match(left: &Path, right: &Path) -> Result<bool, String> {
268 use std::io::Read;
269 let mut left = std::fs::File::open(left).map_err(|error| error.to_string())?;
270 let mut right = std::fs::File::open(right).map_err(|error| error.to_string())?;
271 if left.metadata().map_err(|error| error.to_string())?.len()
272 != right.metadata().map_err(|error| error.to_string())?.len()
273 {
274 return Ok(false);
275 }
276 let mut a = vec![0; 1 << 20];
277 let mut b = vec![0; 1 << 20];
278 loop {
279 let n = left.read(&mut a).map_err(|error| error.to_string())?;
280 if n == 0 {
281 return Ok(right.read(&mut b).map_err(|error| error.to_string())? == 0);
282 }
283 right
284 .read_exact(&mut b[..n])
285 .map_err(|error| error.to_string())?;
286 if a[..n] != b[..n] {
287 return Ok(false);
288 }
289 }
290}
291
292#[cfg(test)]
293#[path = "output_tests.rs"]
294mod tests;