safe_artifact_io.rs (132112B)
1 //! Descriptor-bound, resource-bounded input handling for release tooling. 2 3 use std::collections::BTreeMap; 4 use std::ffi::{OsStr, OsString}; 5 use std::fmt; 6 use std::fs::{self, File}; 7 use std::io::{self, BufRead as _, BufReader, Read, Write}; 8 use std::path::{Component, Path, PathBuf}; 9 10 use flate2::{Compression, GzBuilder, bufread::GzDecoder}; 11 use sha2::{Digest as _, Sha256}; 12 use tar::{Builder as TarBuilder, Header as TarHeader}; 13 14 const STREAM_BUFFER_BYTES: usize = 64 * 1024; 15 const HARD_MAX_BUFFERED_READ_BYTES: u64 = 67_108_864; 16 const HARD_MAX_STREAM_FILE_BYTES: u64 = 17_179_869_184; 17 const HARD_MAX_TRAVERSAL_ENTRIES: u64 = 65_536; 18 const HARD_MAX_TRAVERSAL_FILES: u64 = 65_536; 19 const HARD_MAX_TRAVERSAL_TOTAL_BYTES: u64 = 68_719_476_736; 20 const HARD_MAX_TRAVERSAL_DEPTH: usize = 64; 21 const HARD_MAX_PATH_BYTES: usize = 4_096; 22 const HARD_MAX_ARCHIVE_COMPRESSED_BYTES: u64 = 2_147_483_648; 23 const HARD_MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184; 24 const HARD_MAX_ARCHIVE_MEMBERS: u64 = 65_536; 25 const HARD_MAX_ARCHIVE_MEMBER_BYTES: u64 = 17_179_869_184; 26 const HARD_MAX_ARCHIVE_PAYLOAD_BYTES: u64 = 17_179_869_184; 27 const TAR_BLOCK_BYTES: usize = 512; 28 const TAR_TERMINATOR_BYTES: usize = TAR_BLOCK_BYTES * 2; 29 const CANONICAL_GZIP_COMPRESSION_LEVEL: u32 = 6; 30 31 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 32 pub(crate) enum LimitKind { 33 ArchiveCompressedBytes, 34 ArchiveDepth, 35 ArchiveExpandedBytes, 36 ArchiveMemberBytes, 37 ArchiveMembers, 38 ArchivePathBytes, 39 ArchivePayloadBytes, 40 FileBytes, 41 TraversalDepth, 42 TraversalEntries, 43 TraversalFiles, 44 TraversalPathBytes, 45 TraversalTotalBytes, 46 } 47 48 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 49 pub(crate) enum ArtifactIoFailureKind { 50 ChangedDuringRead, 51 InvalidObject, 52 InvalidRequest, 53 IoFailure, 54 LimitExceeded(LimitKind), 55 MalformedArchive, 56 #[cfg_attr(unix, allow(dead_code))] 57 UnsupportedPlatform, 58 } 59 60 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 61 pub(crate) struct ArtifactIoError { 62 kind: ArtifactIoFailureKind, 63 } 64 65 impl ArtifactIoError { 66 const fn new(kind: ArtifactIoFailureKind) -> Self { 67 Self { kind } 68 } 69 70 pub(crate) const fn kind(self) -> ArtifactIoFailureKind { 71 self.kind 72 } 73 } 74 75 impl fmt::Display for ArtifactIoError { 76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 77 formatter.write_str(match self.kind { 78 ArtifactIoFailureKind::ChangedDuringRead => "artifact binding changed during admission", 79 ArtifactIoFailureKind::InvalidObject => "artifact object type is not admitted", 80 ArtifactIoFailureKind::InvalidRequest => "artifact I/O request is invalid", 81 ArtifactIoFailureKind::IoFailure => "artifact I/O operation failed", 82 ArtifactIoFailureKind::LimitExceeded(_) => "artifact I/O limit was exceeded", 83 ArtifactIoFailureKind::MalformedArchive => "artifact archive is malformed", 84 ArtifactIoFailureKind::UnsupportedPlatform => { 85 "safe artifact I/O is unsupported on this platform" 86 } 87 }) 88 } 89 } 90 91 impl std::error::Error for ArtifactIoError {} 92 93 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 94 pub(crate) struct TraversalLimits { 95 pub(crate) max_entries: u64, 96 pub(crate) max_files: u64, 97 pub(crate) max_total_bytes: u64, 98 pub(crate) max_file_bytes: u64, 99 pub(crate) max_depth: usize, 100 pub(crate) max_path_bytes: usize, 101 } 102 103 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 104 pub(crate) struct TarGzipLimits { 105 pub(crate) max_compressed_bytes: u64, 106 pub(crate) max_expanded_bytes: u64, 107 pub(crate) max_members: u64, 108 pub(crate) max_member_bytes: u64, 109 pub(crate) max_payload_bytes: u64, 110 pub(crate) max_depth: usize, 111 pub(crate) max_path_bytes: usize, 112 } 113 114 #[derive(Clone, Debug, Eq, PartialEq)] 115 pub(crate) struct FileEvidence { 116 pub(crate) byte_length: u64, 117 pub(crate) sha256: String, 118 } 119 120 #[derive(Clone, Debug, Eq, PartialEq)] 121 pub(crate) struct ArchiveEvidence { 122 pub(crate) compressed: FileEvidence, 123 pub(crate) expanded_bytes: u64, 124 pub(crate) member_count: u64, 125 pub(crate) payload_bytes: u64, 126 } 127 128 #[derive(Debug)] 129 pub(crate) struct MaterializedArchive { 130 root_descriptor: File, 131 root_identity: OutputDirectoryIdentity, 132 root_chain: Vec<DirectoryIdentity>, 133 parent_descriptor: File, 134 parent_identity: OutputDirectoryIdentity, 135 parent_path: PathBuf, 136 parent_chain: Vec<OutputDirectoryIdentity>, 137 snapshot: TraversalSnapshot, 138 evidence: ArchiveEvidence, 139 directory: tempfile::TempDir, 140 } 141 142 impl MaterializedArchive { 143 pub(crate) fn root(&self) -> &Path { 144 self.directory.path() 145 } 146 147 pub(crate) fn evidence(&self) -> &ArchiveEvidence { 148 &self.evidence 149 } 150 151 pub(crate) fn snapshot(&self) -> &TraversalSnapshot { 152 &self.snapshot 153 } 154 155 pub(crate) fn revalidate(&self) -> Result<(), ArtifactIoError> { 156 let changed = || ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead); 157 if output_directory_identity(identity(&self.parent_descriptor).map_err(|_| changed())?) 158 != self.parent_identity 159 { 160 return Err(ArtifactIoError::new( 161 ArtifactIoFailureKind::ChangedDuringRead, 162 )); 163 } 164 if output_directory_identity(identity(&self.root_descriptor).map_err(|_| changed())?) 165 != self.root_identity 166 { 167 return Err(ArtifactIoError::new( 168 ArtifactIoFailureKind::ChangedDuringRead, 169 )); 170 } 171 let (current_root, current_root_chain) = 172 open_absolute_directory(self.root()).map_err(|_| changed())?; 173 if output_directory_identity(identity(¤t_root).map_err(|_| changed())?) 174 != self.root_identity 175 || current_root_chain != self.root_chain 176 { 177 return Err(ArtifactIoError::new( 178 ArtifactIoFailureKind::ChangedDuringRead, 179 )); 180 } 181 let (_, current_parent_chain) = 182 open_trusted_output_directory(&self.parent_path).map_err(|_| changed())?; 183 if current_parent_chain != self.parent_chain { 184 return Err(ArtifactIoError::new( 185 ArtifactIoFailureKind::ChangedDuringRead, 186 )); 187 } 188 self.snapshot.revalidate().map_err(|_| changed()) 189 } 190 } 191 192 #[derive(Clone, Debug)] 193 pub(crate) struct TraversedFile { 194 relative: PathBuf, 195 identity: FileIdentity, 196 } 197 198 impl TraversedFile { 199 pub(crate) fn relative_path(&self) -> &Path { 200 &self.relative 201 } 202 203 pub(crate) const fn permission_mode(&self) -> u32 { 204 permission_mode(self.identity) 205 } 206 } 207 208 #[derive(Debug)] 209 pub(crate) struct TraversalSnapshot { 210 root: PathBuf, 211 root_chain: Vec<DirectoryIdentity>, 212 directories: Vec<TraversedDirectory>, 213 files: Vec<TraversedFile>, 214 entry_count: u64, 215 total_bytes: u64, 216 } 217 218 impl TraversalSnapshot { 219 pub(crate) fn files(&self) -> &[TraversedFile] { 220 &self.files 221 } 222 223 pub(crate) const fn entry_count(&self) -> u64 { 224 self.entry_count 225 } 226 227 pub(crate) const fn total_bytes(&self) -> u64 { 228 self.total_bytes 229 } 230 231 pub(crate) fn directories(&self) -> impl Iterator<Item = (&Path, u32)> { 232 self.directories 233 .iter() 234 .map(|directory| (directory.relative.as_path(), directory.permission_mode)) 235 } 236 237 pub(crate) fn root_permission_mode(&self) -> u32 { 238 self.directories 239 .iter() 240 .find(|directory| directory.relative.as_os_str().is_empty()) 241 .map_or(0, |directory| directory.permission_mode) 242 } 243 244 pub(crate) fn read( 245 &self, 246 file: &TraversedFile, 247 maximum: u64, 248 ) -> Result<Vec<u8>, ArtifactIoError> { 249 read_regular_impl( 250 &self.root, 251 &file.relative, 252 maximum, 253 Some(&file.identity), 254 || {}, 255 ) 256 } 257 258 pub(crate) fn read_evidenced( 259 &self, 260 file: &TraversedFile, 261 maximum: u64, 262 ) -> Result<(Vec<u8>, FileEvidence), ArtifactIoError> { 263 read_regular_evidenced_impl( 264 &self.root, 265 &file.relative, 266 maximum, 267 Some(&file.identity), 268 || {}, 269 ) 270 } 271 272 pub(crate) fn hash( 273 &self, 274 file: &TraversedFile, 275 maximum: u64, 276 ) -> Result<FileEvidence, ArtifactIoError> { 277 with_regular( 278 &self.root, 279 &file.relative, 280 maximum, 281 Some(&file.identity), 282 || {}, 283 |input, admitted_length| { 284 let evidence = stream_regular(input, maximum, None)?; 285 require_observed_length(evidence.byte_length, admitted_length)?; 286 Ok(evidence) 287 }, 288 ) 289 } 290 291 pub(crate) fn admit_deterministic_tar_gzip( 292 &self, 293 file: &TraversedFile, 294 limits: TarGzipLimits, 295 ) -> Result<ArchiveEvidence, ArtifactIoError> { 296 admit_tar_gzip_relative( 297 &self.root, 298 &file.relative, 299 limits, 300 Some(&file.identity), 301 TarGzipPolicy::DeterministicSnapshot, 302 ) 303 } 304 305 pub(crate) fn materialize_deterministic_tar_gzip( 306 &self, 307 file: &TraversedFile, 308 trusted_parent: &Path, 309 limits: TarGzipLimits, 310 ) -> Result<MaterializedArchive, ArtifactIoError> { 311 materialize_tar_gzip_relative( 312 &self.root, 313 &file.relative, 314 trusted_parent, 315 limits, 316 Some(&file.identity), 317 TarGzipPolicy::DeterministicSnapshot, 318 ) 319 } 320 321 pub(crate) fn copy_to_new_path( 322 &self, 323 file: &TraversedFile, 324 output: &Path, 325 maximum: u64, 326 ) -> Result<FileEvidence, ArtifactIoError> { 327 copy_regular_to_new_path_impl( 328 &self.root.join(&file.relative), 329 output, 330 maximum, 331 Some(&file.identity), 332 || {}, 333 || {}, 334 ) 335 } 336 337 pub(crate) fn revalidate(&self) -> Result<(), ArtifactIoError> { 338 let (_, root_chain) = open_absolute_directory(&self.root)?; 339 if root_chain != self.root_chain { 340 return Err(ArtifactIoError::new( 341 ArtifactIoFailureKind::ChangedDuringRead, 342 )); 343 } 344 for directory in &self.directories { 345 let opened = if directory.relative.as_os_str().is_empty() { 346 let (file, chain) = open_absolute_directory(&self.root)?; 347 OpenedObject { 348 identity: identity(&file)?, 349 file, 350 chain, 351 } 352 } else { 353 open_relative(&self.root, &directory.relative, ObjectKind::Directory)? 354 }; 355 if directory_identity(opened.identity) != directory.identity 356 || permission_mode(opened.identity) != directory.permission_mode 357 { 358 return Err(ArtifactIoError::new( 359 ArtifactIoFailureKind::ChangedDuringRead, 360 )); 361 } 362 revalidate_directory_members(&opened.file, &directory.members)?; 363 } 364 for file in &self.files { 365 let opened = open_relative(&self.root, &file.relative, ObjectKind::Regular)?; 366 if opened.identity != file.identity { 367 return Err(ArtifactIoError::new( 368 ArtifactIoFailureKind::ChangedDuringRead, 369 )); 370 } 371 } 372 Ok(()) 373 } 374 } 375 376 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 377 struct FileIdentity { 378 device: u64, 379 inode: u64, 380 mode: u32, 381 owner: u32, 382 links: u64, 383 length: u64, 384 modified_seconds: i64, 385 modified_nanoseconds: i64, 386 changed_seconds: i64, 387 changed_nanoseconds: i64, 388 } 389 390 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 391 struct DirectoryIdentity { 392 device: u64, 393 inode: u64, 394 } 395 396 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 397 struct OutputDirectoryIdentity { 398 directory: DirectoryIdentity, 399 mode: u32, 400 owner: u32, 401 } 402 403 #[derive(Clone, Debug, Eq, PartialEq)] 404 struct TraversedDirectory { 405 relative: PathBuf, 406 identity: DirectoryIdentity, 407 permission_mode: u32, 408 members: Vec<DirectoryMember>, 409 } 410 411 #[derive(Clone, Debug, Eq, PartialEq)] 412 struct DirectoryMember { 413 name: OsString, 414 identity: MemberIdentity, 415 } 416 417 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 418 enum MemberIdentity { 419 Directory(DirectoryIdentity), 420 Regular(FileIdentity), 421 } 422 423 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 424 enum ObjectKind { 425 Directory, 426 Regular, 427 } 428 429 struct OpenedObject { 430 file: File, 431 identity: FileIdentity, 432 chain: Vec<DirectoryIdentity>, 433 } 434 435 pub(crate) fn read_regular_path(path: &Path, maximum: u64) -> Result<Vec<u8>, ArtifactIoError> { 436 let (root, relative) = split_absolute_file(path)?; 437 read_regular(&root, &relative, maximum) 438 } 439 440 pub(crate) fn read_regular( 441 root: &Path, 442 relative: &Path, 443 maximum: u64, 444 ) -> Result<Vec<u8>, ArtifactIoError> { 445 read_regular_impl(root, relative, maximum, None, || {}) 446 } 447 448 fn read_regular_impl<F>( 449 root: &Path, 450 relative: &Path, 451 maximum: u64, 452 expected: Option<&FileIdentity>, 453 after_open: F, 454 ) -> Result<Vec<u8>, ArtifactIoError> 455 where 456 F: FnOnce(), 457 { 458 read_regular_evidenced_impl(root, relative, maximum, expected, after_open) 459 .map(|(bytes, _)| bytes) 460 } 461 462 fn read_regular_evidenced_impl<F>( 463 root: &Path, 464 relative: &Path, 465 maximum: u64, 466 expected: Option<&FileIdentity>, 467 after_open: F, 468 ) -> Result<(Vec<u8>, FileEvidence), ArtifactIoError> 469 where 470 F: FnOnce(), 471 { 472 if maximum == 0 || maximum > HARD_MAX_BUFFERED_READ_BYTES { 473 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 474 } 475 with_regular( 476 root, 477 relative, 478 maximum, 479 expected, 480 after_open, 481 |file, admitted_length| { 482 let initial_capacity = usize::try_from(maximum.min(STREAM_BUFFER_BYTES as u64)) 483 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 484 let mut bytes = Vec::with_capacity(initial_capacity); 485 let mut hasher = Sha256::new(); 486 let mut buffer = [0_u8; STREAM_BUFFER_BYTES]; 487 loop { 488 let read = file 489 .read(&mut buffer) 490 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 491 if read == 0 { 492 break; 493 } 494 let next = (bytes.len() as u64) 495 .checked_add(read as u64) 496 .ok_or_else(|| { 497 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 498 LimitKind::FileBytes, 499 )) 500 })?; 501 if next > maximum { 502 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 503 LimitKind::FileBytes, 504 ))); 505 } 506 hasher.update(&buffer[..read]); 507 bytes.extend_from_slice(&buffer[..read]); 508 } 509 let byte_length = bytes.len() as u64; 510 require_observed_length(byte_length, admitted_length)?; 511 Ok(( 512 bytes, 513 FileEvidence { 514 byte_length, 515 sha256: hex::encode(hasher.finalize()), 516 }, 517 )) 518 }, 519 ) 520 } 521 522 pub(crate) fn hash_regular_path( 523 path: &Path, 524 maximum: u64, 525 ) -> Result<FileEvidence, ArtifactIoError> { 526 let (root, relative) = split_absolute_file(path)?; 527 hash_regular(&root, &relative, maximum) 528 } 529 530 pub(crate) fn hash_regular( 531 root: &Path, 532 relative: &Path, 533 maximum: u64, 534 ) -> Result<FileEvidence, ArtifactIoError> { 535 with_regular( 536 root, 537 relative, 538 maximum, 539 None, 540 || {}, 541 |file, admitted_length| { 542 let evidence = stream_regular(file, maximum, None)?; 543 require_observed_length(evidence.byte_length, admitted_length)?; 544 Ok(evidence) 545 }, 546 ) 547 } 548 549 pub(crate) fn copy_regular_to_new_path( 550 source: &Path, 551 output: &Path, 552 maximum: u64, 553 ) -> Result<FileEvidence, ArtifactIoError> { 554 copy_regular_to_new_path_impl(source, output, maximum, None, || {}, || {}) 555 } 556 557 fn copy_regular_to_new_path_impl<H, J>( 558 source: &Path, 559 output: &Path, 560 maximum: u64, 561 expected: Option<&FileIdentity>, 562 after_open: H, 563 after_install: J, 564 ) -> Result<FileEvidence, ArtifactIoError> 565 where 566 H: FnOnce(), 567 J: FnOnce(), 568 { 569 if !output.is_absolute() || output.file_name().is_none() { 570 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 571 } 572 let output_parent = output 573 .parent() 574 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 575 let (root, relative) = split_absolute_file(source)?; 576 copy_regular_descriptor_relative( 577 &root, 578 &relative, 579 output_parent, 580 output 581 .file_name() 582 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?, 583 maximum, 584 expected, 585 after_open, 586 after_install, 587 ) 588 } 589 590 #[cfg(unix)] 591 #[allow(clippy::too_many_arguments)] 592 fn copy_regular_descriptor_relative<H, J>( 593 input_root: &Path, 594 input_relative: &Path, 595 output_parent: &Path, 596 output_name: &OsStr, 597 maximum: u64, 598 expected: Option<&FileIdentity>, 599 after_open: H, 600 after_install: J, 601 ) -> Result<FileEvidence, ArtifactIoError> 602 where 603 H: FnOnce(), 604 J: FnOnce(), 605 { 606 use rustix::fs::{Mode, OFlags, fchmod, openat}; 607 608 validate_single_component(output_name)?; 609 let (parent, parent_chain) = open_trusted_output_directory(output_parent)?; 610 let parent_identity = *parent_chain 611 .last() 612 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 613 let output = openat( 614 &parent, 615 output_name, 616 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW, 617 Mode::RUSR | Mode::WUSR, 618 ) 619 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 620 let mut output = File::from(output); 621 fchmod(&output, Mode::RUSR | Mode::WUSR) 622 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 623 let initial_output = identity(&output)?; 624 if initial_output.links != 1 || permission_mode(initial_output) != 0o600 { 625 return Err(ArtifactIoError::new( 626 ArtifactIoFailureKind::ChangedDuringRead, 627 )); 628 } 629 630 let evidence = with_regular( 631 input_root, 632 input_relative, 633 maximum, 634 expected, 635 after_open, 636 |input, admitted_length| { 637 let evidence = stream_regular(input, maximum, Some(&mut output))?; 638 require_observed_length(evidence.byte_length, admitted_length)?; 639 output 640 .sync_all() 641 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 642 Ok(evidence) 643 }, 644 )?; 645 let finalized_identity = identity(&output)?; 646 if finalized_identity.links != 1 647 || finalized_identity.length != evidence.byte_length 648 || permission_mode(finalized_identity) != 0o600 649 { 650 return Err(ArtifactIoError::new( 651 ArtifactIoFailureKind::ChangedDuringRead, 652 )); 653 } 654 if parent.sync_all().is_err() { 655 return Err(ArtifactIoError::new(ArtifactIoFailureKind::IoFailure)); 656 } 657 after_install(); 658 let installed_identity = identity(&output)?; 659 if installed_identity != finalized_identity { 660 return Err(ArtifactIoError::new( 661 ArtifactIoFailureKind::ChangedDuringRead, 662 )); 663 } 664 let rebound = open_at(&parent, output_name); 665 if rebound.as_ref().ok().and_then(|file| identity(file).ok()) != Some(finalized_identity) { 666 return Err(ArtifactIoError::new( 667 ArtifactIoFailureKind::ChangedDuringRead, 668 )); 669 } 670 let retained_parent_identity = output_directory_identity(identity(&parent)?); 671 let current_parent_chain = open_trusted_output_directory(output_parent) 672 .map(|(_, chain)| chain) 673 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead))?; 674 if retained_parent_identity != parent_identity || current_parent_chain != parent_chain { 675 return Err(ArtifactIoError::new( 676 ArtifactIoFailureKind::ChangedDuringRead, 677 )); 678 } 679 Ok(evidence) 680 } 681 682 #[cfg(not(unix))] 683 #[allow(clippy::too_many_arguments)] 684 fn copy_regular_descriptor_relative<H, J>( 685 _input_root: &Path, 686 _input_relative: &Path, 687 _output_parent: &Path, 688 _output_name: &OsStr, 689 _maximum: u64, 690 _expected: Option<&FileIdentity>, 691 _after_open: H, 692 _after_install: J, 693 ) -> Result<FileEvidence, ArtifactIoError> 694 where 695 H: FnOnce(), 696 J: FnOnce(), 697 { 698 Err(ArtifactIoError::new( 699 ArtifactIoFailureKind::UnsupportedPlatform, 700 )) 701 } 702 703 fn require_observed_length(observed: u64, admitted: u64) -> Result<(), ArtifactIoError> { 704 if observed == admitted { 705 Ok(()) 706 } else { 707 Err(ArtifactIoError::new( 708 ArtifactIoFailureKind::ChangedDuringRead, 709 )) 710 } 711 } 712 713 const fn output_directory_identity(identity: FileIdentity) -> OutputDirectoryIdentity { 714 OutputDirectoryIdentity { 715 directory: directory_identity(identity), 716 mode: permission_mode(identity), 717 owner: identity.owner, 718 } 719 } 720 721 #[cfg(unix)] 722 fn output_directory_is_trusted(identity: OutputDirectoryIdentity) -> bool { 723 (identity.owner == 0 || identity.owner == rustix::process::geteuid().as_raw()) 724 && identity.mode & 0o022 == 0 725 } 726 727 fn stream_regular( 728 file: &mut File, 729 maximum: u64, 730 mut output: Option<&mut File>, 731 ) -> Result<FileEvidence, ArtifactIoError> { 732 let mut hasher = Sha256::new(); 733 let mut total = 0_u64; 734 let mut buffer = [0_u8; STREAM_BUFFER_BYTES]; 735 loop { 736 let read = file 737 .read(&mut buffer) 738 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 739 if read == 0 { 740 break; 741 } 742 total = total 743 .checked_add(read as u64) 744 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 745 if total > maximum { 746 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 747 LimitKind::FileBytes, 748 ))); 749 } 750 hasher.update(&buffer[..read]); 751 if let Some(destination) = output.as_deref_mut() { 752 destination 753 .write_all(&buffer[..read]) 754 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 755 } 756 } 757 Ok(FileEvidence { 758 byte_length: total, 759 sha256: hex::encode(hasher.finalize()), 760 }) 761 } 762 763 fn with_regular<T, F, H>( 764 root: &Path, 765 relative: &Path, 766 maximum: u64, 767 expected: Option<&FileIdentity>, 768 after_open: H, 769 operation: F, 770 ) -> Result<T, ArtifactIoError> 771 where 772 F: FnOnce(&mut File, u64) -> Result<T, ArtifactIoError>, 773 H: FnOnce(), 774 { 775 if maximum == 0 || maximum > HARD_MAX_STREAM_FILE_BYTES { 776 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 777 } 778 let mut opened = open_relative(root, relative, ObjectKind::Regular)?; 779 if opened.identity.length > maximum { 780 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 781 LimitKind::FileBytes, 782 ))); 783 } 784 if opened.identity.links != 1 { 785 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 786 } 787 if expected.is_some_and(|identity| *identity != opened.identity) { 788 return Err(ArtifactIoError::new( 789 ArtifactIoFailureKind::ChangedDuringRead, 790 )); 791 } 792 let before = opened.identity; 793 let chain = opened.chain.clone(); 794 after_open(); 795 let result = operation(&mut opened.file, before.length)?; 796 let after = identity(&opened.file)?; 797 let rebound = open_relative(root, relative, ObjectKind::Regular)?; 798 if after != before || rebound.identity != before || rebound.chain != chain { 799 return Err(ArtifactIoError::new( 800 ArtifactIoFailureKind::ChangedDuringRead, 801 )); 802 } 803 Ok(result) 804 } 805 806 pub(crate) fn traverse_regular_files( 807 root: &Path, 808 limits: TraversalLimits, 809 excluded_directory_names: &[&str], 810 ) -> Result<TraversalSnapshot, ArtifactIoError> { 811 traverse_regular_files_impl(root, limits, excluded_directory_names, || {}) 812 } 813 814 fn traverse_regular_files_impl<F>( 815 root: &Path, 816 limits: TraversalLimits, 817 excluded_directory_names: &[&str], 818 after_walk: F, 819 ) -> Result<TraversalSnapshot, ArtifactIoError> 820 where 821 F: FnOnce(), 822 { 823 validate_traversal_limits(limits)?; 824 if excluded_directory_names.iter().any(|name| { 825 name.is_empty() 826 || matches!(*name, "." | "..") 827 || name.as_bytes().contains(&b'/') 828 || name.as_bytes().contains(&b'\\') 829 || name.as_bytes().contains(&0) 830 }) { 831 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 832 } 833 let (root_file, root_chain) = open_absolute_directory(root)?; 834 let mut state = TraversalState { 835 limits, 836 exclusions: excluded_directory_names, 837 entries: 0, 838 files: Vec::new(), 839 directories: Vec::new(), 840 total_bytes: 0, 841 }; 842 let root_identity = identity(&root_file)?; 843 walk_directory(&root_file, Path::new(""), 0, root_identity, &mut state)?; 844 after_walk(); 845 let snapshot = TraversalSnapshot { 846 root: root.to_path_buf(), 847 root_chain, 848 directories: state.directories, 849 files: state.files, 850 entry_count: state.entries, 851 total_bytes: state.total_bytes, 852 }; 853 snapshot.revalidate()?; 854 Ok(snapshot) 855 } 856 857 struct TraversalState<'a> { 858 limits: TraversalLimits, 859 exclusions: &'a [&'a str], 860 entries: u64, 861 files: Vec<TraversedFile>, 862 directories: Vec<TraversedDirectory>, 863 total_bytes: u64, 864 } 865 866 fn validate_traversal_limits(limits: TraversalLimits) -> Result<(), ArtifactIoError> { 867 if limits.max_entries == 0 868 || limits.max_entries > HARD_MAX_TRAVERSAL_ENTRIES 869 || limits.max_files == 0 870 || limits.max_files > HARD_MAX_TRAVERSAL_FILES 871 || limits.max_total_bytes == 0 872 || limits.max_total_bytes > HARD_MAX_TRAVERSAL_TOTAL_BYTES 873 || limits.max_file_bytes == 0 874 || limits.max_file_bytes > HARD_MAX_STREAM_FILE_BYTES 875 || limits.max_depth == 0 876 || limits.max_depth > HARD_MAX_TRAVERSAL_DEPTH 877 || limits.max_path_bytes == 0 878 || limits.max_path_bytes > HARD_MAX_PATH_BYTES 879 { 880 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)) 881 } else { 882 Ok(()) 883 } 884 } 885 886 #[cfg(unix)] 887 fn walk_directory( 888 directory: &File, 889 relative: &Path, 890 depth: usize, 891 current_identity: FileIdentity, 892 state: &mut TraversalState<'_>, 893 ) -> Result<(), ArtifactIoError> { 894 use std::ffi::OsStr; 895 use std::os::unix::ffi::OsStrExt as _; 896 897 let mut reader = rustix::fs::Dir::read_from(directory) 898 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 899 let mut names = Vec::new(); 900 while let Some(entry) = reader.read() { 901 let entry = entry.map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 902 let name = entry.file_name().to_bytes(); 903 if matches!(name, b"." | b"..") { 904 continue; 905 } 906 state.entries = state.entries.checked_add(1).ok_or_else(|| { 907 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 908 LimitKind::TraversalEntries, 909 )) 910 })?; 911 if state.entries > state.limits.max_entries { 912 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 913 LimitKind::TraversalEntries, 914 ))); 915 } 916 names.push(name.to_vec()); 917 } 918 names.sort(); 919 920 let mut members = Vec::with_capacity(names.len()); 921 for name in names { 922 if name.is_empty() || name.contains(&0) || name.contains(&b'/') { 923 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 924 } 925 let component = OsStr::from_bytes(&name); 926 let child_relative = relative.join(component); 927 if path_byte_len(&child_relative) > state.limits.max_path_bytes { 928 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 929 LimitKind::TraversalPathBytes, 930 ))); 931 } 932 let child = open_at(directory, component)?; 933 let metadata = child 934 .metadata() 935 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 936 let child_identity = identity_from_metadata(&metadata); 937 if metadata.is_dir() { 938 let child_directory_identity = directory_identity(child_identity); 939 members.push(DirectoryMember { 940 name: component.to_os_string(), 941 identity: MemberIdentity::Directory(child_directory_identity), 942 }); 943 let next_depth = depth.checked_add(1).ok_or_else(|| { 944 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 945 LimitKind::TraversalDepth, 946 )) 947 })?; 948 if next_depth > state.limits.max_depth { 949 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 950 LimitKind::TraversalDepth, 951 ))); 952 } 953 if state 954 .exclusions 955 .iter() 956 .any(|excluded| name == excluded.as_bytes()) 957 { 958 continue; 959 } 960 walk_directory(&child, &child_relative, next_depth, child_identity, state)?; 961 } else if metadata.is_file() { 962 if child_identity.links != 1 { 963 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 964 } 965 if child_identity.length > state.limits.max_file_bytes { 966 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 967 LimitKind::FileBytes, 968 ))); 969 } 970 let file_count = state.files.len().checked_add(1).ok_or_else(|| { 971 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 972 LimitKind::TraversalFiles, 973 )) 974 })?; 975 if file_count as u64 > state.limits.max_files { 976 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 977 LimitKind::TraversalFiles, 978 ))); 979 } 980 state.total_bytes = state 981 .total_bytes 982 .checked_add(child_identity.length) 983 .ok_or_else(|| { 984 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 985 LimitKind::TraversalTotalBytes, 986 )) 987 })?; 988 if state.total_bytes > state.limits.max_total_bytes { 989 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 990 LimitKind::TraversalTotalBytes, 991 ))); 992 } 993 state.files.push(TraversedFile { 994 relative: child_relative, 995 identity: child_identity, 996 }); 997 members.push(DirectoryMember { 998 name: component.to_os_string(), 999 identity: MemberIdentity::Regular(child_identity), 1000 }); 1001 } else { 1002 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1003 } 1004 } 1005 state.directories.push(TraversedDirectory { 1006 relative: relative.to_path_buf(), 1007 identity: directory_identity(current_identity), 1008 permission_mode: permission_mode(current_identity), 1009 members, 1010 }); 1011 Ok(()) 1012 } 1013 1014 #[cfg(unix)] 1015 fn revalidate_directory_members( 1016 directory: &File, 1017 expected: &[DirectoryMember], 1018 ) -> Result<(), ArtifactIoError> { 1019 use std::os::unix::ffi::OsStrExt as _; 1020 1021 let changed = || ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead); 1022 let mut reader = rustix::fs::Dir::read_from(directory).map_err(|_| changed())?; 1023 let mut names = Vec::with_capacity(expected.len()); 1024 while let Some(entry) = reader.read() { 1025 let entry = entry.map_err(|_| changed())?; 1026 let name = entry.file_name().to_bytes(); 1027 if matches!(name, b"." | b"..") { 1028 continue; 1029 } 1030 if names.len() == expected.len() { 1031 return Err(changed()); 1032 } 1033 names.push(OsString::from(OsStr::from_bytes(name))); 1034 } 1035 names.sort(); 1036 if names.len() != expected.len() 1037 || names 1038 .iter() 1039 .zip(expected) 1040 .any(|(name, member)| name != &member.name) 1041 { 1042 return Err(changed()); 1043 } 1044 for member in expected { 1045 let child = open_at(directory, &member.name).map_err(|_| changed())?; 1046 let metadata = child.metadata().map_err(|_| changed())?; 1047 let identity = identity_from_metadata(&metadata); 1048 let observed = if metadata.is_dir() { 1049 MemberIdentity::Directory(directory_identity(identity)) 1050 } else if metadata.is_file() && identity.links == 1 { 1051 MemberIdentity::Regular(identity) 1052 } else { 1053 return Err(changed()); 1054 }; 1055 if observed != member.identity { 1056 return Err(changed()); 1057 } 1058 } 1059 Ok(()) 1060 } 1061 1062 #[cfg(not(unix))] 1063 fn revalidate_directory_members( 1064 _directory: &File, 1065 _expected: &[DirectoryMember], 1066 ) -> Result<(), ArtifactIoError> { 1067 Err(ArtifactIoError::new( 1068 ArtifactIoFailureKind::UnsupportedPlatform, 1069 )) 1070 } 1071 1072 #[cfg(not(unix))] 1073 fn walk_directory( 1074 _directory: &File, 1075 _relative: &Path, 1076 _depth: usize, 1077 _current_identity: FileIdentity, 1078 _state: &mut TraversalState<'_>, 1079 ) -> Result<(), ArtifactIoError> { 1080 Err(ArtifactIoError::new( 1081 ArtifactIoFailureKind::UnsupportedPlatform, 1082 )) 1083 } 1084 1085 pub(crate) fn admit_tar_gzip_path( 1086 path: &Path, 1087 limits: TarGzipLimits, 1088 ) -> Result<ArchiveEvidence, ArtifactIoError> { 1089 let (root, relative) = split_absolute_file(path)?; 1090 admit_tar_gzip_relative(&root, &relative, limits, None, TarGzipPolicy::Generic) 1091 } 1092 1093 pub(crate) fn materialize_tar_gzip_path( 1094 path: &Path, 1095 trusted_parent: &Path, 1096 limits: TarGzipLimits, 1097 ) -> Result<MaterializedArchive, ArtifactIoError> { 1098 let (root, relative) = split_absolute_file(path)?; 1099 materialize_tar_gzip_relative( 1100 &root, 1101 &relative, 1102 trusted_parent, 1103 limits, 1104 None, 1105 TarGzipPolicy::Generic, 1106 ) 1107 } 1108 1109 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 1110 enum TarGzipPolicy { 1111 Generic, 1112 DeterministicSnapshot, 1113 } 1114 1115 fn admit_tar_gzip_relative( 1116 root: &Path, 1117 relative: &Path, 1118 limits: TarGzipLimits, 1119 expected: Option<&FileIdentity>, 1120 policy: TarGzipPolicy, 1121 ) -> Result<ArchiveEvidence, ArtifactIoError> { 1122 validate_archive_limits(limits)?; 1123 with_regular( 1124 root, 1125 relative, 1126 limits.max_compressed_bytes, 1127 expected, 1128 || {}, 1129 |file, admitted_length| { 1130 let evidence = admit_tar_gzip_reader(file, limits, policy, None)?; 1131 require_observed_length(evidence.compressed.byte_length, admitted_length)?; 1132 Ok(evidence) 1133 }, 1134 ) 1135 .map_err(|error| match error.kind() { 1136 ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) => ArtifactIoError::new( 1137 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes), 1138 ), 1139 _ => error, 1140 }) 1141 } 1142 1143 fn materialize_tar_gzip_relative( 1144 root: &Path, 1145 relative: &Path, 1146 trusted_parent: &Path, 1147 limits: TarGzipLimits, 1148 expected: Option<&FileIdentity>, 1149 policy: TarGzipPolicy, 1150 ) -> Result<MaterializedArchive, ArtifactIoError> { 1151 validate_archive_limits(limits)?; 1152 let (parent_descriptor, parent_chain) = open_trusted_output_directory(trusted_parent)?; 1153 let parent_identity = *parent_chain 1154 .last() 1155 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 1156 let mut directory_builder = tempfile::Builder::new(); 1157 directory_builder.prefix("radroots-advisory-materialized-"); 1158 #[cfg(unix)] 1159 { 1160 use std::os::unix::fs::PermissionsExt as _; 1161 1162 directory_builder.permissions(fs::Permissions::from_mode(0o700)); 1163 } 1164 let directory = directory_builder 1165 .tempdir_in(trusted_parent) 1166 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1167 let (_, rebound_parent_chain) = open_trusted_output_directory(trusted_parent)?; 1168 if rebound_parent_chain != parent_chain 1169 || output_directory_identity(identity(&parent_descriptor)?) != parent_identity 1170 { 1171 return Err(ArtifactIoError::new( 1172 ArtifactIoFailureKind::ChangedDuringRead, 1173 )); 1174 } 1175 let (output, root_chain) = open_absolute_directory(directory.path())?; 1176 let output_identity = identity(&output)?; 1177 let parent_directory_chain = parent_chain 1178 .iter() 1179 .map(|identity| identity.directory) 1180 .collect::<Vec<_>>(); 1181 if root_chain.len() != parent_directory_chain.len().saturating_add(1) 1182 || root_chain[..parent_directory_chain.len()] != parent_directory_chain 1183 { 1184 return Err(ArtifactIoError::new( 1185 ArtifactIoFailureKind::ChangedDuringRead, 1186 )); 1187 } 1188 #[cfg(unix)] 1189 if output_identity.owner != rustix::process::geteuid().as_raw() 1190 || permission_mode(output_identity) != 0o700 1191 { 1192 return Err(ArtifactIoError::new( 1193 ArtifactIoFailureKind::ChangedDuringRead, 1194 )); 1195 } 1196 parent_descriptor 1197 .sync_all() 1198 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1199 let evidence = with_regular( 1200 root, 1201 relative, 1202 limits.max_compressed_bytes, 1203 expected, 1204 || {}, 1205 |file, admitted_length| { 1206 let evidence = admit_tar_gzip_reader(file, limits, policy, Some(&output))?; 1207 require_observed_length(evidence.compressed.byte_length, admitted_length)?; 1208 output 1209 .sync_all() 1210 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1211 Ok(evidence) 1212 }, 1213 ) 1214 .map_err(|error| match error.kind() { 1215 ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) => ArtifactIoError::new( 1216 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes), 1217 ), 1218 _ => error, 1219 })?; 1220 let materialized_snapshot = traverse_regular_files( 1221 directory.path(), 1222 TraversalLimits { 1223 max_entries: limits.max_members, 1224 max_files: limits.max_members, 1225 max_total_bytes: limits.max_payload_bytes, 1226 max_file_bytes: limits.max_member_bytes, 1227 max_depth: limits.max_depth, 1228 max_path_bytes: limits.max_path_bytes, 1229 }, 1230 &[], 1231 )?; 1232 if materialized_snapshot.entry_count() != evidence.member_count 1233 || materialized_snapshot.total_bytes() != evidence.payload_bytes 1234 { 1235 return Err(ArtifactIoError::new( 1236 ArtifactIoFailureKind::ChangedDuringRead, 1237 )); 1238 } 1239 materialized_snapshot.revalidate()?; 1240 parent_descriptor 1241 .sync_all() 1242 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1243 let materialized = MaterializedArchive { 1244 root_descriptor: output, 1245 root_identity: output_directory_identity(output_identity), 1246 root_chain, 1247 parent_descriptor, 1248 parent_identity, 1249 parent_path: trusted_parent.to_owned(), 1250 parent_chain, 1251 snapshot: materialized_snapshot, 1252 evidence, 1253 directory, 1254 }; 1255 materialized.revalidate()?; 1256 Ok(materialized) 1257 } 1258 1259 fn validate_archive_limits(limits: TarGzipLimits) -> Result<(), ArtifactIoError> { 1260 if limits.max_compressed_bytes == 0 1261 || limits.max_compressed_bytes > HARD_MAX_ARCHIVE_COMPRESSED_BYTES 1262 || limits.max_expanded_bytes == 0 1263 || limits.max_expanded_bytes > HARD_MAX_ARCHIVE_EXPANDED_BYTES 1264 || limits.max_members == 0 1265 || limits.max_members > HARD_MAX_ARCHIVE_MEMBERS 1266 || limits.max_member_bytes == 0 1267 || limits.max_member_bytes > HARD_MAX_ARCHIVE_MEMBER_BYTES 1268 || limits.max_payload_bytes == 0 1269 || limits.max_payload_bytes > HARD_MAX_ARCHIVE_PAYLOAD_BYTES 1270 || limits.max_depth == 0 1271 || limits.max_depth > HARD_MAX_TRAVERSAL_DEPTH 1272 || limits.max_path_bytes == 0 1273 || limits.max_path_bytes > HARD_MAX_PATH_BYTES 1274 { 1275 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)) 1276 } else { 1277 Ok(()) 1278 } 1279 } 1280 1281 fn admit_tar_gzip_reader( 1282 file: &mut File, 1283 limits: TarGzipLimits, 1284 policy: TarGzipPolicy, 1285 materialization_root: Option<&File>, 1286 ) -> Result<ArchiveEvidence, ArtifactIoError> { 1287 let expected_compressed_bytes = file 1288 .metadata() 1289 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))? 1290 .len(); 1291 let compressed = HashingLimitReader::new(file, limits.max_compressed_bytes); 1292 let mut buffered = BufReader::new(compressed); 1293 validate_gzip_header(&mut buffered, policy)?; 1294 let decoder = GzDecoder::new(buffered); 1295 let expanded = LimitReader::new(decoder, limits.max_expanded_bytes); 1296 let mut archive = tar::Archive::new(expanded); 1297 let mut names = BTreeMap::<Vec<u8>, ArchiveMemberKind>::new(); 1298 let mut members = 0_u64; 1299 let mut payload = 0_u64; 1300 let mut previous_name = None::<Vec<u8>>; 1301 let mut canonical = (policy == TarGzipPolicy::DeterministicSnapshot).then(|| { 1302 GzBuilder::new().mtime(0).operating_system(255).write( 1303 HashingLimitWriter::new(limits.max_compressed_bytes), 1304 Compression::new(CANONICAL_GZIP_COMPRESSION_LEVEL), 1305 ) 1306 }); 1307 1308 let parse_result = (|| { 1309 let entries = archive 1310 .entries() 1311 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))? 1312 .raw(true); 1313 for entry in entries { 1314 let mut entry = 1315 entry.map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?; 1316 members = members.checked_add(1).ok_or_else(|| { 1317 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1318 LimitKind::ArchiveMembers, 1319 )) 1320 })?; 1321 if members > limits.max_members { 1322 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1323 LimitKind::ArchiveMembers, 1324 ))); 1325 } 1326 let declared = entry 1327 .header() 1328 .size() 1329 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?; 1330 if declared > limits.max_member_bytes { 1331 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1332 LimitKind::ArchiveMemberBytes, 1333 ))); 1334 } 1335 let member_kind = match entry.header().entry_type().as_byte() { 1336 0 | b'0' => ArchiveMemberKind::File, 1337 b'5' if declared == 0 => ArchiveMemberKind::Directory, 1338 _ => { 1339 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1340 } 1341 }; 1342 let name = entry.path_bytes().into_owned(); 1343 validate_archive_path(&name, limits.max_depth, limits.max_path_bytes)?; 1344 let canonical_header = if policy == TarGzipPolicy::DeterministicSnapshot { 1345 let header = validate_deterministic_tar_header( 1346 entry.header(), 1347 member_kind, 1348 &name, 1349 declared, 1350 )?; 1351 if previous_name 1352 .as_ref() 1353 .is_some_and(|previous| previous >= &name) 1354 { 1355 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1356 } 1357 previous_name = Some(name.clone()); 1358 Some(header) 1359 } else { 1360 None 1361 }; 1362 if match member_kind { 1363 ArchiveMemberKind::Directory => !name.ends_with(b"/"), 1364 ArchiveMemberKind::File => name.ends_with(b"/"), 1365 } { 1366 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1367 } 1368 admit_archive_name(&mut names, &name, member_kind)?; 1369 if let (Some(encoder), Some(header)) = (canonical.as_mut(), canonical_header.as_ref()) { 1370 write_canonical_archive_bytes(encoder, header.as_bytes())?; 1371 } 1372 payload = payload.checked_add(declared).ok_or_else(|| { 1373 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1374 LimitKind::ArchivePayloadBytes, 1375 )) 1376 })?; 1377 if payload > limits.max_payload_bytes { 1378 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1379 LimitKind::ArchivePayloadBytes, 1380 ))); 1381 } 1382 let mut materialized = materialization_root 1383 .map(|root| materialize_archive_member(root, member_kind, &name)) 1384 .transpose()? 1385 .flatten(); 1386 let mut actual = 0_u64; 1387 let mut buffer = [0_u8; STREAM_BUFFER_BYTES]; 1388 loop { 1389 let read = entry 1390 .read(&mut buffer) 1391 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?; 1392 if read == 0 { 1393 break; 1394 } 1395 actual = actual.checked_add(read as u64).ok_or_else(|| { 1396 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1397 LimitKind::ArchiveMemberBytes, 1398 )) 1399 })?; 1400 if actual > limits.max_member_bytes { 1401 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1402 LimitKind::ArchiveMemberBytes, 1403 ))); 1404 } 1405 if let Some(destination) = materialized.as_mut() { 1406 destination 1407 .write_all(&buffer[..read]) 1408 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1409 } 1410 if let Some(encoder) = canonical.as_mut() { 1411 write_canonical_archive_bytes(encoder, &buffer[..read])?; 1412 } 1413 } 1414 if actual != declared { 1415 return Err(ArtifactIoError::new( 1416 ArtifactIoFailureKind::MalformedArchive, 1417 )); 1418 } 1419 if let Some(encoder) = canonical.as_mut() { 1420 let padding = (TAR_BLOCK_BYTES as u64 - actual % TAR_BLOCK_BYTES as u64) 1421 % TAR_BLOCK_BYTES as u64; 1422 write_canonical_archive_bytes( 1423 encoder, 1424 &[0_u8; TAR_BLOCK_BYTES][..padding as usize], 1425 )?; 1426 } 1427 if let Some(destination) = materialized { 1428 destination 1429 .sync_all() 1430 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1431 } 1432 } 1433 Ok(()) 1434 })(); 1435 1436 let mut expanded = archive.into_inner(); 1437 if expanded.exceeded() { 1438 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1439 LimitKind::ArchiveExpandedBytes, 1440 ))); 1441 } 1442 if compressed_limit_exceeded(&expanded) { 1443 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1444 LimitKind::ArchiveCompressedBytes, 1445 ))); 1446 } 1447 parse_result?; 1448 1449 if let Some(encoder) = canonical.as_mut() { 1450 write_canonical_archive_bytes(encoder, &[0_u8; TAR_TERMINATOR_BYTES])?; 1451 } 1452 1453 let mut trailing = [0_u8; STREAM_BUFFER_BYTES]; 1454 let mut trailing_zero_bytes = 0_u64; 1455 loop { 1456 let read = expanded 1457 .read(&mut trailing) 1458 .map_err(|_| classify_expanded_error(&expanded))?; 1459 if read == 0 { 1460 break; 1461 } 1462 if trailing[..read].iter().any(|byte| *byte != 0) { 1463 return Err(ArtifactIoError::new( 1464 ArtifactIoFailureKind::MalformedArchive, 1465 )); 1466 } 1467 trailing_zero_bytes = trailing_zero_bytes 1468 .checked_add(read as u64) 1469 .ok_or_else(|| { 1470 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1471 LimitKind::ArchiveExpandedBytes, 1472 )) 1473 })?; 1474 } 1475 if expanded.exceeded() { 1476 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1477 LimitKind::ArchiveExpandedBytes, 1478 ))); 1479 } 1480 if compressed_limit_exceeded(&expanded) { 1481 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1482 LimitKind::ArchiveCompressedBytes, 1483 ))); 1484 } 1485 if members == 0 || trailing_zero_bytes < 512 { 1486 return Err(ArtifactIoError::new( 1487 ArtifactIoFailureKind::MalformedArchive, 1488 )); 1489 } 1490 let expanded_bytes = expanded.total(); 1491 let decoder = expanded.into_inner(); 1492 let mut buffered = decoder.into_inner(); 1493 if !buffered 1494 .fill_buf() 1495 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))? 1496 .is_empty() 1497 { 1498 return Err(ArtifactIoError::new( 1499 ArtifactIoFailureKind::MalformedArchive, 1500 )); 1501 } 1502 let compressed = buffered.into_inner(); 1503 if compressed.exceeded() { 1504 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1505 LimitKind::ArchiveCompressedBytes, 1506 ))); 1507 } 1508 let compressed_bytes = compressed.total(); 1509 if compressed_bytes != expected_compressed_bytes { 1510 return Err(ArtifactIoError::new( 1511 ArtifactIoFailureKind::MalformedArchive, 1512 )); 1513 } 1514 let compressed_evidence = FileEvidence { 1515 byte_length: compressed_bytes, 1516 sha256: compressed.finalize(), 1517 }; 1518 if let Some(encoder) = canonical { 1519 let canonical_evidence = encoder 1520 .finish() 1521 .map_err(|_| { 1522 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1523 LimitKind::ArchiveCompressedBytes, 1524 )) 1525 })? 1526 .finalize(); 1527 if canonical_evidence != compressed_evidence { 1528 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1529 } 1530 } 1531 Ok(ArchiveEvidence { 1532 compressed: compressed_evidence, 1533 expanded_bytes, 1534 member_count: members, 1535 payload_bytes: payload, 1536 }) 1537 } 1538 1539 #[cfg(unix)] 1540 fn materialize_archive_member( 1541 root: &File, 1542 kind: ArchiveMemberKind, 1543 raw_name: &[u8], 1544 ) -> Result<Option<File>, ArtifactIoError> { 1545 use rustix::fs::{Mode, OFlags, fchmod, mkdirat, openat}; 1546 1547 let name = std::str::from_utf8(raw_name) 1548 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))? 1549 .trim_end_matches('/'); 1550 let path = Path::new(name); 1551 let mut components = path.components().peekable(); 1552 let mut current = root 1553 .try_clone() 1554 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1555 while let Some(component) = components.next() { 1556 let Component::Normal(component) = component else { 1557 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1558 }; 1559 let is_leaf = components.peek().is_none(); 1560 if !is_leaf { 1561 current = File::from( 1562 openat( 1563 ¤t, 1564 component, 1565 OFlags::RDONLY 1566 | OFlags::DIRECTORY 1567 | OFlags::NOFOLLOW 1568 | OFlags::CLOEXEC 1569 | OFlags::NONBLOCK, 1570 Mode::empty(), 1571 ) 1572 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, 1573 ); 1574 continue; 1575 } 1576 return match kind { 1577 ArchiveMemberKind::Directory => { 1578 mkdirat(¤t, component, Mode::RUSR | Mode::WUSR | Mode::XUSR) 1579 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 1580 let directory = File::from( 1581 openat( 1582 ¤t, 1583 component, 1584 OFlags::RDONLY 1585 | OFlags::DIRECTORY 1586 | OFlags::NOFOLLOW 1587 | OFlags::CLOEXEC 1588 | OFlags::NONBLOCK, 1589 Mode::empty(), 1590 ) 1591 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, 1592 ); 1593 fchmod( 1594 &directory, 1595 Mode::RUSR 1596 | Mode::WUSR 1597 | Mode::XUSR 1598 | Mode::RGRP 1599 | Mode::XGRP 1600 | Mode::ROTH 1601 | Mode::XOTH, 1602 ) 1603 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1604 directory 1605 .sync_all() 1606 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1607 current 1608 .sync_all() 1609 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1610 Ok(None) 1611 } 1612 ArchiveMemberKind::File => { 1613 let file = File::from( 1614 openat( 1615 ¤t, 1616 component, 1617 OFlags::WRONLY 1618 | OFlags::CREATE 1619 | OFlags::EXCL 1620 | OFlags::NOFOLLOW 1621 | OFlags::CLOEXEC 1622 | OFlags::NONBLOCK, 1623 Mode::RUSR | Mode::WUSR, 1624 ) 1625 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?, 1626 ); 1627 fchmod(&file, Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::ROTH) 1628 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1629 current 1630 .sync_all() 1631 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 1632 Ok(Some(file)) 1633 } 1634 }; 1635 } 1636 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)) 1637 } 1638 1639 #[cfg(not(unix))] 1640 fn materialize_archive_member( 1641 _root: &File, 1642 _kind: ArchiveMemberKind, 1643 _raw_name: &[u8], 1644 ) -> Result<Option<File>, ArtifactIoError> { 1645 Err(ArtifactIoError::new( 1646 ArtifactIoFailureKind::UnsupportedPlatform, 1647 )) 1648 } 1649 1650 fn validate_gzip_header( 1651 reader: &mut BufReader<HashingLimitReader<'_>>, 1652 policy: TarGzipPolicy, 1653 ) -> Result<(), ArtifactIoError> { 1654 if reader.fill_buf().is_err() { 1655 return Err(if reader.get_ref().exceeded() { 1656 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1657 LimitKind::ArchiveCompressedBytes, 1658 )) 1659 } else { 1660 ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive) 1661 }); 1662 } 1663 let header = reader.buffer(); 1664 if header.len() < 10 || header[..3] != [0x1f, 0x8b, 8] { 1665 return Err(ArtifactIoError::new( 1666 ArtifactIoFailureKind::MalformedArchive, 1667 )); 1668 } 1669 if header[3] != 0 { 1670 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1671 } 1672 if policy == TarGzipPolicy::DeterministicSnapshot 1673 && (header[4..8] != [0, 0, 0, 0] || header[8] != 0 || header[9] != 255) 1674 { 1675 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1676 } 1677 Ok(()) 1678 } 1679 1680 fn validate_deterministic_tar_header( 1681 header: &tar::Header, 1682 kind: ArchiveMemberKind, 1683 name: &[u8], 1684 size: u64, 1685 ) -> Result<TarHeader, ArtifactIoError> { 1686 let path = std::str::from_utf8(name) 1687 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 1688 let mut expected = TarHeader::new_gnu(); 1689 expected 1690 .set_path(path) 1691 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 1692 expected.set_entry_type(match kind { 1693 ArchiveMemberKind::Directory => tar::EntryType::Directory, 1694 ArchiveMemberKind::File => tar::EntryType::Regular, 1695 }); 1696 expected.set_mode(match kind { 1697 ArchiveMemberKind::Directory => 0o755, 1698 ArchiveMemberKind::File => 0o644, 1699 }); 1700 expected.set_uid(0); 1701 expected.set_gid(0); 1702 expected.set_mtime(0); 1703 expected.set_size(size); 1704 expected.set_cksum(); 1705 if header.as_bytes() == expected.as_bytes() { 1706 Ok(expected) 1707 } else { 1708 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)) 1709 } 1710 } 1711 1712 fn write_canonical_archive_bytes( 1713 encoder: &mut flate2::write::GzEncoder<HashingLimitWriter>, 1714 bytes: &[u8], 1715 ) -> Result<(), ArtifactIoError> { 1716 encoder.write_all(bytes).map_err(|_| { 1717 if encoder.get_ref().exceeded() { 1718 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1719 LimitKind::ArchiveCompressedBytes, 1720 )) 1721 } else { 1722 ArtifactIoError::new(ArtifactIoFailureKind::IoFailure) 1723 } 1724 }) 1725 } 1726 1727 fn classify_expanded_error( 1728 reader: &LimitReader<GzDecoder<BufReader<HashingLimitReader<'_>>>>, 1729 ) -> ArtifactIoError { 1730 if reader.exceeded() { 1731 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1732 LimitKind::ArchiveExpandedBytes, 1733 )) 1734 } else if compressed_limit_exceeded(reader) { 1735 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1736 LimitKind::ArchiveCompressedBytes, 1737 )) 1738 } else { 1739 ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive) 1740 } 1741 } 1742 1743 fn compressed_limit_exceeded( 1744 reader: &LimitReader<GzDecoder<BufReader<HashingLimitReader<'_>>>>, 1745 ) -> bool { 1746 reader.get_ref().get_ref().get_ref().exceeded() 1747 } 1748 1749 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 1750 enum ArchiveMemberKind { 1751 Directory, 1752 File, 1753 } 1754 1755 fn validate_archive_path( 1756 path: &[u8], 1757 maximum_depth: usize, 1758 maximum_bytes: usize, 1759 ) -> Result<(), ArtifactIoError> { 1760 if path.len() > maximum_bytes { 1761 return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1762 LimitKind::ArchivePathBytes, 1763 ))); 1764 } 1765 if path.is_empty() 1766 || path.starts_with(b"/") 1767 || path.contains(&0) 1768 || path.contains(&b'\\') 1769 || std::str::from_utf8(path).is_err() 1770 { 1771 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1772 } 1773 let trimmed = path.strip_suffix(b"/").unwrap_or(path); 1774 let mut depth = 0_usize; 1775 for component in trimmed.split(|byte| *byte == b'/') { 1776 if component.is_empty() || matches!(component, b"." | b"..") { 1777 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1778 } 1779 depth = depth.checked_add(1).ok_or_else(|| { 1780 ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1781 LimitKind::ArchiveDepth, 1782 )) 1783 })?; 1784 } 1785 if depth > maximum_depth { 1786 Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded( 1787 LimitKind::ArchiveDepth, 1788 ))) 1789 } else { 1790 Ok(()) 1791 } 1792 } 1793 1794 fn admit_archive_name( 1795 names: &mut BTreeMap<Vec<u8>, ArchiveMemberKind>, 1796 path: &[u8], 1797 kind: ArchiveMemberKind, 1798 ) -> Result<(), ArtifactIoError> { 1799 let mut path = path.to_vec(); 1800 if kind == ArchiveMemberKind::Directory && path.ends_with(b"/") { 1801 path.pop(); 1802 } 1803 if names.contains_key(&path) { 1804 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1805 } 1806 let mut offset = 0_usize; 1807 while let Some(index) = path[offset..].iter().position(|byte| *byte == b'/') { 1808 offset = offset 1809 .checked_add(index) 1810 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 1811 if names 1812 .get(&path[..offset]) 1813 .is_some_and(|existing| *existing == ArchiveMemberKind::File) 1814 { 1815 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1816 } 1817 offset = offset 1818 .checked_add(1) 1819 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 1820 } 1821 if kind == ArchiveMemberKind::File { 1822 let mut prefix = path.clone(); 1823 prefix.push(b'/'); 1824 if names 1825 .range(prefix.clone()..) 1826 .next() 1827 .is_some_and(|(candidate, _)| candidate.starts_with(&prefix)) 1828 { 1829 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 1830 } 1831 } 1832 names.insert(path, kind); 1833 Ok(()) 1834 } 1835 1836 struct LimitReader<R> { 1837 inner: R, 1838 maximum: u64, 1839 total: u64, 1840 exceeded: bool, 1841 } 1842 1843 impl<R> LimitReader<R> { 1844 const fn new(inner: R, maximum: u64) -> Self { 1845 Self { 1846 inner, 1847 maximum, 1848 total: 0, 1849 exceeded: false, 1850 } 1851 } 1852 1853 const fn total(&self) -> u64 { 1854 self.total 1855 } 1856 1857 const fn exceeded(&self) -> bool { 1858 self.exceeded 1859 } 1860 1861 fn into_inner(self) -> R { 1862 self.inner 1863 } 1864 1865 const fn get_ref(&self) -> &R { 1866 &self.inner 1867 } 1868 } 1869 1870 impl<R: Read> Read for LimitReader<R> { 1871 fn read(&mut self, output: &mut [u8]) -> io::Result<usize> { 1872 if output.is_empty() { 1873 return Ok(0); 1874 } 1875 let remaining = self.maximum.saturating_sub(self.total); 1876 let allowed = usize::try_from(remaining.saturating_add(1)) 1877 .unwrap_or(usize::MAX) 1878 .min(output.len()); 1879 let read = self.inner.read(&mut output[..allowed])?; 1880 self.total = self 1881 .total 1882 .checked_add(read as u64) 1883 .ok_or_else(|| io::Error::other("bounded stream length overflow"))?; 1884 if self.total > self.maximum { 1885 self.exceeded = true; 1886 Err(io::Error::other("bounded stream limit exceeded")) 1887 } else { 1888 Ok(read) 1889 } 1890 } 1891 } 1892 1893 struct HashingLimitReader<'a> { 1894 inner: &'a mut File, 1895 maximum: u64, 1896 total: u64, 1897 exceeded: bool, 1898 hasher: Sha256, 1899 } 1900 1901 impl<'a> HashingLimitReader<'a> { 1902 fn new(inner: &'a mut File, maximum: u64) -> Self { 1903 Self { 1904 inner, 1905 maximum, 1906 total: 0, 1907 exceeded: false, 1908 hasher: Sha256::new(), 1909 } 1910 } 1911 1912 const fn total(&self) -> u64 { 1913 self.total 1914 } 1915 1916 const fn exceeded(&self) -> bool { 1917 self.exceeded 1918 } 1919 1920 fn finalize(self) -> String { 1921 hex::encode(self.hasher.finalize()) 1922 } 1923 } 1924 1925 impl Read for HashingLimitReader<'_> { 1926 fn read(&mut self, output: &mut [u8]) -> io::Result<usize> { 1927 if output.is_empty() { 1928 return Ok(0); 1929 } 1930 let remaining = self.maximum.saturating_sub(self.total); 1931 let allowed = usize::try_from(remaining.saturating_add(1)) 1932 .unwrap_or(usize::MAX) 1933 .min(output.len()); 1934 let read = self.inner.read(&mut output[..allowed])?; 1935 self.total = self 1936 .total 1937 .checked_add(read as u64) 1938 .ok_or_else(|| io::Error::other("bounded stream length overflow"))?; 1939 if self.total > self.maximum { 1940 self.exceeded = true; 1941 return Err(io::Error::other("bounded stream limit exceeded")); 1942 } 1943 self.hasher.update(&output[..read]); 1944 Ok(read) 1945 } 1946 } 1947 1948 struct HashingLimitWriter { 1949 maximum: u64, 1950 total: u64, 1951 exceeded: bool, 1952 hasher: Sha256, 1953 } 1954 1955 impl HashingLimitWriter { 1956 fn new(maximum: u64) -> Self { 1957 Self { 1958 maximum, 1959 total: 0, 1960 exceeded: false, 1961 hasher: Sha256::new(), 1962 } 1963 } 1964 1965 const fn exceeded(&self) -> bool { 1966 self.exceeded 1967 } 1968 1969 fn finalize(self) -> FileEvidence { 1970 FileEvidence { 1971 byte_length: self.total, 1972 sha256: hex::encode(self.hasher.finalize()), 1973 } 1974 } 1975 } 1976 1977 impl Write for HashingLimitWriter { 1978 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { 1979 let next = self 1980 .total 1981 .checked_add(bytes.len() as u64) 1982 .ok_or_else(|| io::Error::other("bounded stream length overflow"))?; 1983 if next > self.maximum { 1984 self.exceeded = true; 1985 return Err(io::Error::other("bounded stream limit exceeded")); 1986 } 1987 self.hasher.update(bytes); 1988 self.total = next; 1989 Ok(bytes.len()) 1990 } 1991 1992 fn flush(&mut self) -> io::Result<()> { 1993 Ok(()) 1994 } 1995 } 1996 1997 fn split_absolute_file(path: &Path) -> Result<(PathBuf, PathBuf), ArtifactIoError> { 1998 if !path.is_absolute() { 1999 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2000 } 2001 let parent = path 2002 .parent() 2003 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 2004 let name = path 2005 .file_name() 2006 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 2007 Ok((parent.to_path_buf(), PathBuf::from(name))) 2008 } 2009 2010 #[cfg(unix)] 2011 fn open_absolute_directory(path: &Path) -> Result<(File, Vec<DirectoryIdentity>), ArtifactIoError> { 2012 use rustix::fs::{Mode, OFlags, open, openat}; 2013 2014 if !path.is_absolute() { 2015 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2016 } 2017 let flags = 2018 OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::DIRECTORY; 2019 let root = open("/", flags, Mode::empty()) 2020 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 2021 let mut current = File::from(root); 2022 let mut chain = vec![directory_identity(identity(¤t)?)]; 2023 for component in path.components() { 2024 match component { 2025 Component::RootDir => {} 2026 Component::Normal(name) => { 2027 let next = openat(¤t, name, flags, Mode::empty()) 2028 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 2029 current = File::from(next); 2030 chain.push(directory_identity(identity(¤t)?)); 2031 } 2032 _ => { 2033 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2034 } 2035 } 2036 } 2037 Ok((current, chain)) 2038 } 2039 2040 pub(crate) fn validate_trusted_output_directory(path: &Path) -> Result<(), ArtifactIoError> { 2041 let _ = open_trusted_output_directory(path)?; 2042 Ok(()) 2043 } 2044 2045 #[cfg(unix)] 2046 fn open_trusted_output_directory( 2047 path: &Path, 2048 ) -> Result<(File, Vec<OutputDirectoryIdentity>), ArtifactIoError> { 2049 use rustix::fs::{Mode, OFlags, open, openat}; 2050 2051 if !path.is_absolute() { 2052 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2053 } 2054 let flags = 2055 OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::DIRECTORY; 2056 let root = open("/", flags, Mode::empty()) 2057 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 2058 let mut current = File::from(root); 2059 let mut chain = Vec::new(); 2060 let root_identity = output_directory_identity(identity(¤t)?); 2061 if !output_directory_is_trusted(root_identity) { 2062 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2063 } 2064 chain.push(root_identity); 2065 for component in path.components() { 2066 match component { 2067 Component::RootDir => {} 2068 Component::Normal(name) => { 2069 let next = openat(¤t, name, flags, Mode::empty()) 2070 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 2071 current = File::from(next); 2072 let current_identity = output_directory_identity(identity(¤t)?); 2073 if !output_directory_is_trusted(current_identity) { 2074 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2075 } 2076 chain.push(current_identity); 2077 } 2078 _ => { 2079 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2080 } 2081 } 2082 } 2083 Ok((current, chain)) 2084 } 2085 2086 #[cfg(not(unix))] 2087 fn open_absolute_directory( 2088 _path: &Path, 2089 ) -> Result<(File, Vec<DirectoryIdentity>), ArtifactIoError> { 2090 Err(ArtifactIoError::new( 2091 ArtifactIoFailureKind::UnsupportedPlatform, 2092 )) 2093 } 2094 2095 #[cfg(not(unix))] 2096 fn open_trusted_output_directory( 2097 _path: &Path, 2098 ) -> Result<(File, Vec<OutputDirectoryIdentity>), ArtifactIoError> { 2099 Err(ArtifactIoError::new( 2100 ArtifactIoFailureKind::UnsupportedPlatform, 2101 )) 2102 } 2103 2104 fn open_relative( 2105 root: &Path, 2106 relative: &Path, 2107 expected: ObjectKind, 2108 ) -> Result<OpenedObject, ArtifactIoError> { 2109 validate_relative(relative)?; 2110 let (mut current, mut chain) = open_absolute_directory(root)?; 2111 let components = relative.components().collect::<Vec<_>>(); 2112 let mut object_identity = None; 2113 for (index, component) in components.iter().enumerate() { 2114 let Component::Normal(name) = component else { 2115 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)); 2116 }; 2117 let object = open_at(¤t, name)?; 2118 let metadata = object 2119 .metadata() 2120 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 2121 let final_component = index + 1 == components.len(); 2122 if (!final_component && !metadata.is_dir()) 2123 || (final_component 2124 && match expected { 2125 ObjectKind::Directory => !metadata.is_dir(), 2126 ObjectKind::Regular => !metadata.is_file(), 2127 }) 2128 { 2129 return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject)); 2130 } 2131 let current_identity = identity_from_metadata(&metadata); 2132 current = object; 2133 if metadata.is_dir() { 2134 chain.push(directory_identity(current_identity)); 2135 } 2136 object_identity = Some(current_identity); 2137 } 2138 let object_identity = object_identity 2139 .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?; 2140 Ok(OpenedObject { 2141 file: current, 2142 identity: object_identity, 2143 chain, 2144 }) 2145 } 2146 2147 #[cfg(unix)] 2148 fn open_at(directory: &File, name: &std::ffi::OsStr) -> Result<File, ArtifactIoError> { 2149 use rustix::fs::{Mode, OFlags, openat}; 2150 2151 let flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK; 2152 let descriptor = openat(directory, name, flags, Mode::empty()) 2153 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; 2154 Ok(File::from(descriptor)) 2155 } 2156 2157 #[cfg(not(unix))] 2158 fn open_at(_directory: &File, _name: &std::ffi::OsStr) -> Result<File, ArtifactIoError> { 2159 Err(ArtifactIoError::new( 2160 ArtifactIoFailureKind::UnsupportedPlatform, 2161 )) 2162 } 2163 2164 fn validate_relative(path: &Path) -> Result<(), ArtifactIoError> { 2165 if path.as_os_str().is_empty() 2166 || path.is_absolute() 2167 || path 2168 .components() 2169 .any(|component| !matches!(component, Component::Normal(_))) 2170 { 2171 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)) 2172 } else { 2173 Ok(()) 2174 } 2175 } 2176 2177 fn validate_single_component(name: &OsStr) -> Result<(), ArtifactIoError> { 2178 let path = Path::new(name); 2179 let mut components = path.components(); 2180 if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() { 2181 Ok(()) 2182 } else { 2183 Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest)) 2184 } 2185 } 2186 2187 #[cfg(unix)] 2188 fn path_byte_len(path: &Path) -> usize { 2189 use std::os::unix::ffi::OsStrExt as _; 2190 path.as_os_str().as_bytes().len() 2191 } 2192 2193 #[cfg(not(unix))] 2194 fn path_byte_len(path: &Path) -> usize { 2195 path.as_os_str().to_string_lossy().len() 2196 } 2197 2198 fn identity(file: &File) -> Result<FileIdentity, ArtifactIoError> { 2199 let metadata = file 2200 .metadata() 2201 .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?; 2202 Ok(identity_from_metadata(&metadata)) 2203 } 2204 2205 const fn directory_identity(identity: FileIdentity) -> DirectoryIdentity { 2206 DirectoryIdentity { 2207 device: identity.device, 2208 inode: identity.inode, 2209 } 2210 } 2211 2212 const fn permission_mode(identity: FileIdentity) -> u32 { 2213 identity.mode & 0o7777 2214 } 2215 2216 #[cfg(unix)] 2217 fn identity_from_metadata(metadata: &std::fs::Metadata) -> FileIdentity { 2218 use std::os::unix::fs::MetadataExt as _; 2219 FileIdentity { 2220 device: metadata.dev(), 2221 inode: metadata.ino(), 2222 mode: metadata.mode(), 2223 owner: metadata.uid(), 2224 links: metadata.nlink(), 2225 length: metadata.len(), 2226 modified_seconds: metadata.mtime(), 2227 modified_nanoseconds: metadata.mtime_nsec(), 2228 changed_seconds: metadata.ctime(), 2229 changed_nanoseconds: metadata.ctime_nsec(), 2230 } 2231 } 2232 2233 #[cfg(not(unix))] 2234 fn identity_from_metadata(metadata: &std::fs::Metadata) -> FileIdentity { 2235 FileIdentity { 2236 device: 0, 2237 inode: 0, 2238 mode: 0, 2239 owner: 0, 2240 links: 0, 2241 length: metadata.len(), 2242 modified_seconds: 0, 2243 modified_nanoseconds: 0, 2244 changed_seconds: 0, 2245 changed_nanoseconds: 0, 2246 } 2247 } 2248 2249 pub(crate) fn self_test() -> Result<(), String> { 2250 #[cfg(unix)] 2251 { 2252 self_test_suite()?; 2253 Ok(()) 2254 } 2255 #[cfg(not(unix))] 2256 { 2257 Err(ArtifactIoError::new(ArtifactIoFailureKind::UnsupportedPlatform).to_string()) 2258 } 2259 } 2260 2261 #[cfg(unix)] 2262 fn self_test_suite() -> Result<(), String> { 2263 let directory = 2264 tempfile::tempdir().map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?; 2265 let root = directory 2266 .path() 2267 .canonicalize() 2268 .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?; 2269 fs::write(root.join("input"), b"step-293-safe-artifact") 2270 .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?; 2271 2272 let bytes = read_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())?; 2273 let evidence = 2274 hash_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())?; 2275 if bytes != b"step-293-safe-artifact" 2276 || hash_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())? 2277 != evidence 2278 { 2279 return Err("safe artifact I/O self-test evidence failed".to_owned()); 2280 } 2281 let traversal = traverse_regular_files( 2282 &root, 2283 TraversalLimits { 2284 max_entries: 4, 2285 max_files: 4, 2286 max_total_bytes: 256, 2287 max_file_bytes: 128, 2288 max_depth: 2, 2289 max_path_bytes: 128, 2290 }, 2291 &[], 2292 ) 2293 .map_err(|error| error.to_string())?; 2294 if traversal.entry_count() != 1 2295 || traversal.total_bytes() != evidence.byte_length 2296 || traversal 2297 .read(&traversal.files()[0], 64) 2298 .map_err(|error| error.to_string())? 2299 != bytes 2300 || traversal 2301 .read_evidenced(&traversal.files()[0], 64) 2302 .map_err(|error| error.to_string())? 2303 != (bytes.clone(), evidence.clone()) 2304 { 2305 return Err("safe artifact I/O self-test traversal failed".to_owned()); 2306 } 2307 2308 let archive_path = root.join("archive.tar.gz"); 2309 let output = File::create(&archive_path) 2310 .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?; 2311 let encoder = GzBuilder::new().write(output, Compression::fast()); 2312 let mut archive = TarBuilder::new(encoder); 2313 let mut header = TarHeader::new_gnu(); 2314 header.set_size(bytes.len() as u64); 2315 header.set_mode(0o644); 2316 header.set_cksum(); 2317 archive 2318 .append_data(&mut header, "payload", bytes.as_slice()) 2319 .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?; 2320 let encoder = archive 2321 .into_inner() 2322 .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?; 2323 encoder 2324 .finish() 2325 .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?; 2326 let admitted = admit_tar_gzip_path( 2327 &archive_path, 2328 TarGzipLimits { 2329 max_compressed_bytes: 65_536, 2330 max_expanded_bytes: 65_536, 2331 max_members: 4, 2332 max_member_bytes: 256, 2333 max_payload_bytes: 256, 2334 max_depth: 4, 2335 max_path_bytes: 128, 2336 }, 2337 ) 2338 .map_err(|error| error.to_string())?; 2339 if admitted.member_count != 1 || admitted.payload_bytes != bytes.len() as u64 { 2340 return Err("safe artifact I/O self-test archive failed".to_owned()); 2341 } 2342 Ok(()) 2343 } 2344 2345 #[cfg(all(test, unix))] 2346 mod tests { 2347 use std::fs::{self, OpenOptions}; 2348 use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _, symlink}; 2349 use std::time::Duration; 2350 2351 use flate2::{Compression, GzBuilder}; 2352 use tar::{Builder, EntryType, Header}; 2353 use tempfile::TempDir; 2354 2355 use super::*; 2356 2357 fn root(directory: &TempDir) -> PathBuf { 2358 directory 2359 .path() 2360 .canonicalize() 2361 .expect("canonical test root") 2362 } 2363 2364 fn traversal_limits() -> TraversalLimits { 2365 TraversalLimits { 2366 max_entries: 8, 2367 max_files: 4, 2368 max_total_bytes: 64, 2369 max_file_bytes: 32, 2370 max_depth: 3, 2371 max_path_bytes: 64, 2372 } 2373 } 2374 2375 fn archive_limits() -> TarGzipLimits { 2376 TarGzipLimits { 2377 max_compressed_bytes: 32 * 1024, 2378 max_expanded_bytes: 64 * 1024, 2379 max_members: 8, 2380 max_member_bytes: 8 * 1024, 2381 max_payload_bytes: 16 * 1024, 2382 max_depth: 4, 2383 max_path_bytes: 128, 2384 } 2385 } 2386 2387 #[test] 2388 fn zero_limits_and_unsafe_names_fail_before_filesystem_admission() { 2389 for mutate in [ 2390 |limits: &mut TraversalLimits| limits.max_entries = 0, 2391 |limits: &mut TraversalLimits| limits.max_files = 0, 2392 |limits: &mut TraversalLimits| limits.max_total_bytes = 0, 2393 |limits: &mut TraversalLimits| limits.max_file_bytes = 0, 2394 |limits: &mut TraversalLimits| limits.max_depth = 0, 2395 |limits: &mut TraversalLimits| limits.max_path_bytes = 0, 2396 ] { 2397 let mut limits = traversal_limits(); 2398 mutate(&mut limits); 2399 assert_eq!( 2400 validate_traversal_limits(limits).unwrap_err().kind(), 2401 ArtifactIoFailureKind::InvalidRequest 2402 ); 2403 } 2404 for mutate in [ 2405 |limits: &mut TarGzipLimits| limits.max_compressed_bytes = 0, 2406 |limits: &mut TarGzipLimits| limits.max_expanded_bytes = 0, 2407 |limits: &mut TarGzipLimits| limits.max_members = 0, 2408 |limits: &mut TarGzipLimits| limits.max_member_bytes = 0, 2409 |limits: &mut TarGzipLimits| limits.max_payload_bytes = 0, 2410 |limits: &mut TarGzipLimits| limits.max_depth = 0, 2411 |limits: &mut TarGzipLimits| limits.max_path_bytes = 0, 2412 ] { 2413 let mut limits = archive_limits(); 2414 mutate(&mut limits); 2415 assert_eq!( 2416 validate_archive_limits(limits).unwrap_err().kind(), 2417 ArtifactIoFailureKind::InvalidRequest 2418 ); 2419 } 2420 let directory = TempDir::new().unwrap(); 2421 let root = root(&directory); 2422 fs::write(root.join("input"), b"x").unwrap(); 2423 assert_eq!( 2424 read_regular(&root, Path::new("input"), 0) 2425 .unwrap_err() 2426 .kind(), 2427 ArtifactIoFailureKind::InvalidRequest 2428 ); 2429 assert_eq!( 2430 hash_regular(&root, Path::new("input"), 0) 2431 .unwrap_err() 2432 .kind(), 2433 ArtifactIoFailureKind::InvalidRequest 2434 ); 2435 for excluded in ["", "a/b", "a\\b", ".", ".."] { 2436 assert!(traverse_regular_files(&root, traversal_limits(), &[excluded]).is_err()); 2437 } 2438 for path in ["", "/absolute", "../escape"] { 2439 assert!(validate_relative(Path::new(path)).is_err()); 2440 } 2441 for name in ["", "/absolute", "nested/file", ".", ".."] { 2442 assert!(validate_single_component(OsStr::new(name)).is_err()); 2443 } 2444 for path in [Path::new("relative"), Path::new("/")] { 2445 assert!(copy_regular_to_new_path(&root.join("input"), path, 1).is_err()); 2446 } 2447 assert!(split_absolute_file(Path::new("relative")).is_err()); 2448 assert_eq!(fs::read(root.join("input")).unwrap(), b"x"); 2449 assert!(open_absolute_directory(Path::new("relative")).is_err()); 2450 assert!(open_trusted_output_directory(Path::new("relative")).is_err()); 2451 assert!(open_relative(&root, Path::new("input/child"), ObjectKind::Regular).is_err()); 2452 } 2453 2454 #[test] 2455 fn bounded_streams_keep_exact_prefixes_and_fail_on_the_next_byte() { 2456 let mut reader = LimitReader::new(io::Cursor::new(b"abcd"), 3); 2457 assert_eq!(reader.read(&mut []).unwrap(), 0); 2458 let mut exact = [0; 3]; 2459 reader.read_exact(&mut exact).unwrap(); 2460 assert_eq!(&exact, b"abc"); 2461 assert!(!reader.exceeded()); 2462 assert!(reader.read(&mut [0; 1]).is_err()); 2463 assert!(reader.exceeded()); 2464 let mut writer = HashingLimitWriter::new(3); 2465 writer.write_all(b"abc").unwrap(); 2466 assert_eq!(writer.write(&[]).unwrap(), 0); 2467 writer.flush().unwrap(); 2468 assert!(writer.write_all(b"d").is_err()); 2469 assert!(writer.exceeded()); 2470 let evidence = writer.finalize(); 2471 assert_eq!(evidence.byte_length, 3); 2472 assert_eq!(evidence.sha256, hex::encode(Sha256::digest(b"abc"))); 2473 let directory = TempDir::new().unwrap(); 2474 let path = directory.path().join("input"); 2475 fs::write(&path, b"abcd").unwrap(); 2476 let mut file = File::open(&path).unwrap(); 2477 let mut reader = HashingLimitReader::new(&mut file, 3); 2478 assert_eq!(reader.read(&mut []).unwrap(), 0); 2479 reader.read_exact(&mut exact).unwrap(); 2480 assert!(reader.read(&mut [0; 1]).is_err()); 2481 assert!(reader.exceeded()); 2482 assert_eq!(reader.finalize(), hex::encode(Sha256::digest(b"abc"))); 2483 assert_eq!( 2484 stream_regular(&mut File::open(&path).unwrap(), 3, None) 2485 .unwrap_err() 2486 .kind(), 2487 ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) 2488 ); 2489 } 2490 2491 #[test] 2492 fn archive_names_and_headers_reject_noncanonical_and_conflicting_members() { 2493 for path in [ 2494 b"".as_slice(), 2495 b"/root", 2496 b"a\0b", 2497 b"a\\b", 2498 &[255], 2499 b"a//b", 2500 b"a/../b", 2501 b"./a", 2502 ] { 2503 assert!(validate_archive_path(path, 4, 128).is_err()); 2504 } 2505 validate_archive_path(b"dir/file", 2, 8).unwrap(); 2506 assert!(validate_archive_path(b"dir/file", 1, 8).is_err()); 2507 assert!(validate_archive_path(b"dir/file", 2, 7).is_err()); 2508 let mut names = BTreeMap::new(); 2509 admit_archive_name(&mut names, b"dir/", ArchiveMemberKind::Directory).unwrap(); 2510 admit_archive_name(&mut names, b"dir/file", ArchiveMemberKind::File).unwrap(); 2511 assert!(admit_archive_name(&mut names, b"dir/file", ArchiveMemberKind::File).is_err()); 2512 assert!( 2513 admit_archive_name(&mut names, b"dir/file/child", ArchiveMemberKind::File).is_err() 2514 ); 2515 let mut reverse = BTreeMap::new(); 2516 admit_archive_name(&mut reverse, b"dir/child", ArchiveMemberKind::File).unwrap(); 2517 assert!(admit_archive_name(&mut reverse, b"dir", ArchiveMemberKind::File).is_err()); 2518 let directory = TempDir::new().unwrap(); 2519 let path = directory.path().join("header"); 2520 let canonical = [0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255]; 2521 for index in [0, 3, 4, 8, 9] { 2522 let mut bytes = canonical; 2523 bytes[index] ^= 1; 2524 fs::write(&path, bytes).unwrap(); 2525 let mut file = File::open(&path).unwrap(); 2526 let mut reader = BufReader::new(HashingLimitReader::new(&mut file, 10)); 2527 assert!( 2528 validate_gzip_header(&mut reader, TarGzipPolicy::DeterministicSnapshot).is_err() 2529 ); 2530 } 2531 fs::write(&path, canonical).unwrap(); 2532 let mut file = File::open(&path).unwrap(); 2533 let mut reader = BufReader::new(HashingLimitReader::new(&mut file, 9)); 2534 assert_eq!( 2535 validate_gzip_header(&mut reader, TarGzipPolicy::DeterministicSnapshot) 2536 .unwrap_err() 2537 .kind(), 2538 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes) 2539 ); 2540 } 2541 2542 #[test] 2543 fn hard_maximums_reject_invalid_requests() { 2544 assert_eq!(HARD_MAX_BUFFERED_READ_BYTES, 67_108_864); 2545 assert_eq!(HARD_MAX_STREAM_FILE_BYTES, 17_179_869_184); 2546 assert_eq!(HARD_MAX_TRAVERSAL_ENTRIES, 65_536); 2547 assert_eq!(HARD_MAX_TRAVERSAL_FILES, 65_536); 2548 assert_eq!(HARD_MAX_TRAVERSAL_TOTAL_BYTES, 68_719_476_736); 2549 assert_eq!(HARD_MAX_TRAVERSAL_DEPTH, 64); 2550 assert_eq!(HARD_MAX_PATH_BYTES, 4_096); 2551 assert_eq!(HARD_MAX_ARCHIVE_COMPRESSED_BYTES, 2_147_483_648); 2552 assert_eq!(HARD_MAX_ARCHIVE_EXPANDED_BYTES, 17_179_869_184); 2553 assert_eq!(HARD_MAX_ARCHIVE_MEMBERS, 65_536); 2554 assert_eq!(HARD_MAX_ARCHIVE_MEMBER_BYTES, 17_179_869_184); 2555 assert_eq!(HARD_MAX_ARCHIVE_PAYLOAD_BYTES, 17_179_869_184); 2556 let directory = TempDir::new().expect("tempdir"); 2557 let root = root(&directory); 2558 fs::write(root.join("input"), b"x").expect("input"); 2559 assert_eq!( 2560 read_regular(&root, Path::new("input"), HARD_MAX_BUFFERED_READ_BYTES + 1,) 2561 .expect_err("buffered read hard maximum") 2562 .kind(), 2563 ArtifactIoFailureKind::InvalidRequest 2564 ); 2565 assert_eq!( 2566 hash_regular(&root, Path::new("input"), HARD_MAX_STREAM_FILE_BYTES + 1,) 2567 .expect_err("streaming file hard maximum") 2568 .kind(), 2569 ArtifactIoFailureKind::InvalidRequest 2570 ); 2571 2572 for mutate in [ 2573 |limits: &mut TraversalLimits| limits.max_entries = HARD_MAX_TRAVERSAL_ENTRIES + 1, 2574 |limits: &mut TraversalLimits| limits.max_files = HARD_MAX_TRAVERSAL_FILES + 1, 2575 |limits: &mut TraversalLimits| { 2576 limits.max_total_bytes = HARD_MAX_TRAVERSAL_TOTAL_BYTES + 1 2577 }, 2578 |limits: &mut TraversalLimits| limits.max_file_bytes = HARD_MAX_STREAM_FILE_BYTES + 1, 2579 |limits: &mut TraversalLimits| limits.max_depth = HARD_MAX_TRAVERSAL_DEPTH + 1, 2580 |limits: &mut TraversalLimits| limits.max_path_bytes = HARD_MAX_PATH_BYTES + 1, 2581 ] { 2582 let mut limits = traversal_limits(); 2583 mutate(&mut limits); 2584 assert_eq!( 2585 validate_traversal_limits(limits) 2586 .expect_err("traversal hard maximum") 2587 .kind(), 2588 ArtifactIoFailureKind::InvalidRequest 2589 ); 2590 } 2591 2592 for mutate in [ 2593 |limits: &mut TarGzipLimits| { 2594 limits.max_compressed_bytes = HARD_MAX_ARCHIVE_COMPRESSED_BYTES + 1 2595 }, 2596 |limits: &mut TarGzipLimits| { 2597 limits.max_expanded_bytes = HARD_MAX_ARCHIVE_EXPANDED_BYTES + 1 2598 }, 2599 |limits: &mut TarGzipLimits| limits.max_members = HARD_MAX_ARCHIVE_MEMBERS + 1, 2600 |limits: &mut TarGzipLimits| { 2601 limits.max_member_bytes = HARD_MAX_ARCHIVE_MEMBER_BYTES + 1 2602 }, 2603 |limits: &mut TarGzipLimits| { 2604 limits.max_payload_bytes = HARD_MAX_ARCHIVE_PAYLOAD_BYTES + 1 2605 }, 2606 |limits: &mut TarGzipLimits| limits.max_depth = HARD_MAX_TRAVERSAL_DEPTH + 1, 2607 |limits: &mut TarGzipLimits| limits.max_path_bytes = HARD_MAX_PATH_BYTES + 1, 2608 ] { 2609 let mut limits = archive_limits(); 2610 mutate(&mut limits); 2611 assert_eq!( 2612 validate_archive_limits(limits) 2613 .expect_err("archive hard maximum") 2614 .kind(), 2615 ArtifactIoFailureKind::InvalidRequest 2616 ); 2617 } 2618 } 2619 2620 #[test] 2621 fn bounded_read_hash_and_copy_are_streaming_and_exact() { 2622 let directory = TempDir::new().expect("tempdir"); 2623 let root = root(&directory); 2624 fs::write(root.join("input"), b"01234567").expect("write input"); 2625 2626 assert_eq!( 2627 read_regular(&root, Path::new("input"), 8).expect("read at cap"), 2628 b"01234567" 2629 ); 2630 assert_eq!( 2631 read_regular(&root, Path::new("input"), 7) 2632 .expect_err("read over cap") 2633 .kind(), 2634 ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) 2635 ); 2636 let evidence = hash_regular(&root, Path::new("input"), 8).expect("hash"); 2637 let output_path = root.join("output"); 2638 let copied = copy_regular_to_new_path(&root.join("input"), &output_path, 8) 2639 .expect("create-new copy"); 2640 assert_eq!(evidence, copied); 2641 assert_eq!(fs::read(output_path).expect("output bytes"), b"01234567"); 2642 assert_eq!( 2643 fs::metadata(root.join("output")) 2644 .expect("output metadata") 2645 .permissions() 2646 .mode() 2647 & 0o7777, 2648 0o600 2649 ); 2650 2651 let failed_output = root.join("failed-output"); 2652 let error = copy_regular_to_new_path_impl( 2653 &root.join("input"), 2654 &failed_output, 2655 8, 2656 None, 2657 || { 2658 fs::rename(root.join("input"), root.join("moved-input")).expect("move copy input"); 2659 fs::write(root.join("input"), b"01234567").expect("replace copy input"); 2660 }, 2661 || {}, 2662 ) 2663 .expect_err("copy binding change"); 2664 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2665 assert_eq!( 2666 fs::read(&failed_output).expect("untrusted failed output"), 2667 b"01234567" 2668 ); 2669 fs::remove_file(&failed_output).expect("remove untrusted failed output"); 2670 2671 let open_hardlink_output = root.join("open-hardlink-output"); 2672 let open_hardlink_alias = root.join("open-hardlink-alias"); 2673 let error = copy_regular_to_new_path_impl( 2674 &root.join("input"), 2675 &open_hardlink_output, 2676 8, 2677 None, 2678 || { 2679 fs::hard_link(&open_hardlink_output, &open_hardlink_alias) 2680 .expect("hardlink open output"); 2681 }, 2682 || {}, 2683 ) 2684 .expect_err("open hardlink race"); 2685 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2686 assert_eq!( 2687 fs::read(&open_hardlink_output).expect("untrusted output"), 2688 b"01234567" 2689 ); 2690 assert_eq!( 2691 fs::read(&open_hardlink_alias).expect("hardlink alias"), 2692 b"01234567" 2693 ); 2694 fs::remove_file(&open_hardlink_output).expect("remove untrusted output"); 2695 fs::remove_file(&open_hardlink_alias).expect("remove hardlink alias"); 2696 2697 let installed_hardlink_output = root.join("installed-hardlink-output"); 2698 let installed_hardlink_alias = root.join("installed-hardlink-alias"); 2699 let error = copy_regular_to_new_path_impl( 2700 &root.join("input"), 2701 &installed_hardlink_output, 2702 8, 2703 None, 2704 || {}, 2705 || { 2706 fs::hard_link(&installed_hardlink_output, &installed_hardlink_alias) 2707 .expect("hardlink installed output"); 2708 }, 2709 ) 2710 .expect_err("installed hardlink race"); 2711 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2712 assert_eq!( 2713 fs::read(&installed_hardlink_output).expect("untrusted installed output"), 2714 b"01234567" 2715 ); 2716 assert!(installed_hardlink_alias.exists()); 2717 fs::remove_file(&installed_hardlink_output).expect("remove untrusted installed output"); 2718 fs::remove_file(&installed_hardlink_alias).expect("remove installed hardlink alias"); 2719 2720 let mutated_output = root.join("mutated-output"); 2721 let error = copy_regular_to_new_path_impl( 2722 &root.join("input"), 2723 &mutated_output, 2724 8, 2725 None, 2726 || {}, 2727 || fs::write(&mutated_output, b"87654321").expect("mutate installed output"), 2728 ) 2729 .expect_err("post-sync output mutation"); 2730 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2731 assert_eq!( 2732 fs::read(&mutated_output).expect("untrusted mutated output"), 2733 b"87654321" 2734 ); 2735 fs::remove_file(&mutated_output).expect("remove untrusted mutated output"); 2736 2737 let name_swap_output = root.join("name-swap-output"); 2738 let moved_created_output = root.join("moved-created-output"); 2739 let error = copy_regular_to_new_path_impl( 2740 &root.join("input"), 2741 &name_swap_output, 2742 8, 2743 None, 2744 || { 2745 fs::rename(&name_swap_output, &moved_created_output).expect("move created output"); 2746 fs::write(&name_swap_output, b"unrelated").expect("replacement output"); 2747 }, 2748 || {}, 2749 ) 2750 .expect_err("created name swap"); 2751 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2752 assert_eq!( 2753 fs::read(&name_swap_output).expect("unrelated replacement preserved"), 2754 b"unrelated" 2755 ); 2756 assert_eq!( 2757 fs::read(&moved_created_output).expect("moved untrusted output"), 2758 b"01234567" 2759 ); 2760 fs::remove_file(&name_swap_output).expect("remove replacement output"); 2761 fs::remove_file(&moved_created_output).expect("remove moved output"); 2762 2763 fs::create_dir(root.join("output-parent")).expect("output parent"); 2764 fs::write(root.join("input"), b"01234567").expect("restore input"); 2765 let rebound_output = root.join("output-parent/installed"); 2766 let error = copy_regular_to_new_path_impl( 2767 &root.join("input"), 2768 &rebound_output, 2769 8, 2770 None, 2771 || { 2772 fs::rename(root.join("output-parent"), root.join("old-output-parent")) 2773 .expect("move output parent"); 2774 fs::create_dir(root.join("output-parent")).expect("replace output parent"); 2775 }, 2776 || {}, 2777 ) 2778 .expect_err("output parent replacement"); 2779 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2780 assert!(!rebound_output.exists()); 2781 assert_eq!( 2782 fs::read(root.join("old-output-parent/installed")) 2783 .expect("untrusted output in moved parent"), 2784 b"01234567" 2785 ); 2786 2787 fs::set_permissions(&root, fs::Permissions::from_mode(0o777)) 2788 .expect("make output parent untrusted"); 2789 assert_eq!( 2790 copy_regular_to_new_path(&root.join("input"), &root.join("untrusted-parent"), 8) 2791 .expect_err("writable output parent rejected") 2792 .kind(), 2793 ArtifactIoFailureKind::InvalidRequest 2794 ); 2795 fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) 2796 .expect("restore output parent mode"); 2797 2798 let parent_mode_output = root.join("parent-mode-output"); 2799 let error = copy_regular_to_new_path_impl( 2800 &root.join("input"), 2801 &parent_mode_output, 2802 8, 2803 None, 2804 || {}, 2805 || { 2806 fs::set_permissions(&root, fs::Permissions::from_mode(0o777)) 2807 .expect("change output parent mode"); 2808 }, 2809 ) 2810 .expect_err("output parent security change"); 2811 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2812 assert!(parent_mode_output.exists()); 2813 fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) 2814 .expect("restore output parent mode"); 2815 fs::remove_file(&parent_mode_output).expect("remove untrusted parent-mode output"); 2816 2817 let secure_ancestor = root.join("secure-ancestor"); 2818 let nested_parent = secure_ancestor.join("output-parent"); 2819 fs::create_dir(&secure_ancestor).expect("secure ancestor"); 2820 fs::create_dir(&nested_parent).expect("nested output parent"); 2821 let ancestor_mode_output = nested_parent.join("installed"); 2822 let error = copy_regular_to_new_path_impl( 2823 &root.join("input"), 2824 &ancestor_mode_output, 2825 8, 2826 None, 2827 || {}, 2828 || { 2829 fs::set_permissions(&secure_ancestor, fs::Permissions::from_mode(0o777)) 2830 .expect("change output ancestor mode"); 2831 }, 2832 ) 2833 .expect_err("output ancestor security change"); 2834 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2835 assert!(ancestor_mode_output.exists()); 2836 fs::set_permissions(&secure_ancestor, fs::Permissions::from_mode(0o755)) 2837 .expect("restore output ancestor mode"); 2838 fs::remove_file(&ancestor_mode_output).expect("remove untrusted ancestor-mode output"); 2839 } 2840 2841 #[test] 2842 fn no_follow_admission_rejects_symlink_fifo_and_replacement() { 2843 let directory = TempDir::new().expect("tempdir"); 2844 let root = root(&directory); 2845 fs::write(root.join("source"), b"original").expect("source"); 2846 symlink("source", root.join("link")).expect("symlink"); 2847 assert_eq!( 2848 read_regular(&root, Path::new("link"), 32) 2849 .expect_err("symlink rejected") 2850 .kind(), 2851 ArtifactIoFailureKind::InvalidObject 2852 ); 2853 2854 let fifo = root.join("fifo"); 2855 let request = crate::bounded_process::ProcessRequest::new("/usr/bin/mkfifo") 2856 .arg(fifo.as_os_str()) 2857 .deadline(Duration::from_secs(5)) 2858 .output_limits(1024, 1024); 2859 let output = crate::bounded_process::run(&request).expect("bounded mkfifo"); 2860 assert!(output.status().success()); 2861 assert!( 2862 fs::symlink_metadata(&fifo) 2863 .expect("fifo metadata") 2864 .file_type() 2865 .is_fifo() 2866 ); 2867 assert_eq!( 2868 read_regular(&root, Path::new("fifo"), 32) 2869 .expect_err("fifo rejected") 2870 .kind(), 2871 ArtifactIoFailureKind::InvalidObject 2872 ); 2873 2874 let error = read_regular_impl(&root, Path::new("source"), 32, None, || { 2875 fs::rename(root.join("source"), root.join("old")).expect("move admitted file"); 2876 fs::write(root.join("source"), b"original").expect("replacement"); 2877 }) 2878 .expect_err("replacement rejected"); 2879 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2880 2881 fs::write(root.join("mutable"), b"same-size").expect("mutable"); 2882 let error = read_regular_impl(&root, Path::new("mutable"), 32, None, || { 2883 fs::write(root.join("mutable"), b"new-bytes").expect("in-place mutation"); 2884 }) 2885 .expect_err("in-place mutation rejected"); 2886 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2887 2888 fs::create_dir(root.join("parent")).expect("parent"); 2889 fs::write(root.join("parent/member"), b"member").expect("member"); 2890 let error = read_regular_impl(&root, Path::new("parent/member"), 32, None, || { 2891 fs::rename(root.join("parent"), root.join("old-parent")).expect("move parent"); 2892 fs::create_dir(root.join("parent")).expect("replacement parent"); 2893 fs::write(root.join("parent/member"), b"member").expect("replacement member"); 2894 }) 2895 .expect_err("parent replacement rejected"); 2896 assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2897 } 2898 2899 #[test] 2900 fn traversal_enforces_type_count_byte_and_depth_bounds() { 2901 let directory = TempDir::new().expect("tempdir"); 2902 let root = root(&directory); 2903 fs::create_dir(root.join("a")).expect("a"); 2904 fs::write(root.join("a/one"), b"1").expect("one"); 2905 fs::write(root.join("two"), b"22").expect("two"); 2906 let snapshot = traverse_regular_files(&root, traversal_limits(), &[]).expect("traverse"); 2907 assert_eq!(snapshot.entry_count(), 3); 2908 assert_eq!(snapshot.total_bytes(), 3); 2909 assert_eq!( 2910 snapshot 2911 .files() 2912 .iter() 2913 .map(|file| file.relative_path().to_path_buf()) 2914 .collect::<Vec<_>>(), 2915 [PathBuf::from("a/one"), PathBuf::from("two")] 2916 ); 2917 assert_eq!( 2918 snapshot 2919 .read(&snapshot.files()[0], 1) 2920 .expect("snapshot read"), 2921 b"1" 2922 ); 2923 let (bytes, evidenced) = snapshot 2924 .read_evidenced(&snapshot.files()[0], 1) 2925 .expect("snapshot evidenced read"); 2926 assert_eq!(bytes, b"1"); 2927 assert_eq!( 2928 evidenced, 2929 snapshot 2930 .hash(&snapshot.files()[0], 1) 2931 .expect("snapshot hash") 2932 ); 2933 let output_directory = TempDir::new().expect("snapshot copy output"); 2934 let output_root = output_directory 2935 .path() 2936 .canonicalize() 2937 .expect("canonical snapshot copy output"); 2938 assert_eq!( 2939 snapshot 2940 .copy_to_new_path(&snapshot.files()[0], &output_root.join("copy"), 1) 2941 .expect("snapshot copy"), 2942 evidenced 2943 ); 2944 assert_eq!( 2945 fs::read(output_root.join("copy")).expect("copied bytes"), 2946 b"1" 2947 ); 2948 2949 let late_error = traverse_regular_files_impl(&root, traversal_limits(), &[], || { 2950 fs::write(root.join("late"), b"late").expect("late insertion"); 2951 }) 2952 .expect_err("late insertion is detected"); 2953 assert_eq!(late_error.kind(), ArtifactIoFailureKind::ChangedDuringRead); 2954 fs::remove_file(root.join("late")).expect("remove late insertion"); 2955 2956 fs::create_dir(root.join("excluded")).expect("excluded directory"); 2957 let excluded = traverse_regular_files(&root, traversal_limits(), &["excluded"]) 2958 .expect("excluded snapshot"); 2959 fs::rename(root.join("excluded"), root.join("old-excluded")) 2960 .expect("move excluded directory"); 2961 fs::create_dir(root.join("excluded")).expect("replace excluded directory"); 2962 assert_eq!( 2963 excluded 2964 .revalidate() 2965 .expect_err("excluded binding change") 2966 .kind(), 2967 ArtifactIoFailureKind::ChangedDuringRead 2968 ); 2969 2970 let mut one_entry = traversal_limits(); 2971 one_entry.max_entries = 1; 2972 assert_eq!( 2973 traverse_regular_files(&root, one_entry, &[]) 2974 .expect_err("entry cap") 2975 .kind(), 2976 ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalEntries) 2977 ); 2978 let mut one_file = traversal_limits(); 2979 one_file.max_files = 1; 2980 assert_eq!( 2981 traverse_regular_files(&root, one_file, &[]) 2982 .expect_err("file cap") 2983 .kind(), 2984 ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalFiles) 2985 ); 2986 let mut one_file_byte = traversal_limits(); 2987 one_file_byte.max_file_bytes = 1; 2988 assert_eq!( 2989 traverse_regular_files(&root, one_file_byte, &[]) 2990 .expect_err("per-file cap") 2991 .kind(), 2992 ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) 2993 ); 2994 let mut short_path = traversal_limits(); 2995 short_path.max_path_bytes = 2; 2996 assert_eq!( 2997 traverse_regular_files(&root, short_path, &[]) 2998 .expect_err("path cap") 2999 .kind(), 3000 ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalPathBytes) 3001 ); 3002 let mut two_bytes = traversal_limits(); 3003 two_bytes.max_total_bytes = 2; 3004 assert_eq!( 3005 traverse_regular_files(&root, two_bytes, &[]) 3006 .expect_err("byte cap") 3007 .kind(), 3008 ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalTotalBytes) 3009 ); 3010 let mut no_depth = traversal_limits(); 3011 no_depth.max_depth = 1; 3012 fs::create_dir(root.join("a/deep")).expect("deep"); 3013 fs::write(root.join("a/deep/file"), b"x").expect("deep file"); 3014 assert_eq!( 3015 traverse_regular_files(&root, no_depth, &[]) 3016 .expect_err("depth cap") 3017 .kind(), 3018 ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalDepth) 3019 ); 3020 } 3021 3022 #[test] 3023 fn retained_traversal_rejects_root_directory_member_and_file_replacement() { 3024 for case in [ 3025 "root", 3026 "directory", 3027 "mode", 3028 "file", 3029 "members", 3030 "member-name", 3031 ] { 3032 let directory = TempDir::new().unwrap(); 3033 let base = root(&directory); 3034 let tree = base.join("tree"); 3035 fs::create_dir_all(tree.join("nested")).unwrap(); 3036 fs::write(tree.join("nested/file"), b"original").unwrap(); 3037 let snapshot = traverse_regular_files(&tree, traversal_limits(), &[]).unwrap(); 3038 snapshot.revalidate().unwrap(); 3039 match case { 3040 "root" => { 3041 fs::rename(&tree, base.join("old-tree")).unwrap(); 3042 fs::create_dir_all(tree.join("nested")).unwrap(); 3043 fs::write(tree.join("nested/file"), b"original").unwrap(); 3044 } 3045 "directory" => { 3046 fs::rename(tree.join("nested"), base.join("old-nested")).unwrap(); 3047 fs::create_dir(tree.join("nested")).unwrap(); 3048 fs::write(tree.join("nested/file"), b"original").unwrap(); 3049 } 3050 "mode" => { 3051 fs::set_permissions(tree.join("nested"), fs::Permissions::from_mode(0o700)) 3052 .unwrap() 3053 } 3054 "file" => fs::write(tree.join("nested/file"), b"different length").unwrap(), 3055 "members" => fs::write(tree.join("nested/added"), b"new").unwrap(), 3056 "member-name" => { 3057 fs::rename(tree.join("nested/file"), tree.join("nested/renamed")).unwrap() 3058 } 3059 _ => unreachable!(), 3060 } 3061 assert_eq!( 3062 snapshot.revalidate().unwrap_err().kind(), 3063 ArtifactIoFailureKind::ChangedDuringRead, 3064 "{case}" 3065 ); 3066 } 3067 } 3068 3069 fn write_archive(path: &Path, members: &[(&str, EntryType, &[u8])]) { 3070 let output = File::create(path).expect("archive output"); 3071 let encoder = GzBuilder::new().write(output, Compression::fast()); 3072 let mut builder = Builder::new(encoder); 3073 for (name, kind, contents) in members { 3074 let mut header = Header::new_gnu(); 3075 header.set_entry_type(*kind); 3076 header.set_mode(0o644); 3077 header.set_size(contents.len() as u64); 3078 header.set_cksum(); 3079 builder 3080 .append_data(&mut header, name, *contents) 3081 .expect("archive member"); 3082 } 3083 let encoder = builder.into_inner().expect("finish tar"); 3084 encoder.finish().expect("finish gzip"); 3085 } 3086 3087 fn write_raw_named_archive(path: &Path, name: &[u8], contents: &[u8]) { 3088 assert!(name.len() < 100); 3089 let output = File::create(path).expect("archive output"); 3090 let encoder = GzBuilder::new().write(output, Compression::fast()); 3091 let mut builder = Builder::new(encoder); 3092 let mut header = Header::new_gnu(); 3093 header.as_mut_bytes()[..name.len()].copy_from_slice(name); 3094 header.set_entry_type(EntryType::Regular); 3095 header.set_mode(0o644); 3096 header.set_size(contents.len() as u64); 3097 header.set_cksum(); 3098 builder 3099 .append(&header, contents) 3100 .expect("raw archive member"); 3101 let encoder = builder.into_inner().expect("finish tar"); 3102 encoder.finish().expect("finish gzip"); 3103 } 3104 3105 fn write_single_terminator_archive(path: &Path) { 3106 let mut header = Header::new_gnu(); 3107 header.set_path("one").expect("path"); 3108 header.set_entry_type(EntryType::Regular); 3109 header.set_mode(0o644); 3110 header.set_size(3); 3111 header.set_cksum(); 3112 let mut bytes = Vec::from(header.as_bytes()); 3113 bytes.extend_from_slice(b"one"); 3114 bytes.resize(1536, 0); 3115 let output = File::create(path).expect("archive output"); 3116 let mut encoder = GzBuilder::new().write(output, Compression::fast()); 3117 encoder.write_all(&bytes).expect("write raw tar"); 3118 encoder.finish().expect("finish gzip"); 3119 } 3120 3121 #[test] 3122 fn tar_gzip_admission_is_parse_only_and_bounded() { 3123 let directory = TempDir::new().expect("tempdir"); 3124 let root = root(&directory); 3125 let valid = root.join("valid.tar.gz"); 3126 write_archive(&valid, &[("bin/tool", EntryType::Regular, b"payload")]); 3127 let evidence = admit_tar_gzip_path(&valid, archive_limits()).expect("valid archive"); 3128 assert_eq!(evidence.member_count, 1); 3129 assert_eq!(evidence.payload_bytes, 7); 3130 let valid_bytes = fs::read(&valid).expect("valid archive bytes"); 3131 assert_eq!(evidence.compressed.byte_length, valid_bytes.len() as u64); 3132 assert_eq!( 3133 evidence.compressed.sha256, 3134 hex::encode(Sha256::digest(&valid_bytes)) 3135 ); 3136 assert!(!root.join("bin").exists()); 3137 3138 for (name, flags) in [ 3139 ("optional-header.tar.gz", 0x08), 3140 ("reserved-header.tar.gz", 0x20), 3141 ] { 3142 let path = root.join(name); 3143 let mut bytes = valid_bytes.clone(); 3144 bytes[3] = flags; 3145 fs::write(&path, bytes).expect("write rejected gzip header"); 3146 assert_eq!( 3147 admit_tar_gzip_path(&path, archive_limits()) 3148 .expect_err("non-minimal gzip header rejected") 3149 .kind(), 3150 ArtifactIoFailureKind::InvalidObject 3151 ); 3152 } 3153 3154 let symlink_archive = root.join("symlink.tar.gz"); 3155 write_archive( 3156 &symlink_archive, 3157 &[("escape", EntryType::Symlink, b"target")], 3158 ); 3159 assert_eq!( 3160 admit_tar_gzip_path(&symlink_archive, archive_limits()) 3161 .expect_err("special member rejected") 3162 .kind(), 3163 ArtifactIoFailureKind::InvalidObject 3164 ); 3165 3166 let escape_archive = root.join("escape.tar.gz"); 3167 write_raw_named_archive(&escape_archive, b"../escape", b"payload"); 3168 assert_eq!( 3169 admit_tar_gzip_path(&escape_archive, archive_limits()) 3170 .expect_err("escape rejected") 3171 .kind(), 3172 ArtifactIoFailureKind::InvalidObject 3173 ); 3174 3175 let bomb = root.join("bomb.tar.gz"); 3176 write_archive(&bomb, &[("large", EntryType::Regular, &[0_u8; 8192])]); 3177 let mut bounded = archive_limits(); 3178 bounded.max_expanded_bytes = 1024; 3179 assert_eq!( 3180 admit_tar_gzip_path(&bomb, bounded) 3181 .expect_err("expanded cap") 3182 .kind(), 3183 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveExpandedBytes) 3184 ); 3185 3186 let mut compressed = archive_limits(); 3187 compressed.max_compressed_bytes = fs::metadata(&valid).expect("valid metadata").len() - 1; 3188 assert_eq!( 3189 admit_tar_gzip_path(&valid, compressed) 3190 .expect_err("compressed cap") 3191 .kind(), 3192 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes) 3193 ); 3194 3195 let two_members = root.join("two-members.tar.gz"); 3196 write_archive( 3197 &two_members, 3198 &[ 3199 ("one", EntryType::Regular, b"1"), 3200 ("two", EntryType::Regular, b"2"), 3201 ], 3202 ); 3203 let mut one_member = archive_limits(); 3204 one_member.max_members = 1; 3205 assert_eq!( 3206 admit_tar_gzip_path(&two_members, one_member) 3207 .expect_err("member count cap") 3208 .kind(), 3209 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveMembers) 3210 ); 3211 3212 let mut one_member_byte = archive_limits(); 3213 one_member_byte.max_member_bytes = 1; 3214 assert_eq!( 3215 admit_tar_gzip_path(&valid, one_member_byte) 3216 .expect_err("member byte cap") 3217 .kind(), 3218 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveMemberBytes) 3219 ); 3220 3221 let mut one_payload_byte = archive_limits(); 3222 one_payload_byte.max_payload_bytes = 1; 3223 assert_eq!( 3224 admit_tar_gzip_path(&two_members, one_payload_byte) 3225 .expect_err("payload cap") 3226 .kind(), 3227 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchivePayloadBytes) 3228 ); 3229 3230 let deep = root.join("deep.tar.gz"); 3231 write_archive(&deep, &[("a/b/c/d/e", EntryType::Regular, b"x")]); 3232 assert_eq!( 3233 admit_tar_gzip_path(&deep, archive_limits()) 3234 .expect_err("archive depth cap") 3235 .kind(), 3236 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveDepth) 3237 ); 3238 3239 let long_path = root.join("long-path.tar.gz"); 3240 write_archive( 3241 &long_path, 3242 &[("a-long-member-name", EntryType::Regular, b"x")], 3243 ); 3244 let mut short_path = archive_limits(); 3245 short_path.max_path_bytes = 4; 3246 assert_eq!( 3247 admit_tar_gzip_path(&long_path, short_path) 3248 .expect_err("archive path cap") 3249 .kind(), 3250 ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchivePathBytes) 3251 ); 3252 } 3253 3254 #[test] 3255 fn archive_rejects_duplicates_prefix_conflicts_and_concatenation() { 3256 let directory = TempDir::new().expect("tempdir"); 3257 let root = root(&directory); 3258 let duplicate = root.join("duplicate.tar.gz"); 3259 write_archive( 3260 &duplicate, 3261 &[ 3262 ("same", EntryType::Regular, b"one"), 3263 ("same", EntryType::Regular, b"two"), 3264 ], 3265 ); 3266 assert_eq!( 3267 admit_tar_gzip_path(&duplicate, archive_limits()) 3268 .expect_err("duplicate rejected") 3269 .kind(), 3270 ArtifactIoFailureKind::InvalidObject 3271 ); 3272 3273 let prefix = root.join("prefix.tar.gz"); 3274 write_archive( 3275 &prefix, 3276 &[ 3277 ("a", EntryType::Regular, b"one"), 3278 ("a/b", EntryType::Regular, b"two"), 3279 ], 3280 ); 3281 assert_eq!( 3282 admit_tar_gzip_path(&prefix, archive_limits()) 3283 .expect_err("prefix rejected") 3284 .kind(), 3285 ArtifactIoFailureKind::InvalidObject 3286 ); 3287 3288 let first = root.join("first.tar.gz"); 3289 let second = root.join("second.tar.gz"); 3290 write_archive(&first, &[("one", EntryType::Regular, b"one")]); 3291 write_archive(&second, &[("two", EntryType::Regular, b"two")]); 3292 let second_bytes = fs::read(&second).expect("second bytes"); 3293 OpenOptions::new() 3294 .append(true) 3295 .open(&first) 3296 .expect("append archive") 3297 .write_all(&second_bytes) 3298 .expect("concatenate"); 3299 assert_eq!( 3300 admit_tar_gzip_path(&first, archive_limits()) 3301 .expect_err("concatenated gzip rejected") 3302 .kind(), 3303 ArtifactIoFailureKind::MalformedArchive 3304 ); 3305 3306 let trailing_slash = root.join("trailing-slash.tar.gz"); 3307 write_raw_named_archive(&trailing_slash, b"file/", b"payload"); 3308 assert_eq!( 3309 admit_tar_gzip_path(&trailing_slash, archive_limits()) 3310 .expect_err("file trailing slash rejected") 3311 .kind(), 3312 ArtifactIoFailureKind::InvalidObject 3313 ); 3314 3315 let truncated = root.join("truncated-terminator.tar.gz"); 3316 write_single_terminator_archive(&truncated); 3317 assert_eq!( 3318 admit_tar_gzip_path(&truncated, archive_limits()) 3319 .expect_err("single terminator rejected") 3320 .kind(), 3321 ArtifactIoFailureKind::MalformedArchive 3322 ); 3323 3324 let malformed = root.join("malformed.tar.gz"); 3325 fs::write(&malformed, b"not a gzip stream").expect("malformed archive"); 3326 assert_eq!( 3327 admit_tar_gzip_path(&malformed, archive_limits()) 3328 .expect_err("malformed gzip rejected") 3329 .kind(), 3330 ArtifactIoFailureKind::MalformedArchive 3331 ); 3332 3333 let gzip_truncated = root.join("gzip-truncated.tar.gz"); 3334 let mut truncated_bytes = fs::read(&second).expect("archive bytes"); 3335 truncated_bytes.truncate(truncated_bytes.len() - 4); 3336 fs::write(&gzip_truncated, truncated_bytes).expect("truncated gzip"); 3337 assert_eq!( 3338 admit_tar_gzip_path(&gzip_truncated, archive_limits()) 3339 .expect_err("truncated gzip rejected") 3340 .kind(), 3341 ArtifactIoFailureKind::MalformedArchive 3342 ); 3343 3344 for (index, name) in [b"/absolute".as_slice(), b"./dot", b"a//b", b"a\\b"] 3345 .into_iter() 3346 .enumerate() 3347 { 3348 let path = root.join(format!("escape-{index}.tar.gz")); 3349 write_raw_named_archive(&path, name, b"payload"); 3350 assert_eq!( 3351 admit_tar_gzip_path(&path, archive_limits()) 3352 .expect_err("nonportable path rejected") 3353 .kind(), 3354 ArtifactIoFailureKind::InvalidObject 3355 ); 3356 } 3357 3358 for (index, kind) in [ 3359 EntryType::Symlink, 3360 EntryType::Link, 3361 EntryType::Fifo, 3362 EntryType::Char, 3363 EntryType::Block, 3364 EntryType::Continuous, 3365 EntryType::GNULongName, 3366 EntryType::GNULongLink, 3367 EntryType::GNUSparse, 3368 EntryType::XGlobalHeader, 3369 EntryType::XHeader, 3370 EntryType::new(b'Z'), 3371 ] 3372 .into_iter() 3373 .enumerate() 3374 { 3375 let path = root.join(format!("special-{index}.tar.gz")); 3376 write_archive(&path, &[("special", kind, b"")]); 3377 assert!( 3378 admit_tar_gzip_path(&path, archive_limits()).is_err(), 3379 "special archive member was admitted" 3380 ); 3381 } 3382 } 3383 3384 #[test] 3385 fn hardlinked_regular_inputs_are_rejected() { 3386 let directory = TempDir::new().expect("tempdir"); 3387 let root = root(&directory); 3388 fs::write(root.join("original"), b"bytes").expect("original"); 3389 fs::hard_link(root.join("original"), root.join("alias")).expect("hard link"); 3390 assert_eq!( 3391 read_regular(&root, Path::new("original"), 8) 3392 .expect_err("hardlink rejected") 3393 .kind(), 3394 ArtifactIoFailureKind::InvalidObject 3395 ); 3396 assert_eq!( 3397 traverse_regular_files(&root, traversal_limits(), &[]) 3398 .expect_err("traversal hardlink rejected") 3399 .kind(), 3400 ArtifactIoFailureKind::InvalidObject 3401 ); 3402 } 3403 3404 #[test] 3405 fn diagnostics_are_redacted() { 3406 let error = read_regular( 3407 Path::new("/definitely-not-present-sensitive-root"), 3408 Path::new("secret-token"), 3409 1, 3410 ) 3411 .expect_err("missing input"); 3412 let diagnostic = format!("{error}"); 3413 assert!(!diagnostic.contains("sensitive")); 3414 assert!(!diagnostic.contains("secret")); 3415 } 3416 } 3417 3418 #[cfg(all(test, unix))] 3419 mod step_294_tests { 3420 use std::fs::{self, File}; 3421 use std::io::Write as _; 3422 use std::os::unix::fs::PermissionsExt as _; 3423 use std::path::{Path, PathBuf}; 3424 3425 use flate2::{Compression, GzBuilder}; 3426 use sha2::{Digest as _, Sha256}; 3427 use tar::{Builder, EntryType, Header}; 3428 use tempfile::TempDir; 3429 3430 use super::*; 3431 3432 const REVIEWED_FLATE2_LOCK: &str = concat!( 3433 "name = \"flate2\"\n", 3434 "version = \"1.1.9\"\n", 3435 "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", 3436 "checksum = \"843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c\"", 3437 ); 3438 const REVIEWED_MINIZ_OXIDE_LOCK: &str = concat!( 3439 "name = \"miniz_oxide\"\n", 3440 "version = \"0.8.9\"\n", 3441 "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", 3442 "checksum = \"1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316\"", 3443 ); 3444 const CANONICAL_ARCHIVE_BYTE_LENGTH: u64 = 147; 3445 const CANONICAL_ARCHIVE_SHA256: &str = 3446 "6073c70a98ff9ab610085ed74b7886811da3fe64e9ec2771464300a94befd0fb"; 3447 3448 fn root(directory: &TempDir) -> PathBuf { 3449 directory 3450 .path() 3451 .canonicalize() 3452 .expect("canonical Step 294 test root") 3453 } 3454 3455 fn trusted_tempdir(prefix: &str) -> TempDir { 3456 let current = std::env::current_dir() 3457 .expect("Step 294 current directory") 3458 .canonicalize() 3459 .expect("canonical Step 294 current directory"); 3460 open_trusted_output_directory(¤t).expect("trusted Step 294 current directory"); 3461 let mut builder = tempfile::Builder::new(); 3462 builder 3463 .prefix(prefix) 3464 .permissions(fs::Permissions::from_mode(0o700)); 3465 let directory = builder 3466 .tempdir_in(current) 3467 .expect("private Step 294 tempdir"); 3468 let canonical = root(&directory); 3469 let (descriptor, _) = 3470 open_trusted_output_directory(&canonical).expect("trusted private Step 294 tempdir"); 3471 let actual = identity(&descriptor).expect("Step 294 tempdir identity"); 3472 assert_eq!(actual.owner, rustix::process::geteuid().as_raw()); 3473 assert_eq!(permission_mode(actual), 0o700); 3474 directory 3475 } 3476 3477 fn archive_limits() -> TarGzipLimits { 3478 TarGzipLimits { 3479 max_compressed_bytes: 64 * 1024, 3480 max_expanded_bytes: 128 * 1024, 3481 max_members: 8, 3482 max_member_bytes: 32 * 1024, 3483 max_payload_bytes: 64 * 1024, 3484 max_depth: 4, 3485 max_path_bytes: 128, 3486 } 3487 } 3488 3489 fn write_deterministic_archive(path: &Path, payload: &[u8], extra_terminator: bool) { 3490 let output = File::create(path).expect("Step 294 archive output"); 3491 let encoder = GzBuilder::new() 3492 .mtime(0) 3493 .operating_system(255) 3494 .write(output, Compression::new(CANONICAL_GZIP_COMPRESSION_LEVEL)); 3495 let mut builder = Builder::new(encoder); 3496 3497 let mut directory = Header::new_gnu(); 3498 directory 3499 .set_path("snapshot/") 3500 .expect("Step 294 directory path"); 3501 directory.set_entry_type(EntryType::Directory); 3502 directory.set_mode(0o755); 3503 directory.set_uid(0); 3504 directory.set_gid(0); 3505 directory.set_mtime(0); 3506 directory.set_size(0); 3507 directory.set_cksum(); 3508 builder 3509 .append(&directory, &[][..]) 3510 .expect("Step 294 directory member"); 3511 3512 let mut file = Header::new_gnu(); 3513 file.set_path("snapshot/data.json") 3514 .expect("Step 294 file path"); 3515 file.set_entry_type(EntryType::Regular); 3516 file.set_mode(0o644); 3517 file.set_uid(0); 3518 file.set_gid(0); 3519 file.set_mtime(0); 3520 file.set_size(payload.len() as u64); 3521 file.set_cksum(); 3522 builder 3523 .append(&file, payload) 3524 .expect("Step 294 file member"); 3525 3526 let mut encoder = builder.into_inner().expect("finish Step 294 tar"); 3527 if extra_terminator { 3528 encoder 3529 .write_all(&[0_u8; TAR_BLOCK_BYTES]) 3530 .expect("append noncanonical tar terminator"); 3531 } 3532 encoder.finish().expect("finish Step 294 gzip"); 3533 } 3534 3535 #[test] 3536 fn canonical_tar_gzip_is_exactly_reencoded_before_admission() { 3537 let directory = trusted_tempdir("radroots-step-294-source-"); 3538 let root = root(&directory); 3539 let canonical = root.join("canonical.tar.gz"); 3540 let noncanonical = root.join("noncanonical.tar.gz"); 3541 let payload = br#"{"schema":"radroots.step-294.test.v1"}"#; 3542 write_deterministic_archive(&canonical, payload, false); 3543 write_deterministic_archive(&noncanonical, payload, true); 3544 3545 let evidence = admit_tar_gzip_relative( 3546 &root, 3547 Path::new("canonical.tar.gz"), 3548 archive_limits(), 3549 None, 3550 TarGzipPolicy::DeterministicSnapshot, 3551 ) 3552 .expect("exact canonical archive admitted"); 3553 let cargo_lock = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")); 3554 assert_eq!(cargo_lock.matches(REVIEWED_FLATE2_LOCK).count(), 1); 3555 assert_eq!(cargo_lock.matches(REVIEWED_MINIZ_OXIDE_LOCK).count(), 1); 3556 assert_eq!( 3557 evidence.compressed.byte_length, 3558 CANONICAL_ARCHIVE_BYTE_LENGTH 3559 ); 3560 assert_eq!(evidence.compressed.sha256, CANONICAL_ARCHIVE_SHA256); 3561 assert_eq!(evidence.member_count, 2); 3562 assert_eq!(evidence.payload_bytes, payload.len() as u64); 3563 assert_eq!( 3564 evidence.compressed.sha256, 3565 hex::encode(Sha256::digest( 3566 fs::read(&canonical).expect("canonical bytes") 3567 )) 3568 ); 3569 3570 admit_tar_gzip_path(&noncanonical, archive_limits()) 3571 .expect("generic parser accepts safe extended terminator"); 3572 assert_eq!( 3573 admit_tar_gzip_relative( 3574 &root, 3575 Path::new("noncanonical.tar.gz"), 3576 archive_limits(), 3577 None, 3578 TarGzipPolicy::DeterministicSnapshot, 3579 ) 3580 .expect_err("byte-noncanonical archive rejected") 3581 .kind(), 3582 ArtifactIoFailureKind::InvalidObject 3583 ); 3584 } 3585 3586 #[test] 3587 fn materialization_retains_exact_member_and_parent_bindings() { 3588 let directory = trusted_tempdir("radroots-step-294-source-"); 3589 let source_root = root(&directory); 3590 let archive = source_root.join("canonical.tar.gz"); 3591 let payload = br#"{"snapshot":"immutable"}"#; 3592 write_deterministic_archive(&archive, payload, false); 3593 3594 let parent = trusted_tempdir("radroots-step-294-materialization-"); 3595 let parent_root = root(&parent); 3596 let materialized = materialize_tar_gzip_relative( 3597 &source_root, 3598 Path::new("canonical.tar.gz"), 3599 &parent_root, 3600 archive_limits(), 3601 None, 3602 TarGzipPolicy::DeterministicSnapshot, 3603 ) 3604 .expect("descriptor-bound materialization"); 3605 let snapshot = materialized.snapshot(); 3606 assert_eq!(snapshot.entry_count(), 2); 3607 assert_eq!(snapshot.files().len(), 1); 3608 assert_eq!(snapshot.total_bytes(), payload.len() as u64); 3609 assert_eq!( 3610 snapshot 3611 .hash(&snapshot.files()[0], payload.len() as u64) 3612 .expect("bound member hash") 3613 .sha256, 3614 hex::encode(Sha256::digest(payload)) 3615 ); 3616 materialized 3617 .revalidate() 3618 .expect("unchanged materialization"); 3619 3620 fs::write(materialized.root().join("snapshot/data.json"), b"changed") 3621 .expect("mutate materialized member"); 3622 assert_eq!( 3623 materialized 3624 .revalidate() 3625 .expect_err("member mutation invalidates materialization") 3626 .kind(), 3627 ArtifactIoFailureKind::ChangedDuringRead 3628 ); 3629 } 3630 3631 #[test] 3632 fn materialization_rejects_parent_root_and_ancestor_rebinding() { 3633 let source = trusted_tempdir("radroots-materialization-source-"); 3634 let source_root = root(&source); 3635 write_deterministic_archive(&source_root.join("archive.tar.gz"), b"immutable", false); 3636 for case in [ 3637 "parent-mode", 3638 "root-mode", 3639 "root-replacement", 3640 "ancestor-replacement", 3641 "ancestor-mode", 3642 ] { 3643 let parent = trusted_tempdir("radroots-materialization-parent-"); 3644 let base = root(&parent); 3645 let ancestor = base.join("ancestor"); 3646 let output = ancestor.join("output"); 3647 fs::create_dir_all(&output).unwrap(); 3648 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700)).unwrap(); 3649 fs::set_permissions(&output, fs::Permissions::from_mode(0o700)).unwrap(); 3650 let materialized = materialize_tar_gzip_relative( 3651 &source_root, 3652 Path::new("archive.tar.gz"), 3653 &output, 3654 archive_limits(), 3655 None, 3656 TarGzipPolicy::DeterministicSnapshot, 3657 ) 3658 .unwrap(); 3659 materialized.revalidate().unwrap(); 3660 match case { 3661 "parent-mode" => { 3662 fs::set_permissions(&output, fs::Permissions::from_mode(0o750)).unwrap() 3663 } 3664 "root-mode" => { 3665 fs::set_permissions(materialized.root(), fs::Permissions::from_mode(0o750)) 3666 .unwrap() 3667 } 3668 "root-replacement" => { 3669 fs::rename(materialized.root(), base.join("retained-root")).unwrap(); 3670 fs::create_dir(materialized.root()).unwrap(); 3671 fs::set_permissions(materialized.root(), fs::Permissions::from_mode(0o700)) 3672 .unwrap(); 3673 } 3674 "ancestor-replacement" => { 3675 fs::rename(&ancestor, base.join("retained-ancestor")).unwrap(); 3676 fs::create_dir(&ancestor).unwrap(); 3677 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700)).unwrap(); 3678 fs::rename(base.join("retained-ancestor/output"), &output).unwrap(); 3679 } 3680 "ancestor-mode" => { 3681 fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o750)).unwrap() 3682 } 3683 _ => unreachable!(), 3684 } 3685 assert_eq!( 3686 materialized.revalidate().unwrap_err().kind(), 3687 ArtifactIoFailureKind::ChangedDuringRead, 3688 "{case}" 3689 ); 3690 } 3691 } 3692 }