crates/gunnar-ui-server/src/git/mod.rs
//! Reading a repository's contents, one object at a time.
//!
//! gunnar has no RPC that lists a tree or fetches a file by path. What it has is
//! `Refs.ReadObject`, which hands back **the undecorated payload** of an object:
//! no `"tree <n>\0"` header, no zlib frame, no delta. So the walk from a
//! revision to a file is done here — commit, tree, subtree, blob — and every
//! step is a round trip that costs one of gunnar's single-use credentials.
//!
//! Three things follow from that, and all three are load-bearing:
//!
//! * **The hash kind travels with every parse.** A sha256 repository has
//! 32-byte object ids inside its trees, and gunnar defaults new repositories
//! to sha256. Parsing one as sha1 does not fail — it reads the wrong bytes as
//! a name and the wrong name as an id.
//! * **Objects are cached by id, and only when complete.** A git object is
//! immutable under its id, so this cache never needs invalidating. A
//! *truncated* object is not the object, though, and storing one under the
//! real id would poison every later read of it.
//! * **Every request carries a fetch budget.** A diff over a large tree can want
//! hundreds of objects, and gunnar's replay guard remembers 8192 credentials
//! across five minutes for the whole server. Running out is reported, never
//! hidden.
pub mod diff;
pub mod highlight;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::api::ApiError;
use crate::grpc::{Fail, Gunnar};
use crate::pb;
/// How many bytes of decoded objects to keep.
///
/// Bounded by size rather than by count because a tree is a few hundred bytes
/// and a blob can be megabytes, so a count would bound nothing that matters.
const CACHE_BYTES: usize = 64 * 1024 * 1024;
/// The largest object the console will ever ask gunnar for.
///
/// gunnar caps a single response at 4 MiB and refuses anything over 64 MiB
/// outright, so this is a console decision rather than a limit: a file this size
/// is not something a browser should be asked to render.
pub const MAX_BLOB_BYTES: u64 = 2 * 1024 * 1024;
/// **The largest file the raw route hands a browser**, a git blob and a Git LFS
/// object alike.
///
/// gunnar's own ceiling for one `Refs.ReadObject` / `Refs.ReadLfsObject`
/// answer (its `MAX_RESPONSE_BYTES`), and under [`crate::grpc::MAX_DECODE`].
/// Not [`MAX_BLOB_BYTES`], which is what the console reads to *render*: the
/// files a README links to are the large ones — a 3.9 MB LFS picture on
/// gunnar.rs (nordisk/knut, 2026-10-15) was refused at 2 MiB. Past this gunnar
/// has no read that streams (an LFS object is served whole or refused), so a
/// larger file is refused in plain words (`api::browse::raw`).
pub const MAX_RAW_BYTES: u64 = 4 * 1024 * 1024;
/// One range of a Git LFS object ([`Repository::read_lfs_range`]).
#[derive(Debug, Clone)]
pub struct LfsRange {
/// The WHOLE object's size.
pub size: u64,
/// The range.
pub data: Vec<u8>,
}
/// How many objects one request may fetch.
pub const DEFAULT_BUDGET: usize = 400;
/// A git object as gunnar returned it.
#[derive(Debug, Clone)]
pub struct Object {
pub oid: String,
pub kind: &'static str,
/// The object's real size, whatever arrived.
pub size: u64,
pub data: Vec<u8>,
pub truncated: bool,
}
/// One entry in a tree.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Entry {
pub name: String,
/// `dir`, `file`, `executable`, `symlink` or `submodule`.
pub kind: &'static str,
pub oid: String,
/// The octal file mode, as git stores it.
pub mode: String,
/// True when the name shown is not the name stored.
///
/// git stores a filename as bytes, and nothing says they are UTF-8. A name
/// that is not gets replacement characters for display — which can never be
/// matched back against the raw bytes, so a link built from it would always
/// fail. The console marks these unreachable rather than offering a link
/// that cannot work.
pub lossy_name: bool,
}
impl Entry {
/// True when following this entry means reading another tree.
pub fn is_dir(&self) -> bool {
self.kind == "dir"
}
}
/// A resolved revision.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Revision {
pub commit_oid: String,
pub tree_oid: String,
pub subject: String,
pub author: String,
pub at_unix_seconds: i64,
}
/// Objects held by id.
///
/// Shared by every request, because the whole point is that the second person to
/// open a file does not pay for it again.
pub struct ObjectCache {
inner: Mutex<CacheInner>,
}
struct CacheInner {
held: HashMap<String, Arc<Object>>,
/// Insertion order, oldest first. Good enough: git objects do not become
/// more or less popular, they are either on the path being browsed or not.
order: Vec<String>,
bytes: usize,
}
impl Default for ObjectCache {
fn default() -> ObjectCache {
ObjectCache {
inner: Mutex::new(CacheInner {
held: HashMap::new(),
order: Vec::new(),
bytes: 0,
}),
}
}
}
impl std::fmt::Debug for ObjectCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let held = self.inner.lock().map(|i| i.held.len()).unwrap_or(0);
f.debug_struct("ObjectCache")
.field("objects", &held)
.finish()
}
}
impl ObjectCache {
fn key(repo: &pb::RepoPath, oid: &str) -> String {
format!("{}/{}@{oid}", repo.namespace, repo.repo)
}
fn get(&self, repo: &pb::RepoPath, oid: &str) -> Option<Arc<Object>> {
let inner = self.inner.lock().ok()?;
inner.held.get(&ObjectCache::key(repo, oid)).cloned()
}
fn put(&self, repo: &pb::RepoPath, object: Arc<Object>) {
// A truncated object is not the object it claims to be. Caching one
// under the real id would make every later full read return the stub.
if object.truncated {
return;
}
let Ok(mut inner) = self.inner.lock() else {
return;
};
let key = ObjectCache::key(repo, &object.oid);
if inner.held.contains_key(&key) {
return;
}
inner.bytes += object.data.len();
inner.held.insert(key.clone(), object);
inner.order.push(key);
while inner.bytes > CACHE_BYTES && !inner.order.is_empty() {
let oldest = inner.order.remove(0);
if let Some(dropped) = inner.held.remove(&oldest) {
inner.bytes = inner.bytes.saturating_sub(dropped.data.len());
}
}
}
}
/// A handle for reading one repository's contents.
pub struct Repository {
gunnar: Gunnar,
path: pb::RepoPath,
hash_kind: gix_hash::Kind,
cache: Arc<ObjectCache>,
budget: AtomicUsize,
}
impl Repository {
/// Open a repository, learning its hash kind from gunnar.
///
/// # Errors
///
/// Fails if gunnar refuses to describe the repository.
pub async fn open(
gunnar: &Gunnar,
namespace: &str,
name: &str,
cache: Arc<ObjectCache>,
) -> Result<Repository, ApiError> {
let path = crate::api::repo_path(namespace, name);
let repo = gunnar
.repos()
.get(pb::GetRepoRequest {
path: Some(path.clone()),
})
.await
.map_err(ApiError::from)?
.into_inner()
.repo
.unwrap_or_default();
let hash_kind = match pb::HashKind::try_from(repo.hash_kind) {
Ok(pb::HashKind::Sha1) => gix_hash::Kind::Sha1,
Ok(pb::HashKind::Sha256) => gix_hash::Kind::Sha256,
// Guessing here is the one mistake this module cannot recover from:
// the wrong kind does not fail, it reads the wrong bytes as names
// and ids. Say so instead.
_ => {
return Err(ApiError(Fail {
code: "internal".to_owned(),
message: format!(
"Gunnar did not say which hash {namespace}/{name} uses, and guessing would silently mis-read every tree in it"
),
}))
}
};
Ok(Repository {
gunnar: gunnar.clone(),
path,
hash_kind,
cache,
budget: AtomicUsize::new(DEFAULT_BUDGET),
})
}
/// gunnar's own tree diff between two tree-ishes, under `path_prefixes`,
/// asked with this repository's client — the reader's (thin layer, owner
/// 2026-10-04). Structure and line counts are gunnar's (`Refs.Diff`).
pub async fn changes(
&self,
from: &str,
to: &str,
path_prefixes: Vec<String>,
) -> Result<Vec<pb::FileChange>, ApiError> {
Ok(self
.gunnar
.refs()
.diff(pb::DiffRequest {
repo: Some(self.path.clone()),
from: from.to_owned(),
to: to.to_owned(),
path_prefixes,
})
.await
.map_err(ApiError::from)?
.into_inner()
.changes)
}
/// The hash algorithm this repository's object ids use.
pub fn hash_kind(&self) -> gix_hash::Kind {
self.hash_kind
}
/// How many more objects this request may fetch.
pub fn remaining_budget(&self) -> usize {
self.budget.load(Ordering::Relaxed)
}
fn spend(&self) -> Result<(), ApiError> {
let left = self
.budget
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1));
left.map(|_| ()).map_err(|_| {
ApiError(Fail {
code: "resourceexhausted".to_owned(),
message: format!(
"the console stopped after fetching {DEFAULT_BUDGET} objects for this one \
request. Every object is a separate call to Gunnar, and each spends one \
single-use credential from a pool the whole server shares. Narrow the \
path or the revision range."
),
})
})
}
/// Read one object, from the cache when it is there.
///
/// `max_bytes` of zero means the whole object. Never pass a limit for a tree
/// or a commit: a truncated tree cannot be decoded at all, and the failure
/// looks like corruption rather than like a limit.
///
/// # Errors
///
/// Fails if the budget is exhausted or gunnar refuses the read.
pub async fn read_object(&self, oid: &str, max_bytes: u64) -> Result<Arc<Object>, ApiError> {
if let Some(held) = self.cache.get(&self.path, oid) {
// A cached object is complete, so it satisfies any limit.
return Ok(held);
}
self.spend()?;
let response = self
.gunnar
.refs()
.read_object(pb::ReadObjectRequest {
repo: Some(self.path.clone()),
oid: oid.to_owned(),
max_bytes,
})
.await
.map_err(ApiError::from)?
.into_inner();
let object = Arc::new(Object {
oid: response.oid,
kind: crate::api::refs::object_kind_name(response.kind),
size: response.size,
data: response.data,
truncated: response.truncated,
});
self.cache.put(&self.path, object.clone());
Ok(object)
}
/// **The bytes of a Git LFS object this repository's tree points at**
/// (`Refs.ReadLfsObject`), whole or refused — never a prefix.
///
/// On the same `Read` grant the tree walk that found the pointer spent, so
/// following a pointer widens nothing. The error is gunnar's own status, so
/// a caller can tell an older gunnar (`Unimplemented`) from a refusal.
pub async fn read_lfs_object(
&self,
oid: &str,
max_bytes: u64,
) -> Result<Vec<u8>, tonic::Status> {
let response = self
.gunnar
.refs()
.read_lfs_object(pb::ReadLfsObjectRequest {
repo: Some(self.path.clone()),
oid: oid.to_owned(),
max_bytes,
// Neither: the whole-or-refused read every gunnar answers.
offset: 0,
length: 0,
})
.await?;
Ok(response.into_inner().data)
}
/// **One range of a Git LFS object** (`Refs.ReadLfsObject` with
/// `offset`/`length`, gunnar 2026-10-15): at most `length` bytes from
/// `offset`, under gunnar's per-answer ceiling.
///
/// A gunnar older than ranged reads ignores the two fields and answers
/// whole-or-refused, so the answer is CHECKED: it must start where it was
/// asked to, be no longer than asked, and name the object's size. An
/// answer that does not is `FailedPrecondition` ("this gunnar does not read
/// ranges"), never bytes passed on as if they were the range.
pub async fn read_lfs_range(
&self,
oid: &str,
offset: u64,
length: u64,
) -> Result<LfsRange, tonic::Status> {
let answer = self
.gunnar
.refs()
.read_lfs_object(pb::ReadLfsObjectRequest {
repo: Some(self.path.clone()),
oid: oid.to_owned(),
max_bytes: length,
offset,
length,
})
.await?
.into_inner();
let fits = answer.offset == offset
&& answer.data.len() as u64 <= length
&& offset + answer.data.len() as u64 <= answer.size
&& (!answer.data.is_empty() || offset == answer.size);
if !fits {
return Err(tonic::Status::failed_precondition(
"this Gunnar does not read LFS objects a range at a time",
));
}
Ok(LfsRange {
size: answer.size,
data: answer.data,
})
}
/// Resolve a reference name or object id to the commit and tree it names.
///
/// Uses `Refs.Log` rather than reading and parsing the commit: gunnar
/// already decodes it there, and one call beats two.
///
/// # Errors
///
/// Fails if gunnar cannot resolve the revision, or if the repository has no
/// commits yet.
pub async fn resolve(&self, rev: &str) -> Result<Revision, ApiError> {
// gunnar wants a full reference name or an object id, and refuses a
// bare "main" outright. Operators type "main", so expand it here rather
// than passing on a refusal about reference-name syntax.
let mut candidates: Vec<String> = Vec::new();
if rev.is_empty() || rev == "HEAD" || rev.starts_with("refs/") || looks_like_oid(rev) {
candidates.push(rev.to_owned());
} else {
candidates.push(format!("refs/heads/{rev}"));
candidates.push(format!("refs/tags/{rev}"));
}
let mut first_failure: Option<tonic::Status> = None;
let mut response = None;
for candidate in &candidates {
match self
.gunnar
.refs()
.log(pb::LogRequest {
repo: Some(self.path.clone()),
start: candidate.clone(),
page_size: 1,
page_token: String::new(),
path: String::new(),
all_refs: false,
})
.await
{
Ok(ok) => {
response = Some(ok.into_inner());
break;
}
Err(status) => {
// Anything that is not "no such reference" will not be
// fixed by trying the next spelling, and trying anyway
// spends a second credential on a call that cannot work.
// It is also the more useful message: an operator whose
// console credential has lost its grant should be told
// that, not that a tag does not exist.
if status.code() != tonic::Code::NotFound {
return Err(ApiError::from(status));
}
first_failure.get_or_insert(status);
}
}
}
let response = match response {
Some(response) => response,
None => {
let status = first_failure
.expect("a candidate list is never empty, so a failure was recorded");
// A repository nobody has pushed to yet answers "HEAD not
// found", which is true and tells an operator nothing. Explain
// the state, and keep gunnar's own words underneath it rather
// than replacing them.
if rev.is_empty() && status.code() == tonic::Code::NotFound {
return Err(ApiError(Fail {
code: "notfound".to_owned(),
message: format!(
"this repository has no commits yet, so there is nothing to browse. Push to it over SSH and it will fill in. (Gunnar said: {})",
status.message()
),
}));
}
return Err(ApiError::from(status));
}
};
let commit = response.commits.into_iter().next().ok_or_else(|| {
ApiError(Fail {
code: "notfound".to_owned(),
message: if rev.is_empty() {
"this repository has no commits on its default branch yet".to_owned()
} else {
format!("nothing here is called {rev:?}")
},
})
})?;
Ok(Revision {
commit_oid: commit.oid,
tree_oid: commit.tree_oid,
subject: commit.message.lines().next().unwrap_or_default().to_owned(),
author: commit
.author
.as_ref()
.map(|a| a.name.clone())
.unwrap_or_default(),
at_unix_seconds: commit
.author
.map(|a| a.time_unix_seconds)
.unwrap_or_default(),
})
}
/// List a tree's entries.
///
/// # Errors
///
/// Fails if the object cannot be read or is not a decodable tree.
pub async fn read_tree(&self, tree_oid: &str) -> Result<Vec<Entry>, ApiError> {
// No max_bytes: a partial tree is not a smaller tree, it is unparseable.
let object = self.read_object(tree_oid, 0).await?;
// gunnar caps a single response at 4 MiB whatever was asked for, so a
// very large directory arrives short. Parsing it anyway is worse than
// failing: when the cut lands on an entry boundary the parse SUCCEEDS
// and returns a silently incomplete listing, which a diff then reports
// as files that were deleted.
if object.truncated || object.data.len() as u64 != object.size {
return Err(ApiError(Fail {
code: "resourceexhausted".to_owned(),
message: format!(
"this directory is too large for the console to list: Gunnar sent {} of its {} bytes and caps a single response at 4 MiB",
object.data.len(),
object.size
),
}));
}
parse_tree(&object.data, self.hash_kind).map_err(|why| {
ApiError(Fail {
code: "internal".to_owned(),
message: format!("cannot read tree {tree_oid}: {why}"),
})
})
}
/// Follow a path from a tree, returning what it names.
///
/// `path` is slash-separated and may be empty, which names the tree itself.
///
/// # Errors
///
/// Fails if a segment is missing, or if the path tries to walk through
/// something that is not a directory.
pub async fn walk(&self, tree_oid: &str, path: &str) -> Result<Located, ApiError> {
let segments = split_path(path)?;
let last = segments.len().saturating_sub(1);
let mut current = tree_oid.to_owned();
let mut walked = Vec::new();
for (index, segment) in segments.into_iter().enumerate() {
let entries = self.read_tree(¤t).await?;
let found = entries
.into_iter()
.find(|e| e.name == segment)
.ok_or_else(|| not_found(&walked, &segment))?;
walked.push(segment);
if !found.is_dir() {
// A path may not continue through a file. Returning the file
// anyway would make `README.md/anything` quietly resolve to
// README.md, which is a wrong answer rather than an error.
if index != last {
return Err(ApiError(Fail {
code: "notfound".to_owned(),
message: format!(
"{} is a {}, so nothing can be under it",
walked.join("/"),
found.kind
),
}));
}
return Ok(Located::Entry {
entry: found,
path: walked.join("/"),
});
}
current = found.oid;
}
Ok(Located::Tree {
oid: current,
path: walked.join("/"),
})
}
}
/// What a path named.
#[derive(Debug, Clone)]
pub enum Located {
/// A directory, including the repository root.
Tree { oid: String, path: String },
/// Anything else: a file, a symlink, a submodule.
Entry { entry: Entry, path: String },
}
fn not_found(walked: &[String], missing: &str) -> ApiError {
let mut full = walked.to_vec();
full.push(missing.to_owned());
ApiError(Fail {
code: "notfound".to_owned(),
message: format!("{} is not in this revision", full.join("/")),
})
}
/// True when this looks like a full object id rather than a name.
///
/// Both lengths gunnar uses: 40 hex characters for sha1, 64 for sha256.
pub fn looks_like_oid(rev: &str) -> bool {
matches!(rev.len(), 40 | 64) && rev.chars().all(|c| c.is_ascii_hexdigit())
}
/// Split a path into segments, refusing anything that could escape the tree.
///
/// A git tree cannot contain `.`, `..`, an empty name or a slash, so a path that
/// asks for one is not a path that missed — it is a path that is trying
/// something.
pub fn split_path(path: &str) -> Result<Vec<String>, ApiError> {
let refused = |why: &str| {
Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: format!("{path:?} is not a path inside a repository: {why}"),
}))
};
let trimmed = path.trim_matches('/');
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut segments = Vec::new();
for segment in trimmed.split('/') {
match segment {
"" => return refused("it has an empty segment"),
"." | ".." => return refused("git trees contain no . or .. entries"),
_ if segment.contains('\0') => return refused("it contains a null byte"),
_ => segments.push(segment.to_owned()),
}
}
Ok(segments)
}
/// Decode a tree object's payload.
pub fn parse_tree(data: &[u8], hash_kind: gix_hash::Kind) -> Result<Vec<Entry>, String> {
use gix_object::tree::EntryKind;
use gix_object::TreeRefIter;
let mut entries = Vec::new();
for entry in TreeRefIter::from_bytes(data, hash_kind) {
let entry = entry.map_err(|e| e.to_string())?;
let name = String::from_utf8_lossy(entry.filename);
entries.push(Entry {
lossy_name: matches!(name, std::borrow::Cow::Owned(_)),
name: name.into_owned(),
kind: match entry.mode.kind() {
EntryKind::Tree => "dir",
EntryKind::Blob => "file",
EntryKind::BlobExecutable => "executable",
EntryKind::Link => "symlink",
EntryKind::Commit => "submodule",
},
mode: format!("{:o}", entry.mode.value()),
oid: entry.oid.to_hex().to_string(),
});
}
// git stores trees in its own order, which puts `a.txt` after `a/`.
// Directories first, then names, is what a person expects to read.
entries.sort_by(|a, b| {
b.is_dir()
.cmp(&a.is_dir())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// True when these bytes are not text this console can show.
///
/// git's own test is a null byte in the first 8000, and that is the first check
/// here. It is not enough on its own: a short file of arbitrary bytes has a good
/// chance of containing none, and the console would then render it as
/// replacement characters and call it a text file. Found by pushing 64 random
/// bytes and watching them come back as lines.
///
/// So anything that is not valid UTF-8 is binary too — with one exception. A
/// blob fetched under a size limit can be cut through the middle of a multi-byte
/// character, and that is the fetch's doing rather than the file's.
pub fn looks_binary(data: &[u8], truncated: bool) -> bool {
if data.iter().take(8000).any(|b| *b == 0) {
return true;
}
match std::str::from_utf8(data) {
Ok(_) => false,
Err(e) => {
// `error_len() == None` means the input simply ended mid-character.
let cut_short =
truncated && e.error_len().is_none() && e.valid_up_to() + 4 >= data.len();
!cut_short
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a tree object's payload by hand: `<mode> <name>\0<raw oid>`.
fn tree_bytes(entries: &[(&str, &str, usize)]) -> Vec<u8> {
let mut out = Vec::new();
for (mode, name, id_len) in entries {
out.extend_from_slice(mode.as_bytes());
out.push(b' ');
out.extend_from_slice(name.as_bytes());
out.push(0);
out.extend(std::iter::repeat_n(0xab, *id_len));
}
out
}
#[test]
fn a_sha256_tree_decodes_with_the_right_hash_kind() {
// 32-byte ids. Parsing this as sha1 does not error — it reads 20 bytes
// as the id and the rest as the next entry's mode, which is how a
// wrong hash kind turns into nonsense instead of a failure.
let data = tree_bytes(&[("40000", "src", 32), ("100644", "README.md", 32)]);
let entries = parse_tree(&data, gix_hash::Kind::Sha256).expect("a sha256 tree");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "src");
assert_eq!(entries[0].kind, "dir");
assert_eq!(entries[1].name, "README.md");
assert_eq!(entries[1].kind, "file");
}
#[test]
fn a_sha1_tree_decodes_too() {
let data = tree_bytes(&[("100755", "build.sh", 20)]);
let entries = parse_tree(&data, gix_hash::Kind::Sha1).expect("a sha1 tree");
assert_eq!(entries[0].kind, "executable");
assert_eq!(entries[0].name, "build.sh");
}
#[test]
fn the_wrong_hash_kind_does_not_quietly_succeed() {
// The guard against the mistake this module exists to avoid.
let data = tree_bytes(&[("40000", "src", 32), ("100644", "README.md", 32)]);
let misparsed = parse_tree(&data, gix_hash::Kind::Sha1);
match misparsed {
Err(_) => {}
Ok(entries) => assert_ne!(
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
vec!["src", "README.md"],
"sha1 parsing of a sha256 tree must not produce the right answer"
),
}
}
#[test]
fn directories_sort_before_files() {
let data = tree_bytes(&[
("100644", "a.txt", 32),
("40000", "zeta", 32),
("100644", "Beta.md", 32),
]);
let entries = parse_tree(&data, gix_hash::Kind::Sha256).expect("a tree");
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["zeta", "a.txt", "Beta.md"]);
}
#[test]
fn a_name_that_is_not_utf8_is_marked_unreachable() {
// git stores filenames as bytes. A lossy rendering can never be matched
// back against them, so a link built from it would always 404.
let mut data = Vec::new();
data.extend_from_slice(b"100644 ");
data.extend_from_slice(&[0xff, 0xfe, b'.', b't', b'x', b't']);
data.push(0);
data.extend(std::iter::repeat_n(0xab, 32));
let entries = parse_tree(&data, gix_hash::Kind::Sha256).expect("a tree");
assert!(entries[0].lossy_name);
assert!(entries[0].name.contains('\u{fffd}'));
}
#[test]
fn an_ordinary_name_is_not_marked_lossy() {
let data = tree_bytes(&[("100644", "räksmörgås.md", 32)]);
let entries = parse_tree(&data, gix_hash::Kind::Sha256).expect("a tree");
assert!(!entries[0].lossy_name, "valid UTF-8 is not lossy");
}
#[test]
fn submodules_and_symlinks_keep_their_own_kind() {
let data = tree_bytes(&[("120000", "link", 32), ("160000", "vendored", 32)]);
let entries = parse_tree(&data, gix_hash::Kind::Sha256).expect("a tree");
let kinds: Vec<&str> = entries.iter().map(|e| e.kind).collect();
assert!(kinds.contains(&"symlink"));
assert!(kinds.contains(&"submodule"));
}
#[test]
fn a_path_cannot_climb_out_of_the_tree() {
assert!(split_path("../../etc/passwd").is_err());
assert!(split_path("src/../../../etc").is_err());
assert!(split_path("src/./lib.rs").is_err());
assert!(split_path("a//b").is_err());
assert!(split_path("a\0b").is_err());
}
#[test]
fn an_object_id_is_told_apart_from_a_branch_name() {
assert!(looks_like_oid(&"a".repeat(64)));
assert!(looks_like_oid(&"0".repeat(40)));
assert!(!looks_like_oid("main"));
assert!(!looks_like_oid("refs/heads/main"));
// A branch could be named entirely in hex characters, but not at
// exactly one of git's two id lengths.
assert!(!looks_like_oid("deadbeef"));
}
#[test]
fn an_ordinary_path_splits() {
assert_eq!(split_path("").expect("root"), Vec::<String>::new());
assert_eq!(split_path("/").expect("root"), Vec::<String>::new());
assert_eq!(
split_path("/src/git/mod.rs/").expect("a path"),
vec!["src", "git", "mod.rs"]
);
}
#[test]
fn a_null_byte_makes_a_file_binary() {
assert!(looks_binary(b"\x7fELF\0\0\0", false));
assert!(!looks_binary(b"fn main() {}\n", false));
assert!(!looks_binary("räksmörgås\n".as_bytes(), false));
}
#[test]
fn bytes_that_are_not_utf8_are_binary_even_without_a_null() {
// The case e2e caught: 64 random bytes usually contain no zero, so the
// null-byte test alone let them through and the console rendered a
// screen of replacement characters.
assert!(looks_binary(&[0xff, 0xfe, 0x9c, 0x80, 0x41, 0x42], false));
}
#[test]
fn a_truncated_multibyte_character_is_not_a_binary_file() {
// Cut through the middle of the final å, which is two bytes.
let text = "räksmörgå".as_bytes();
let cut = &text[..text.len() - 1];
assert!(
!looks_binary(cut, true),
"the fetch cut it, the file is fine"
);
assert!(
looks_binary(cut, false),
"the same bytes from a complete fetch really are malformed"
);
}
#[test]
fn a_truncated_object_is_never_cached() {
// It would be stored under the real object's id, so every later full
// read of that object would return the stub instead.
let cache = ObjectCache::default();
let path = crate::api::repo_path("vetra", "gunnar");
cache.put(
&path,
Arc::new(Object {
oid: "abc".into(),
kind: "blob",
size: 9000,
data: vec![1, 2, 3],
truncated: true,
}),
);
assert!(cache.get(&path, "abc").is_none());
}
#[test]
fn a_complete_object_is_cached_and_found_again() {
let cache = ObjectCache::default();
let path = crate::api::repo_path("vetra", "gunnar");
cache.put(
&path,
Arc::new(Object {
oid: "abc".into(),
kind: "blob",
size: 3,
data: vec![1, 2, 3],
truncated: false,
}),
);
assert_eq!(cache.get(&path, "abc").expect("cached").size, 3);
assert!(
cache
.get(&crate::api::repo_path("vetra", "other"), "abc")
.is_none(),
"two repositories can hold the same object id with different contents"
);
}
#[test]
fn the_cache_stays_within_its_byte_bound() {
let cache = ObjectCache::default();
let path = crate::api::repo_path("vetra", "gunnar");
// Twice the bound, in 8 MiB pieces.
for i in 0..16 {
cache.put(
&path,
Arc::new(Object {
oid: format!("oid{i}"),
kind: "blob",
size: 0,
data: vec![0u8; 8 * 1024 * 1024],
truncated: false,
}),
);
}
let held = cache.inner.lock().expect("not poisoned").bytes;
assert!(
held <= CACHE_BYTES,
"{held} bytes held, bound is {CACHE_BYTES}"
);
assert!(cache.get(&path, "oid0").is_none(), "the oldest was evicted");
assert!(
cache.get(&path, "oid15").is_some(),
"the newest is still here"
);
}
}