| 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, ¤t.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 |