Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333use std::io::{Seek, SeekFrom, Write};use std::sync::atomic::AtomicBool;
use gix::bstr::BString;use knot_types::{Oid, ParseError};
use crate::error::{GitError, backend};use crate::objects::MAX_TREE_DEPTH;use crate::repo::Repo;
const TAR_BLOCK: u64 = 512;
knot_types::scalar_newtype! { pub struct ArchiveLimit(u64);}
impl Default for ArchiveLimit { fn default() -> Self { Self::new(1024 * 1024 * 1024) }}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ArchiveFormat { Tar, TarGz, Zip,}
impl ArchiveFormat { fn gix(self) -> gix_archive::Format { match self { ArchiveFormat::Tar => gix_archive::Format::Tar, ArchiveFormat::TarGz => gix_archive::Format::TarGz { compression_level: None, }, ArchiveFormat::Zip => gix_archive::Format::Zip { compression_level: None, }, } }}
fn inside_root(value: String, kind: &'static str, limit: usize) -> Result<String, ParseError> { let safe = value.len() <= limit && !value.contains('\0') && !value.starts_with(['/', '\\']) && value.split(['/', '\\']).all(|component| component != ".."); match safe { true => Ok(value), false => Err(ParseError::Invalid { kind, value }), }}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct ArchivePrefix(String);
impl ArchivePrefix { pub const MAX_BYTES: usize = 255;
pub fn new(value: impl Into<String>) -> Result<Self, ParseError> { inside_root(value.into(), "archive prefix", Self::MAX_BYTES).map(Self) }
pub fn stem(repo_name: &str, safe_ref: &str) -> Self { let joined = format!("{repo_name}-{safe_ref}").replace(['/', '\\', '\0'], "-"); Self( joined .char_indices() .take_while(|(offset, character)| offset + character.len_utf8() <= Self::MAX_BYTES) .map(|(_, character)| character) .collect(), ) }
pub fn as_str(&self) -> &str { &self.0 }
pub fn into_tree_prefix(self) -> TreePrefix { TreePrefix(format!("{}/", self.0)) }}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct TreePrefix(String);
impl TreePrefix { pub const MAX_BYTES: usize = ArchivePrefix::MAX_BYTES + 1;
pub fn new(value: impl Into<String>) -> Result<Self, ParseError> { inside_root(value.into(), "archive tree prefix", Self::MAX_BYTES).map(Self) }
pub fn as_str(&self) -> &str { &self.0 }}
impl Repo { pub fn peel_to_tree(&self, oid: Oid) -> Result<Oid, GitError> { self.git() .find_object(oid.object_id()) .map_err(backend)? .peel_to_tree() .map(|tree| Oid::from(tree.id)) .map_err(backend) }
pub fn write_archive( &self, tree: Oid, format: ArchiveFormat, prefix: Option<&TreePrefix>, limit: ArchiveLimit, out: impl std::io::Write + std::io::Seek, ) -> Result<(), GitError> { self.bound_archive_source(tree.object_id(), limit, MAX_TREE_DEPTH, &mut 0)?; let (stream, _index) = self .git() .worktree_stream(tree.object_id()) .map_err(backend)?; let interrupt = AtomicBool::new(false); let mut spool = BoundedSpool { inner: out, position: 0, limit, overflowed: false, }; let written = self.git().worktree_archive( stream, &mut spool, gix::progress::Discard, &interrupt, gix_archive::Options { format: format.gix(), tree_prefix: prefix.map(|prefix| BString::from(prefix.as_str())), modification_time: 0, }, ); match (written, spool.overflowed) { (_, true) => Err(GitError::ArchiveTooLarge { limit }), (Ok(()), false) => Ok(()), (Err(error), false) => Err(backend(error)), } }
fn bound_archive_source( &self, tree: gix::ObjectId, limit: ArchiveLimit, nesting: usize, spooled: &mut u64, ) -> Result<(), GitError> { if nesting == 0 { return Err(GitError::DepthExceeded("tree nesting")); } if tree == gix::ObjectId::empty_tree(self.git().object_hash()) { return Ok(()); } let object = self.git().find_tree(tree).map_err(backend)?; let decoded = object .decode() .map_err(|error| GitError::Decode(error.to_string()))?; decoded.entries.iter().try_for_each(|entry| { let oid = entry.oid.to_owned(); *spooled = spooled.saturating_add(TAR_BLOCK); match entry.mode.kind() { _ if *spooled > limit.get() => Err(GitError::ArchiveTooLarge { limit }), gix::objs::tree::EntryKind::Commit => Ok(()), gix::objs::tree::EntryKind::Tree => { self.bound_archive_source(oid, limit, nesting - 1, spooled) } _ => { let content = self.blob_size(Oid::from(oid))?; *spooled = spooled.saturating_add(content.next_multiple_of(TAR_BLOCK)); match *spooled > limit.get() { true => Err(GitError::ArchiveTooLarge { limit }), false => Ok(()), } } } }) }}
struct BoundedSpool<W> { inner: W, position: u64, limit: ArchiveLimit, overflowed: bool,}
impl<W: Write> Write for BoundedSpool<W> { fn write(&mut self, data: &[u8]) -> std::io::Result<usize> { let remaining = self.limit.get().saturating_sub(self.position); if data.len() as u64 > remaining { self.overflowed = true; return Err(std::io::Error::from(std::io::ErrorKind::WriteZero)); } let written = self.inner.write(data)?; self.position = self.position.saturating_add(written as u64); Ok(written) }
fn flush(&mut self) -> std::io::Result<()> { self.inner.flush() }}
impl<W: Seek> Seek for BoundedSpool<W> { fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> { let position = self.inner.seek(pos)?; self.position = position; Ok(position) }}
#[cfg(test)]mod tests { use super::{ArchiveLimit, ArchivePrefix, BoundedSpool, TreePrefix}; use std::io::{Seek, SeekFrom, Write};
fn spool(limit: u64) -> BoundedSpool<std::io::Cursor<Vec<u8>>> { BoundedSpool { inner: std::io::Cursor::new(Vec::new()), position: 0, limit: ArchiveLimit::new(limit), overflowed: false, } }
#[test] fn the_spool_refuses_the_write_that_would_pass_the_limit() { let mut spool = spool(8); assert!(spool.write_all(b"12345678").is_ok()); assert!(!spool.overflowed); assert!(spool.write_all(b"9").is_err()); assert!(spool.overflowed); assert_eq!( spool.inner.into_inner(), b"12345678", "the refused write never reaches the inner writer" ); }
#[test] fn a_seek_backwards_re_credits_the_budget_the_zip_writer_rewinds_over() { let mut spool = spool(8); spool.write_all(b"12345678").unwrap(); spool.seek(SeekFrom::Start(4)).unwrap(); assert_eq!(spool.position, 4); spool .write_all(b"abcd") .expect("rewriting bytes already counted stays within the limit"); assert!(!spool.overflowed); }
#[test] fn a_write_whose_length_would_overflow_the_position_is_refused() { let mut spool = spool(u64::MAX); spool.position = u64::MAX; assert!( spool.write_all(b"1").is_err(), "the position saturates at u64::MAX, so the spool must refuse the write" ); assert!(spool.overflowed); }
#[test] fn a_plain_nested_prefix_is_accepted() { assert!(ArchivePrefix::new("squid-main").is_ok()); assert!(ArchivePrefix::new("nested/path").is_ok()); }
#[test] fn traversal_is_rejected_across_both_separators() { assert!(ArchivePrefix::new("../escape").is_err()); assert!(ArchivePrefix::new("nested/../escape").is_err()); assert!(ArchivePrefix::new("..\\escape").is_err()); assert!(ArchivePrefix::new("nested\\..\\escape").is_err()); assert!( ArchivePrefix::new("dotted-..-name/").is_ok(), "a component that merely contains dot-dot is not a traversal" ); assert!( TreePrefix::new("dotted-..-name//").is_ok(), "TreePrefix accepts the empty final component that a trailing separator adds" ); }
#[test] fn absolute_and_null_bearing_prefixes_are_rejected() { assert!(ArchivePrefix::new("/etc").is_err()); assert!(ArchivePrefix::new("\\windows").is_err()); assert!(ArchivePrefix::new("good\0bad").is_err()); }
#[test] fn a_prefix_is_bounded_and_a_stem_always_parses() { let longest = "u".repeat(ArchivePrefix::MAX_BYTES); assert!(ArchivePrefix::new(format!("{longest}u")).is_err());
let tree = ArchivePrefix::new(longest).unwrap().into_tree_prefix(); assert_eq!(tree.as_str().len(), TreePrefix::MAX_BYTES); assert!(TreePrefix::new(tree.as_str()).is_ok()); assert!(TreePrefix::new("u".repeat(TreePrefix::MAX_BYTES + 1)).is_err());
let (long_name, truncated) = ("ü".repeat(400), "ü".repeat(127)); for (repo_name, safe_ref, want) in [ ("squid", "main", "squid-main"), ("did:plc:limpet", "feat/uni", "did:plc:limpet-feat-uni"), ("kelp/../conch", "..", "kelp-..-conch-.."), ("/etc", "\0", "-etc--"), ("", "", "-"), (long_name.as_str(), "main", truncated.as_str()), ] { let stem = ArchivePrefix::stem(repo_name, safe_ref); assert_eq!(stem.as_str(), want); assert!( ArchivePrefix::new(stem.as_str()).is_ok(), "a stem passes the check that its own constructor skips, within {} bytes and on a char boundary", ArchivePrefix::MAX_BYTES ); assert_eq!( stem.into_tree_prefix().as_str(), format!("{want}/"), "into_tree_prefix appends the separator that puts a stem at the archive root" ); } }}