返回 CodeWhale
delivery_files.rs
根目录 / crates / tui / src / delivery_files.rs
1 //! Updater-facing I/O over the existing Fleet file fence. No installation store.
2 //!
3 //! Retained handles identify ownership; a digest alone cannot identify a file.
4 //! Moves never replace another entry. Unix cannot atomically compare a directory
5 //! entry and rename it, so callers must recover/preserve the moved entry when
6 //! post-move identity validation refuses it. Windows moves the opened object.
7 use std::fs::File;
8 use std::io::{self, Read, Seek};
9 use std::path::{Path, PathBuf};
10 use std::time::SystemTime;
11
12 use sha2::{Digest, Sha256};
13
14 use crate::fleet::files::{WorkspaceFile, same_file};
15
16 #[derive(Debug, PartialEq, Eq)]
17 struct Version {
18 length: u64,
19 modified: SystemTime,
20 #[cfg(unix)]
21 changed: (i64, i64),
22 }
23
24 fn version(file: &File) -> io::Result<Version> {
25 let metadata = file.metadata()?;
26 if !metadata.is_file() {
27 return Err(invalid("delivery file is not regular"));
28 }
29 #[cfg(unix)]
30 {
31 use std::os::unix::fs::MetadataExt;
32 if metadata.nlink() != 1 {
33 return Err(invalid("delivery file has multiple links"));
34 }
35 }
36 #[cfg(windows)]
37 {
38 let identity = crate::plugins::windows_file_identity(file)?;
39 if identity.links != 1 || identity.attributes & 0x0000_0400 != 0 {
40 return Err(invalid("delivery file is linked or a reparse point"));
41 }
42 }
43 #[cfg(all(not(unix), not(windows)))]
44 return Err(io::ErrorKind::Unsupported.into());
45 Ok(Version {
46 length: metadata.len(),
47 modified: metadata.modified()?,
48 #[cfg(unix)]
49 changed: {
50 use std::os::unix::fs::MetadataExt;
51 (metadata.ctime(), metadata.ctime_nsec())
52 },
53 })
54 }
55
56 fn invalid(message: &'static str) -> io::Error {
57 io::Error::new(io::ErrorKind::InvalidData, message)
58 }
59
60 // A regular file may still grow on Unix after its version was captured. Read
61 // at most the captured length plus one byte; do not wait for a racing writer's EOF.
62 fn hash_exact_length(reader: &mut impl Read, length: u64) -> io::Result<String> {
63 let limit = length
64 .checked_add(1)
65 .ok_or_else(|| invalid("delivery file length cannot be bounded"))?;
66 let mut bounded = reader.take(limit);
67 let mut hash = Sha256::new();
68 let mut buffer = [0; 64 * 1024];
69 let mut total = 0;
70 loop {
71 let count = bounded.read(&mut buffer)?;
72 if count == 0 {
73 break;
74 }
75 total += count as u64;
76 if total > length {
77 return Err(invalid("delivery file grew while reading"));
78 }
79 hash.update(&buffer[..count]);
80 }
81 if total != length {
82 return Err(invalid("delivery file shortened while reading"));
83 }
84 Ok(crate::hashing::hex_bytes(hash.finalize()))
85 }
86
87 fn stable_hash(file: &mut File, maximum: Option<u64>) -> io::Result<(String, Version)> {
88 let before = version(file)?;
89 if maximum.is_some_and(|maximum| before.length > maximum) {
90 return Err(invalid("delivery file exceeds captured size limit"));
91 }
92 file.rewind()?;
93 let digest = hash_exact_length(file, before.length)?;
94 let after = version(file)?;
95 if before != after {
96 return Err(invalid("delivery file changed while reading"));
97 }
98 Ok((digest, after))
99 }
100
101 /// Blocking I/O facade used by the synchronous CLI updater. Async callers
102 /// must run these methods on their existing blocking worker.
103 /// One opened regular, unlinked file and its pinned parent. Drop closes handles;
104 /// it never deletes a pathname another process could have replaced.
105 #[derive(Debug)]
106 pub struct GuardedFile {
107 location: WorkspaceFile,
108 directory: PathBuf,
109 name: String,
110 file: File,
111 digest: String,
112 version: Version,
113 }
114
115 impl GuardedFile {
116 pub fn open(directory: &Path, name: &str) -> io::Result<Option<Self>> {
117 Self::open_with_limit(directory, name, None)
118 }
119
120 pub fn open_bounded(directory: &Path, name: &str, maximum: usize) -> io::Result<Option<Self>> {
121 Self::open_with_limit(directory, name, Some(maximum))
122 }
123
124 fn open_with_limit(
125 directory: &Path,
126 name: &str,
127 maximum: Option<usize>,
128 ) -> io::Result<Option<Self>> {
129 if Path::new(name).components().count() != 1 {
130 return Err(invalid("delivery filename must be one basename"));
131 }
132 #[cfg(windows)]
133 let directory = if directory.is_absolute() {
134 directory.to_path_buf()
135 } else {
136 std::env::current_dir()?.join(directory)
137 };
138 #[cfg(not(windows))]
139 let directory = directory.canonicalize()?;
140 let location = WorkspaceFile::open_delivery(&directory, Path::new(name))?;
141 let mut file = match location.open_retained() {
142 Ok(file) => file,
143 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
144 Err(error) => return Err(error),
145 };
146 let initial = version(&file)?;
147 if maximum.is_some_and(|maximum| initial.length > maximum as u64) {
148 return Err(invalid("delivery receipt exceeds size limit"));
149 }
150 let (digest, version) = stable_hash(&mut file, maximum.map(|maximum| maximum as u64))?;
151 let result = Self {
152 location,
153 directory,
154 name: name.into(),
155 file,
156 digest,
157 version,
158 };
159 if !result.matches_path()? {
160 return Err(invalid("delivery pathname changed while opening"));
161 }
162 Ok(Some(result))
163 }
164
165 pub fn stage(directory: &Path, bytes: &[u8], executable: bool) -> io::Result<Self> {
166 let name = Self::recovery_name();
167 #[cfg(windows)]
168 let directory = if directory.is_absolute() {
169 directory.to_path_buf()
170 } else {
171 std::env::current_dir()?.join(directory)
172 };
173 #[cfg(not(windows))]
174 let directory = directory.canonicalize()?;
175 let location = WorkspaceFile::open_delivery(&directory, Path::new(&name))?;
176 let mut file = location.publish_retained(bytes, executable)?;
177 let (digest, version) = stable_hash(&mut file, Some(bytes.len() as u64))?;
178 let result = Self {
179 location,
180 directory,
181 name,
182 file,
183 digest,
184 version,
185 };
186 if !result.matches_path()? {
187 return Err(invalid("delivery stage changed while opening"));
188 }
189 Ok(result)
190 }
191
192 pub fn recovery_name() -> String {
193 format!(".codewhale-host-recovery-{}", uuid::Uuid::new_v4())
194 }
195
196 pub fn name(&self) -> &str {
197 &self.name
198 }
199 pub fn sha256(&self) -> &str {
200 &self.digest
201 }
202
203 fn matches_path(&self) -> io::Result<bool> {
204 if !same_file(&self.file, &self.location.identity_probe()?)? {
205 return Ok(false);
206 }
207 // An anchored Unix fd survives a renamed parent. Rewalk the original
208 // captured absolute root without canonicalizing new links, so a new
209 // directory at that pathname cannot inherit the installation receipt.
210 let current = WorkspaceFile::open_delivery(&self.directory, Path::new(&self.name))?;
211 same_file(&self.file, &current.identity_probe()?)
212 }
213
214 pub fn verify(&mut self) -> io::Result<()> {
215 if !self.matches_path()? {
216 return Err(invalid("delivery pathname identity changed"));
217 }
218 let (digest, version) = stable_hash(&mut self.file, Some(self.version.length))?;
219 if digest != self.digest || version != self.version {
220 return Err(invalid("delivery file changed since capture"));
221 }
222 if !self.matches_path()? {
223 return Err(invalid("delivery pathname changed while verifying"));
224 }
225 Ok(())
226 }
227
228 /// Reads and validates the same handle used for its digest, without reopening.
229 pub fn read_bounded(&mut self, maximum: usize) -> io::Result<Vec<u8>> {
230 if self.version.length > maximum as u64 {
231 return Err(invalid("delivery receipt exceeds size limit"));
232 }
233 self.verify()?;
234 self.file.rewind()?;
235 let mut bytes = Vec::new();
236 (&mut self.file)
237 .take(self.version.length.saturating_add(1))
238 .read_to_end(&mut bytes)?;
239 if bytes.len() > maximum || crate::hashing::sha256_hex(&bytes) != self.digest {
240 return Err(invalid("delivery receipt changed while reading"));
241 }
242 self.verify()?;
243 Ok(bytes)
244 }
245
246 /// Moves to a vacant sibling. On a post-move refusal `name()` still names
247 /// the recovery entry; callers must retain/report it and recover without
248 /// replacing any concurrent destination.
249 pub fn move_to_vacant(&mut self, name: &str) -> io::Result<()> {
250 self.verify()?;
251 let destination = self.location.sibling(name)?;
252 self.location
253 .move_opened_to_sibling(&self.file, &destination)?;
254 self.location = destination;
255 self.name = name.into();
256 // Journal the completed move before any fallible durability/validation.
257 self.location.sync_parent()?;
258 if !self.matches_path()? {
259 return Err(invalid("retired delivery entry has a different identity"));
260 }
261 let (digest, version) = stable_hash(&mut self.file, Some(self.version.length))?;
262 if digest != self.digest
263 || version.length != self.version.length
264 || version.modified != self.version.modified
265 {
266 return Err(invalid("delivery file changed during retirement"));
267 }
268 // A successful rename may update ctime; it does not change file bytes.
269 self.version = version;
270 self.verify()
271 }
272 }
273
274 #[cfg(test)]
275 mod tests {
276 use super::*;
277
278 #[test]
279 fn hash_budget_refuses_a_growing_reader_without_waiting_for_eof() {
280 struct AppendingReader {
281 read_bytes: u64,
282 }
283 impl Read for AppendingReader {
284 fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
285 bytes.fill(b'x');
286 self.read_bytes += bytes.len() as u64;
287 Ok(bytes.len())
288 }
289 }
290 for length in [0, 5, 64 * 1024] {
291 let mut reader = AppendingReader { read_bytes: 0 };
292 assert!(hash_exact_length(&mut reader, length).is_err());
293 assert_eq!(reader.read_bytes, length + 1);
294 }
295 let mut shortened = &b"short"[..];
296 assert!(hash_exact_length(&mut shortened, 6).is_err());
297 let mut exact = &b"owned"[..];
298 assert_eq!(
299 hash_exact_length(&mut exact, 5).unwrap(),
300 crate::hashing::sha256_hex(b"owned")
301 );
302 }
303
304 #[cfg(unix)]
305 #[test]
306 fn grown_receipt_is_refused_by_captured_and_requested_limits() {
307 let root = tempfile::tempdir().unwrap();
308 let path = root.path().join("receipt");
309 std::fs::write(&path, b"owned").unwrap();
310 let mut captured = GuardedFile::open_bounded(root.path(), "receipt", 5)
311 .unwrap()
312 .unwrap();
313 std::fs::write(&path, vec![b'x'; 64 * 1024 + 1]).unwrap();
314 assert!(captured.verify().is_err());
315 assert!(captured.read_bounded(64 * 1024).is_err());
316 assert!(GuardedFile::open_bounded(root.path(), "receipt", 64 * 1024).is_err());
317 let mut opened = File::open(&path).unwrap();
318 assert!(stable_hash(&mut opened, Some(64 * 1024)).is_err());
319 assert_eq!(std::fs::metadata(&path).unwrap().len(), 64 * 1024 + 1);
320 }
321
322 #[test]
323 fn bounded_receipt_and_no_clobber_move_keep_canonical_ownership() {
324 let root = tempfile::tempdir().unwrap();
325 std::fs::write(root.path().join("receipt"), b"owned").unwrap();
326 assert!(GuardedFile::open_bounded(root.path(), "receipt", 4).is_err());
327 let mut file = GuardedFile::open_bounded(root.path(), "receipt", 5)
328 .unwrap()
329 .unwrap();
330 assert_eq!(file.read_bounded(5).unwrap(), b"owned");
331 std::fs::write(root.path().join("taken"), b"foreign").unwrap();
332 assert!(file.move_to_vacant("taken").is_err());
333 assert_eq!(file.name(), "receipt");
334 assert_eq!(
335 std::fs::read(root.path().join("taken")).unwrap(),
336 b"foreign"
337 );
338 file.move_to_vacant("retired").unwrap();
339 assert_eq!(file.read_bounded(5).unwrap(), b"owned");
340 assert!(!root.path().join("receipt").exists());
341 }
342
343 #[cfg(unix)]
344 #[test]
345 fn nofollow_reader_refuses_symlinks_and_multiple_links() {
346 use std::os::unix::fs::symlink;
347 let root = tempfile::tempdir().unwrap();
348 let outside = tempfile::tempdir().unwrap();
349 let source = outside.path().join("untouched");
350 std::fs::write(&source, b"foreign").unwrap();
351 symlink(&source, root.path().join("linked")).unwrap();
352 std::fs::hard_link(&source, root.path().join("hardlinked")).unwrap();
353 for name in ["linked", "hardlinked"] {
354 assert!(GuardedFile::open(root.path(), name).is_err());
355 assert!(GuardedFile::open_bounded(root.path(), name, 64 * 1024).is_err());
356 }
357 assert_eq!(std::fs::read(source).unwrap(), b"foreign");
358 }
359
360 #[cfg(unix)]
361 #[test]
362 fn captured_receipt_cannot_be_reopened_through_a_swapped_symlink() {
363 use std::os::unix::fs::symlink;
364 let root = tempfile::tempdir().unwrap();
365 let outside = tempfile::tempdir().unwrap();
366 std::fs::write(root.path().join("receipt"), b"owned").unwrap();
367 std::fs::write(outside.path().join("foreign"), b"owned").unwrap();
368 let mut file = GuardedFile::open_bounded(root.path(), "receipt", 64 * 1024)
369 .unwrap()
370 .unwrap();
371 std::fs::rename(root.path().join("receipt"), root.path().join("original")).unwrap();
372 symlink(outside.path().join("foreign"), root.path().join("receipt")).unwrap();
373 assert!(file.read_bounded(64 * 1024).is_err());
374 assert_eq!(
375 std::fs::read(root.path().join("original")).unwrap(),
376 b"owned"
377 );
378 assert_eq!(
379 std::fs::read(outside.path().join("foreign")).unwrap(),
380 b"owned"
381 );
382 }
383
384 #[cfg(unix)]
385 #[test]
386 fn replaced_parent_cannot_inherit_a_retained_payload_receipt() {
387 let root = tempfile::tempdir().unwrap();
388 let prefix = root.path().join("prefix");
389 std::fs::create_dir(&prefix).unwrap();
390 std::fs::write(prefix.join("host"), b"old").unwrap();
391 let mut file = GuardedFile::open(&prefix, "host").unwrap().unwrap();
392 std::fs::rename(&prefix, root.path().join("original-prefix")).unwrap();
393 std::fs::create_dir(&prefix).unwrap();
394 std::fs::write(prefix.join("host"), b"old").unwrap();
395 assert!(file.verify().is_err());
396 assert!(file.move_to_vacant("backup").is_err());
397 assert_eq!(std::fs::read(prefix.join("host")).unwrap(), b"old");
398 assert_eq!(
399 std::fs::read(root.path().join("original-prefix/host")).unwrap(),
400 b"old"
401 );
402 }
403
404 #[cfg(windows)]
405 #[test]
406 fn retained_windows_reader_denies_writer_and_parent_replacement() {
407 let root = tempfile::tempdir().unwrap();
408 let prefix = root.path().join("prefix");
409 std::fs::create_dir(&prefix).unwrap();
410 std::fs::write(prefix.join("host"), b"old").unwrap();
411 let mut file = GuardedFile::open(&prefix, "host").unwrap().unwrap();
412 assert!(std::fs::write(prefix.join("host"), b"changed").is_err());
413 assert!(std::fs::rename(&prefix, root.path().join("swapped")).is_err());
414 file.move_to_vacant("backup").unwrap();
415 assert_eq!(file.read_bounded(3).unwrap(), b"old");
416 }
417 }
418
418 lines RUST