exact_tree_archive.rs (21850B)
1 use std::{ 2 collections::BTreeSet, 3 fs, 4 io::{Cursor, Read as _}, 5 path::{Component, Path}, 6 process::{Command, Stdio}, 7 }; 8 9 use sha2::{Digest as _, Sha256}; 10 use tar::{Builder, EntryType, Header}; 11 12 const MAX_TREE_LIST_BYTES: usize = 8 * 1024 * 1024; 13 const MAX_SOURCE_MEMBER_BYTES: usize = 64 * 1024 * 1024; 14 const MAX_SOURCE_TREE_BYTES: u64 = 1024 * 1024 * 1024; 15 const MAX_SOURCE_MEMBERS: usize = 65_536; 16 const MAX_USTAR_PATH_BYTES: usize = 255; 17 const ARCHIVE_MODE: u32 = 0o644; 18 19 #[derive(Clone, Debug, Eq, PartialEq)] 20 pub(crate) struct ExactTreeArchiveEvidence { 21 pub(crate) byte_length: u64, 22 pub(crate) sha256: String, 23 pub(crate) members: u64, 24 pub(crate) payload_bytes: u64, 25 } 26 27 #[derive(Debug)] 28 struct TreeMember { 29 path: String, 30 object_id: String, 31 mode: u32, 32 } 33 34 #[derive(Debug)] 35 struct WrittenMember { 36 path: String, 37 mode: u32, 38 byte_length: u64, 39 sha256: String, 40 } 41 42 pub(crate) fn commit_timestamp(root: &Path, revision: &str) -> Result<u64, String> { 43 validate_root_and_revision(root, revision)?; 44 let output = git_output(root, &["show", "-s", "--format=%ct", revision], 64)?; 45 let value = std::str::from_utf8(&output) 46 .map_err(|_| "exact-tree commit timestamp is not UTF-8".to_owned())? 47 .trim() 48 .parse::<u64>() 49 .map_err(|_| "exact-tree commit timestamp is invalid".to_owned())?; 50 if value == 0 { 51 Err("exact-tree commit timestamp is invalid".to_owned()) 52 } else { 53 Ok(value) 54 } 55 } 56 57 pub(crate) fn create( 58 root: &Path, 59 revision: &str, 60 output: &Path, 61 mtime: u64, 62 ) -> Result<ExactTreeArchiveEvidence, String> { 63 validate_root_and_revision(root, revision)?; 64 let output_parent = output 65 .parent() 66 .ok_or_else(|| "exact-tree archive output is invalid".to_owned())?; 67 if !output.is_absolute() 68 || fs::canonicalize(output_parent) 69 .map_err(|_| "exact-tree archive output parent is invalid".to_owned())? 70 != output_parent 71 || mtime == 0 72 || output.exists() 73 { 74 return Err("exact-tree archive request is invalid".to_owned()); 75 } 76 let members = tree_members(root, revision)?; 77 let output_file = fs::OpenOptions::new() 78 .create_new(true) 79 .write(true) 80 .open(output) 81 .map_err(|_| "exact-tree archive output could not be created".to_owned())?; 82 let mut archive = Builder::new(output_file); 83 let mut payload_bytes = 0_u64; 84 let mut written = Vec::with_capacity(members.len()); 85 for member in &members { 86 let contents = git_output( 87 root, 88 &["cat-file", "blob", member.object_id.as_str()], 89 MAX_SOURCE_MEMBER_BYTES, 90 )?; 91 payload_bytes = payload_bytes 92 .checked_add(contents.len() as u64) 93 .ok_or_else(|| "exact-tree archive payload is too large".to_owned())?; 94 if payload_bytes > MAX_SOURCE_TREE_BYTES { 95 return Err("exact-tree archive payload is too large".to_owned()); 96 } 97 let mut header = Header::new_ustar(); 98 header 99 .set_path(&member.path) 100 .map_err(|_| "exact-tree archive path is not representable in ustar".to_owned())?; 101 header.set_entry_type(EntryType::Regular); 102 header.set_size(contents.len() as u64); 103 header.set_mode(member.mode); 104 header.set_uid(0); 105 header.set_gid(0); 106 header.set_mtime(mtime); 107 header 108 .set_username("") 109 .map_err(|_| "exact-tree archive username is invalid".to_owned())?; 110 header 111 .set_groupname("") 112 .map_err(|_| "exact-tree archive group name is invalid".to_owned())?; 113 header.set_cksum(); 114 archive 115 .append(&header, Cursor::new(&contents)) 116 .map_err(|_| "exact-tree archive member could not be written".to_owned())?; 117 written.push(WrittenMember { 118 path: member.path.clone(), 119 mode: member.mode, 120 byte_length: contents.len() as u64, 121 sha256: hex::encode(Sha256::digest(&contents)), 122 }); 123 } 124 let output_file = archive 125 .into_inner() 126 .map_err(|_| "exact-tree archive could not be finalized".to_owned())?; 127 output_file 128 .sync_all() 129 .map_err(|_| "exact-tree archive could not be synchronized".to_owned())?; 130 set_mode(output)?; 131 let bytes = fs::read(output).map_err(|_| "exact-tree archive could not be read".to_owned())?; 132 validate_archive_bytes(&bytes, &written, mtime)?; 133 Ok(ExactTreeArchiveEvidence { 134 byte_length: bytes.len() as u64, 135 sha256: hex::encode(Sha256::digest(&bytes)), 136 members: written.len() as u64, 137 payload_bytes, 138 }) 139 } 140 141 pub(crate) fn read_blob( 142 root: &Path, 143 revision: &str, 144 path: &str, 145 maximum: usize, 146 ) -> Result<Vec<u8>, String> { 147 if maximum == 0 || maximum > MAX_SOURCE_MEMBER_BYTES || !valid_archive_path(path) { 148 return Err("exact-tree blob request is invalid".to_owned()); 149 } 150 validate_root_and_revision(root, revision)?; 151 let member = tree_members(root, revision)? 152 .into_iter() 153 .find(|member| member.path == path) 154 .ok_or_else(|| "exact-tree blob is absent".to_owned())?; 155 git_output(root, &["cat-file", "blob", &member.object_id], maximum) 156 } 157 158 fn validate_root_and_revision(root: &Path, revision: &str) -> Result<(), String> { 159 if !root.is_absolute() 160 || fs::canonicalize(root).map_err(|_| "exact-tree root is invalid".to_owned())? != root 161 || !valid_lower_hex(revision, 40) 162 { 163 return Err("exact-tree root or revision is invalid".to_owned()); 164 } 165 let object = format!("{revision}^{{commit}}"); 166 git_status(root, &["cat-file", "-e", &object]) 167 .map_err(|_| "exact-tree revision is not a commit".to_owned()) 168 } 169 170 fn tree_members(root: &Path, revision: &str) -> Result<Vec<TreeMember>, String> { 171 let bytes = git_output( 172 root, 173 &["ls-tree", "-rz", "--full-tree", revision], 174 MAX_TREE_LIST_BYTES, 175 )?; 176 let mut members = Vec::new(); 177 let mut paths = BTreeSet::new(); 178 for record in bytes 179 .split(|byte| *byte == 0) 180 .filter(|record| !record.is_empty()) 181 { 182 let (metadata, path) = split_once(record, b'\t') 183 .ok_or_else(|| "exact-tree inventory record is malformed".to_owned())?; 184 let fields = metadata.split(|byte| *byte == b' ').collect::<Vec<_>>(); 185 if fields.len() != 3 || fields[1] != b"blob" { 186 return Err("exact-tree inventory contains a forbidden object".to_owned()); 187 } 188 let mode = match fields[0] { 189 b"100644" => 0o644, 190 b"100755" => 0o755, 191 _ => return Err("exact-tree inventory contains a forbidden mode".to_owned()), 192 }; 193 let object_id = std::str::from_utf8(fields[2]) 194 .map_err(|_| "exact-tree object ID is invalid".to_owned())?; 195 let path = 196 std::str::from_utf8(path).map_err(|_| "exact-tree path is not UTF-8".to_owned())?; 197 if !valid_lower_hex(object_id, 40) 198 || !valid_archive_path(path) 199 || !paths.insert(path.to_owned()) 200 { 201 return Err("exact-tree inventory contains an invalid member".to_owned()); 202 } 203 members.push(TreeMember { 204 path: path.to_owned(), 205 object_id: object_id.to_owned(), 206 mode, 207 }); 208 if members.len() > MAX_SOURCE_MEMBERS { 209 return Err("exact-tree inventory is too large".to_owned()); 210 } 211 } 212 if members.is_empty() { 213 return Err("exact-tree inventory is empty".to_owned()); 214 } 215 members.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes())); 216 Ok(members) 217 } 218 219 fn validate_archive_bytes( 220 bytes: &[u8], 221 expected: &[WrittenMember], 222 mtime: u64, 223 ) -> Result<(), String> { 224 let expected_length = expected.iter().try_fold(1024_u64, |total, member| { 225 let padded = member 226 .byte_length 227 .checked_add(511)? 228 .checked_div(512)? 229 .checked_mul(512)?; 230 total.checked_add(512)?.checked_add(padded) 231 }); 232 if expected_length != Some(bytes.len() as u64) 233 || bytes.len() < 1024 234 || !bytes.len().is_multiple_of(512) 235 || bytes[bytes.len() - 1024..].iter().any(|byte| *byte != 0) 236 { 237 return Err("exact-tree archive trailer is not canonical".to_owned()); 238 } 239 let mut archive = tar::Archive::new(Cursor::new(bytes)); 240 let mut observed = Vec::new(); 241 for entry in archive 242 .entries() 243 .map_err(|_| "exact-tree archive is malformed".to_owned())? 244 { 245 let mut entry = entry.map_err(|_| "exact-tree archive is malformed".to_owned())?; 246 let path = entry 247 .path() 248 .map_err(|_| "exact-tree archive path is invalid".to_owned())? 249 .to_str() 250 .ok_or_else(|| "exact-tree archive path is not UTF-8".to_owned())? 251 .to_owned(); 252 if !entry.header().entry_type().is_file() 253 || entry.header().uid().ok() != Some(0) 254 || entry.header().gid().ok() != Some(0) 255 || entry.header().mtime().ok() != Some(mtime) 256 || entry.header().username().ok().flatten() != Some("") 257 || entry.header().groupname().ok().flatten() != Some("") 258 { 259 return Err("exact-tree archive metadata is not canonical".to_owned()); 260 } 261 let mut contents = Vec::new(); 262 entry 263 .read_to_end(&mut contents) 264 .map_err(|_| "exact-tree archive member is unreadable".to_owned())?; 265 observed.push(( 266 path, 267 entry.header().mode().unwrap_or(0), 268 contents.len() as u64, 269 hex::encode(Sha256::digest(&contents)), 270 )); 271 } 272 if observed.len() != expected.len() 273 || observed 274 .iter() 275 .zip(expected) 276 .any(|((path, mode, length, digest), member)| { 277 path != &member.path 278 || mode != &member.mode 279 || length != &member.byte_length 280 || digest != &member.sha256 281 }) 282 { 283 return Err("exact-tree archive inventory or payload differs".to_owned()); 284 } 285 Ok(()) 286 } 287 288 fn valid_archive_path(path: &str) -> bool { 289 !path.is_empty() 290 && path.len() <= MAX_USTAR_PATH_BYTES 291 && !path.contains(['\0', '\n', '\r', '\\']) 292 && Path::new(path) 293 .components() 294 .all(|component| matches!(component, Component::Normal(value) if !value.is_empty())) 295 } 296 297 fn valid_lower_hex(value: &str, length: usize) -> bool { 298 value.len() == length 299 && value 300 .bytes() 301 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 302 } 303 304 fn split_once(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> { 305 let index = bytes.iter().position(|byte| *byte == separator)?; 306 Some((&bytes[..index], &bytes[index + 1..])) 307 } 308 309 fn git_output(root: &Path, arguments: &[&str], maximum: usize) -> Result<Vec<u8>, String> { 310 let mut child = Command::new("git") 311 .args(arguments) 312 .current_dir(root) 313 .stdin(Stdio::null()) 314 .stdout(Stdio::piped()) 315 .stderr(Stdio::null()) 316 .spawn() 317 .map_err(|_| "exact-tree Git command could not start".to_owned())?; 318 let mut stdout = child 319 .stdout 320 .take() 321 .ok_or_else(|| "exact-tree Git stdout is unavailable".to_owned())?; 322 let mut bytes = Vec::new(); 323 if stdout 324 .by_ref() 325 .take(maximum as u64 + 1) 326 .read_to_end(&mut bytes) 327 .is_err() 328 || bytes.len() > maximum 329 { 330 let _ = child.kill(); 331 let _ = child.wait(); 332 return Err("exact-tree Git output exceeded its bound".to_owned()); 333 } 334 let status = child 335 .wait() 336 .map_err(|_| "exact-tree Git command could not finish".to_owned())?; 337 if status.success() { 338 Ok(bytes) 339 } else { 340 Err("exact-tree Git command failed".to_owned()) 341 } 342 } 343 344 fn git_status(root: &Path, arguments: &[&str]) -> Result<(), ()> { 345 let status = Command::new("git") 346 .args(arguments) 347 .current_dir(root) 348 .stdin(Stdio::null()) 349 .stdout(Stdio::null()) 350 .stderr(Stdio::null()) 351 .status() 352 .map_err(|_| ())?; 353 if status.success() { Ok(()) } else { Err(()) } 354 } 355 356 #[cfg(unix)] 357 fn set_mode(path: &Path) -> Result<(), String> { 358 use std::os::unix::fs::PermissionsExt as _; 359 fs::set_permissions(path, fs::Permissions::from_mode(ARCHIVE_MODE)) 360 .map_err(|_| "exact-tree archive mode could not be set".to_owned()) 361 } 362 363 #[cfg(not(unix))] 364 fn set_mode(_path: &Path) -> Result<(), String> { 365 Ok(()) 366 } 367 368 #[cfg(test)] 369 mod tests { 370 use std::{path::PathBuf, process::Command}; 371 372 use tempfile::TempDir; 373 374 use super::*; 375 376 fn git(root: &Path, arguments: &[&str]) -> String { 377 let output = Command::new("git") 378 .args(arguments) 379 .current_dir(root) 380 .output() 381 .expect("run Git"); 382 assert!(output.status.success(), "git {arguments:?}"); 383 String::from_utf8(output.stdout) 384 .expect("Git UTF-8") 385 .trim() 386 .to_owned() 387 } 388 389 fn fixture() -> (TempDir, PathBuf, String) { 390 let fixture = TempDir::new().expect("fixture"); 391 let root = fixture.path().canonicalize().expect("canonical fixture"); 392 fs::create_dir(root.join("nested")).expect("nested"); 393 fs::write(root.join("alpha.txt"), b"alpha\n").expect("alpha"); 394 fs::write(root.join("nested/zeta.txt"), b"zeta\n").expect("zeta"); 395 git(&root, &["init", "--quiet"]); 396 git(&root, &["config", "user.name", "Exact Tree Fixture"]); 397 git(&root, &["config", "user.email", "fixture@radroots.test"]); 398 git(&root, &["add", "."]); 399 let status = Command::new("git") 400 .args(["commit", "--quiet", "-m", "fixture"]) 401 .current_dir(&root) 402 .env("GIT_AUTHOR_DATE", "@1700000000 +0000") 403 .env("GIT_COMMITTER_DATE", "@1700000000 +0000") 404 .status() 405 .expect("commit fixture"); 406 assert!(status.success()); 407 let revision = git(&root, &["rev-parse", "HEAD"]); 408 (fixture, root, revision) 409 } 410 411 #[test] 412 fn archive_verification_rejects_metadata_inventory_and_payload_tampering() { 413 let payload = b"source\n"; 414 let expected = || { 415 vec![WrittenMember { 416 path: "source.rs".to_owned(), 417 mode: 0o644, 418 byte_length: payload.len() as u64, 419 sha256: hex::encode(Sha256::digest(payload)), 420 }] 421 }; 422 let bytes = |change: fn(&mut Header)| { 423 let mut header = Header::new_ustar(); 424 header.set_path("source.rs").unwrap(); 425 header.set_mode(0o644); 426 header.set_uid(0); 427 header.set_gid(0); 428 header.set_mtime(123); 429 header.set_size(payload.len() as u64); 430 header.set_entry_type(EntryType::Regular); 431 header.set_username("").unwrap(); 432 header.set_groupname("").unwrap(); 433 change(&mut header); 434 header.set_cksum(); 435 let mut archive = Builder::new(Vec::new()); 436 archive.append(&header, &payload[..]).unwrap(); 437 archive.into_inner().unwrap() 438 }; 439 let original = bytes(|_| {}); 440 validate_archive_bytes(&original, &expected(), 123).unwrap(); 441 let metadata_changes: [fn(&mut Header); 7] = [ 442 |header| header.set_entry_type(EntryType::Directory), 443 |header| header.set_uid(1), 444 |header| header.set_gid(1), 445 |header| header.set_mtime(124), 446 |header| header.set_username("user").unwrap(), 447 |header| header.set_groupname("group").unwrap(), 448 |header| header.set_mode(0o755), 449 ]; 450 for change in metadata_changes { 451 assert!(validate_archive_bytes(&bytes(change), &expected(), 123).is_err()); 452 } 453 let inventory_changes: [fn(&mut WrittenMember); 4] = [ 454 |member| member.path = "other.rs".to_owned(), 455 |member| member.mode = 0o755, 456 |member| member.byte_length += 1, 457 |member| member.sha256 = "0".repeat(64), 458 ]; 459 for change in inventory_changes { 460 let mut altered = expected(); 461 change(&mut altered[0]); 462 assert!(validate_archive_bytes(&original, &altered, 123).is_err()); 463 } 464 let mut altered = original.clone(); 465 altered[512] ^= 1; 466 assert!(validate_archive_bytes(&altered, &expected(), 123).is_err()); 467 let mut altered = original.clone(); 468 *altered.last_mut().unwrap() = 1; 469 assert!(validate_archive_bytes(&altered, &expected(), 123).is_err()); 470 assert!(validate_archive_bytes(&original[..original.len() - 1], &expected(), 123).is_err()); 471 assert!(validate_archive_bytes(&[], &expected(), 123).is_err()); 472 assert!(validate_archive_bytes(&original, &[], 123).is_err()); 473 let mut overflow = expected(); 474 overflow[0].byte_length = u64::MAX; 475 assert!(validate_archive_bytes(&original, &overflow, 123).is_err()); 476 } 477 478 #[test] 479 fn exact_blob_reads_bind_revision_path_and_output_limit() { 480 let (_fixture, root, revision) = fixture(); 481 assert_eq!( 482 read_blob(&root, &revision, "alpha.txt", 6).unwrap(), 483 b"alpha\n" 484 ); 485 assert!(read_blob(&root, &revision, "alpha.txt", 5).is_err()); 486 assert!(read_blob(&root, &revision, "absent", 6).is_err()); 487 for maximum in [0, MAX_SOURCE_MEMBER_BYTES + 1] { 488 assert!(read_blob(&root, &revision, "alpha.txt", maximum).is_err()); 489 } 490 for path in [ 491 "", 492 "/absolute", 493 "../escape", 494 "a\\b", 495 "a\nb", 496 "a\rb", 497 "a\0b", 498 ".", 499 &"x".repeat(MAX_USTAR_PATH_BYTES + 1), 500 ] { 501 assert!(read_blob(&root, &revision, path, 10).is_err(), "{path:?}"); 502 } 503 for invalid in ["short", &"G".repeat(40), &"0".repeat(40)] { 504 assert!(read_blob(&root, invalid, "alpha.txt", 10).is_err()); 505 } 506 assert!(read_blob(Path::new("relative"), &revision, "alpha.txt", 10).is_err()); 507 assert!(read_blob(&root.join("absent"), &revision, "alpha.txt", 10).is_err()); 508 assert!(git_output(&root, &["not-a-real-git-command"], 100).is_err()); 509 assert!(git_output(&root.join("absent"), &["status"], 100).is_err()); 510 assert_eq!(split_once(b"missing", b'\t'), None); 511 assert_eq!( 512 split_once(b"one\ttwo\tthree", b'\t'), 513 Some((&b"one"[..], &b"two\tthree"[..])) 514 ); 515 } 516 517 #[test] 518 fn archive_creation_requires_fresh_canonical_output_and_nonempty_commit() { 519 let (_fixture, root, revision) = fixture(); 520 let output = root.join("output.tar"); 521 fs::write(&output, b"preserve").unwrap(); 522 assert!(create(&root, &revision, &output, 1).is_err()); 523 assert_eq!(fs::read(&output).unwrap(), b"preserve"); 524 assert!(create(&root, &revision, &root.join("absent/output.tar"), 1).is_err()); 525 git(&root, &["rm", "-r", "alpha.txt", "nested"]); 526 git(&root, &["commit", "--quiet", "-m", "empty source"]); 527 let empty = git(&root, &["rev-parse", "HEAD"]); 528 assert!(create(&root, &empty, &root.join("empty.tar"), 1).is_err()); 529 assert!(!root.join("empty.tar").exists()); 530 #[cfg(unix)] 531 { 532 let link = root.join("parent-link"); 533 std::os::unix::fs::symlink(root.join(".git"), &link).unwrap(); 534 assert!(create(&root, &revision, &link.join("archive.tar"), 1).is_err()); 535 } 536 } 537 538 #[test] 539 fn exact_tree_archive_is_reproducible_and_canonical() { 540 let (fixture, root, revision) = fixture(); 541 let first = fixture.path().join("first.tar"); 542 let second = fixture.path().join("second.tar"); 543 let first_evidence = create(&root, &revision, &first, 1_700_000_000).expect("first"); 544 let second_evidence = create(&root, &revision, &second, 1_700_000_000).expect("second"); 545 assert_eq!(first_evidence, second_evidence); 546 assert_eq!( 547 fs::read(&first).expect("first"), 548 fs::read(&second).expect("second") 549 ); 550 assert_eq!(first_evidence.members, 2); 551 assert_eq!(first_evidence.payload_bytes, 11); 552 assert_eq!( 553 commit_timestamp(&root, &revision).expect("timestamp"), 554 1_700_000_000 555 ); 556 } 557 558 #[test] 559 fn exact_tree_archive_rejects_links_and_noncanonical_requests() { 560 let (fixture, root, revision) = fixture(); 561 assert!(create(&root, &revision, Path::new("relative.tar"), 1).is_err()); 562 let output = fixture.path().join("zero.tar"); 563 assert!(create(&root, &revision, &output, 0).is_err()); 564 565 #[cfg(unix)] 566 { 567 std::os::unix::fs::symlink("alpha.txt", root.join("link")).expect("link"); 568 git(&root, &["add", "link"]); 569 let status = Command::new("git") 570 .args(["commit", "--quiet", "-m", "link"]) 571 .current_dir(&root) 572 .env("GIT_AUTHOR_DATE", "@1700000001 +0000") 573 .env("GIT_COMMITTER_DATE", "@1700000001 +0000") 574 .status() 575 .expect("commit link"); 576 assert!(status.success()); 577 let linked_revision = git(&root, &["rev-parse", "HEAD"]); 578 let linked_output = fixture.path().join("linked.tar"); 579 assert!(create(&root, &linked_revision, &linked_output, 1).is_err()); 580 } 581 } 582 }