use std::fmt;
use std::str::FromStr;
use crate::ObjectKind;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ObjectFilter {
BlobNone,
BlobLimit(u64),
TreeDepth(u64),
ObjectType(ObjectKind),
SparseOid(String),
Combine(Vec<ObjectFilter>),
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ObjectFilterParseError {
#[error("invalid filter-spec '{0}'")]
Unknown(String),
#[error("invalid blob size limit '{0}'")]
BlobLimit(String),
#[error("expected 'tree:<depth>', found '{0}'")]
TreeDepth(String),
#[error("'{0}' is not a valid object type for 'object:type=<type>'")]
ObjectType(String),
#[error("expected an object name after 'sparse:oid='")]
SparseOid,
#[error("empty part in combined filter-spec '{0}'")]
EmptyCombinePart(String),
#[error("character '{0}' must be percent-encoded in a combined filter-spec part")]
Unescaped(char),
#[error("combined filter-spec part '{0}' does not decode to UTF-8")]
Escape(String),
#[error(
"combined filter-spec nested more than {} levels deep",
ObjectFilter::MAX_NESTING
)]
TooDeep,
}
impl ObjectFilter {
pub const MAX_NESTING: usize = 16;
fn parse_nested(spec: &str, depth: usize) -> Result<Self, ObjectFilterParseError> {
if let Some(parts) = spec.strip_prefix("combine:") {
if depth == Self::MAX_NESTING {
return Err(ObjectFilterParseError::TooDeep);
}
return parts
.split('+')
.map(|part| {
if part.is_empty() {
return Err(ObjectFilterParseError::EmptyCombinePart(spec.to_owned()));
}
Self::parse_nested(&decode_part(part)?, depth + 1)
})
.collect::<Result<_, _>>()
.map(Self::Combine);
}
Self::parse_simple(spec)
}
}
impl FromStr for ObjectFilter {
type Err = ObjectFilterParseError;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
Self::parse_nested(spec, 0)
}
}
impl ObjectFilter {
fn parse_simple(spec: &str) -> Result<Self, ObjectFilterParseError> {
if spec == "blob:none" {
return Ok(Self::BlobNone);
}
if let Some(limit) = spec.strip_prefix("blob:limit=") {
return parse_size(limit)
.map(Self::BlobLimit)
.ok_or_else(|| ObjectFilterParseError::BlobLimit(limit.to_owned()));
}
if let Some(depth) = spec.strip_prefix("tree:") {
return parse_number(depth)
.map(Self::TreeDepth)
.ok_or_else(|| ObjectFilterParseError::TreeDepth(depth.to_owned()));
}
if let Some(kind) = spec.strip_prefix("object:type=") {
let kind = match kind {
"blob" => ObjectKind::Blob,
"tree" => ObjectKind::Tree,
"commit" => ObjectKind::Commit,
"tag" => ObjectKind::Tag,
other => return Err(ObjectFilterParseError::ObjectType(other.to_owned())),
};
return Ok(Self::ObjectType(kind));
}
if let Some(name) = spec.strip_prefix("sparse:oid=") {
if name.is_empty() {
return Err(ObjectFilterParseError::SparseOid);
}
return Ok(Self::SparseOid(name.to_owned()));
}
Err(ObjectFilterParseError::Unknown(spec.to_owned()))
}
}
impl ObjectFilter {
pub(crate) fn may_omit(&self, kind: ObjectKind, root_tree: bool) -> bool {
match self {
Self::BlobNone | Self::BlobLimit(_) | Self::SparseOid(_) => kind == ObjectKind::Blob,
Self::TreeDepth(depth) => match kind {
ObjectKind::Blob => true,
ObjectKind::Tree => *depth == 0 || !root_tree,
ObjectKind::Commit | ObjectKind::Tag => false,
},
Self::ObjectType(kept) => kind != *kept,
Self::Combine(parts) => parts.iter().any(|part| part.may_omit(kind, root_tree)),
}
}
}
impl fmt::Display for ObjectFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BlobNone => f.write_str("blob:none"),
Self::BlobLimit(limit) => write!(f, "blob:limit={limit}"),
Self::TreeDepth(depth) => write!(f, "tree:{depth}"),
Self::ObjectType(kind) => write!(f, "object:type={}", kind.as_str()),
Self::SparseOid(name) => write!(f, "sparse:oid={name}"),
Self::Combine(parts) => {
f.write_str("combine:")?;
for (index, part) in parts.iter().enumerate() {
if index > 0 {
f.write_str("+")?;
}
for byte in part.to_string().bytes() {
if needs_escape(byte) {
write!(f, "%{byte:02x}")?;
} else {
f.write_str(std::str::from_utf8(&[byte]).expect("ASCII byte"))?;
}
}
}
Ok(())
}
}
}
}
const RESERVED: &[u8] = b"~!@#$^&*()[]{};'\"<>?,\\`";
fn needs_escape(byte: u8) -> bool {
!byte.is_ascii_graphic() || byte == b'%' || byte == b'+' || RESERVED.contains(&byte)
}
fn decode_part(part: &str) -> Result<String, ObjectFilterParseError> {
let bytes = part.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
let escaped = bytes
.get(index + 1..index + 3)
.filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
.map(|hex| {
let hex = std::str::from_utf8(hex).expect("hex digits are ASCII");
u8::from_str_radix(hex, 16).expect("two hex digits fit in a byte")
})
.filter(|&value| value != 0);
if let (b'%', Some(value)) = (byte, escaped) {
decoded.push(value);
index += 3;
} else if RESERVED.contains(&byte) {
return Err(ObjectFilterParseError::Unescaped(char::from(byte)));
} else {
decoded.push(byte);
index += 1;
}
}
String::from_utf8(decoded).map_err(|_| ObjectFilterParseError::Escape(part.to_owned()))
}
fn parse_number(text: &str) -> Option<u64> {
let text = text.trim_start_matches(|c: char| c.is_ascii_whitespace());
let text = text.strip_prefix('+').unwrap_or(text);
let (digits, radix) =
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
(hex, 16)
} else if text.len() > 1
&& let Some(octal) = text.strip_prefix('0')
{
(octal, 8)
} else {
(text, 10)
};
if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
return None;
}
u64::from_str_radix(digits, radix).ok()
}
fn parse_size(size: &str) -> Option<u64> {
let (number, scale) = match size.as_bytes().last()?.to_ascii_lowercase() {
b'k' => (&size[..size.len() - 1], 1 << 10),
b'm' => (&size[..size.len() - 1], 1 << 20),
b'g' => (&size[..size.len() - 1], 1 << 30),
_ => (size, 1),
};
parse_number(number)?.checked_mul(scale)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(spec: &str) -> Result<ObjectFilter, ObjectFilterParseError> {
spec.parse()
}
#[test]
fn parses_and_writes_git_specs() {
for (spec, sent) in [
("blob:none", "blob:none"),
("blob:limit=0", "blob:limit=0"),
("blob:limit=1k", "blob:limit=1024"),
("blob:limit=1K", "blob:limit=1024"),
("blob:limit=2M", "blob:limit=2097152"),
("blob:limit=1g", "blob:limit=1073741824"),
("tree:0", "tree:0"),
("tree:01", "tree:1"),
("tree:0x10", "tree:16"),
("blob:limit=010", "blob:limit=8"),
("blob:limit=0x10k", "blob:limit=16384"),
("blob:limit=0X10", "blob:limit=16"),
("blob:limit= 5", "blob:limit=5"),
("blob:limit=+5", "blob:limit=5"),
("object:type=tree", "object:type=tree"),
("sparse:oid=main:x", "sparse:oid=main:x"),
("combine:blob:none+tree:0", "combine:blob:none+tree:0"),
("combine:blob:none", "combine:blob:none"),
("combine:tree%3a1", "combine:tree:1"),
(
"combine:tree%3A1+blob:limit=1k",
"combine:tree:1+blob:limit=1024",
),
] {
let filter = parse(spec).unwrap_or_else(|error| panic!("{spec}: {error}"));
assert_eq!(filter.to_string(), sent, "{spec}");
assert_eq!(parse(sent), Ok(filter), "{sent} round trip");
}
}
#[test]
fn rejects_specs_git_rejects() {
use ObjectFilterParseError as E;
for (spec, error) in [
("foo", E::Unknown("foo".into())),
("BLOB:NONE", E::Unknown("BLOB:NONE".into())),
("blob:none ", E::Unknown("blob:none ".into())),
("blob:limit=x", E::BlobLimit("x".into())),
("blob:limit=1kb", E::BlobLimit("1kb".into())),
("blob:limit=-1", E::BlobLimit("-1".into())),
("blob:limit=08", E::BlobLimit("08".into())),
("blob:limit=0x", E::BlobLimit("0x".into())),
("tree:08", E::TreeDepth("08".into())),
(
"blob:limit=99999999999999999999",
E::BlobLimit("99999999999999999999".into()),
),
(
"blob:limit=17179869184g",
E::BlobLimit("17179869184g".into()),
),
("tree:", E::TreeDepth(String::new())),
("tree:-1", E::TreeDepth("-1".into())),
("object:type=TREE", E::ObjectType("TREE".into())),
("sparse:oid=", E::SparseOid),
("combine:", E::EmptyCombinePart("combine:".into())),
("combine:tree:1~", E::Unescaped('~')),
("combine:blob:none+tree:0%", E::TreeDepth("0%".into())),
("combine:tree%3", E::Unknown("tree%3".into())),
("combine:blob:none+tree%ff1", E::Escape("tree%ff1".into())),
] {
assert_eq!(parse(spec), Err(error), "{spec}");
}
}
#[test]
fn rejects_empty_combine_parts() {
for spec in ["combine:blob:none+", "combine:+tree:0"] {
assert_eq!(
parse(spec),
Err(ObjectFilterParseError::EmptyCombinePart(spec.into()))
);
}
}
#[test]
fn keeps_undecodable_escapes() {
for (spec, name) in [
("combine:sparse:oid=a%00b", "a%00b"),
("combine:sparse:oid=a%zz", "a%zz"),
("combine:sparse:oid=a%41", "aA"),
] {
let filter = parse(spec).unwrap();
assert_eq!(
filter,
ObjectFilter::Combine(vec![ObjectFilter::SparseOid(name.into())])
);
assert_eq!(parse(&filter.to_string()), Ok(filter));
}
}
#[test]
fn bounds_combine_nesting() {
let nested = |levels: usize| {
let mut spec = "blob:none".to_owned();
for _ in 0..levels {
spec = format!("combine:{}", spec.replace('%', "%25").replace(':', "%3a"));
}
spec
};
let mut filter = parse(&nested(ObjectFilter::MAX_NESTING)).unwrap();
for _ in 0..ObjectFilter::MAX_NESTING {
let ObjectFilter::Combine(mut parts) = filter else {
panic!("expected combine");
};
filter = parts.remove(0);
}
assert_eq!(filter, ObjectFilter::BlobNone);
assert_eq!(
parse(&nested(ObjectFilter::MAX_NESTING + 1)),
Err(ObjectFilterParseError::TooDeep)
);
let raw = format!("{}blob:none", "combine:".repeat(8000));
assert_eq!(parse(&raw), Err(ObjectFilterParseError::TooDeep));
}
#[test]
fn escapes_reserved_characters_in_combine_parts() {
let filter = ObjectFilter::Combine(vec![
ObjectFilter::SparseOid("a~b+c%d".into()),
ObjectFilter::BlobNone,
]);
let spec = filter.to_string();
assert_eq!(spec, "combine:sparse:oid=a%7eb%2bc%25d+blob:none");
assert_eq!(parse(&spec), Ok(filter));
}
}