Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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(&current).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"
        );
    }
}