lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }