michal/tit

Browse tree · Show commit · Download archive

Diff

f4ebe18477cf87bb8999bb6a

README.md

Mode 100644100644; object d0a82b44e2ece71fe9ba9287

@@ -1,7 +1,8 @@
 # tit
 
 `tit` is a small, self-hosted collaborative development environment (CDE) for
-Git. The project is in Milestone 1D and does not serve repositories at this time.
+Git. The current implementation has a read-only Web UI, HTTP and SSH clone
+services, public feeds, and bounded source search.
 
 Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read
 [CONTRIBUTING.md](CONTRIBUTING.md) before you change code.
@@ -105,3 +106,16 @@
 
 The executable does not run Git or OpenSSH. Tests can use stock clients as
 external test drivers.
+
+## Milestone 2 gate
+
+Install stock Git and OpenSSH. Then, run the read-only CDE gate:
+
+```text
+./scripts/check-m2-8
+```
+
+This command runs the quality gate, measures source search without an index,
+and tests the public routes with SHA-1 and SHA-256 repositories. Read the
+[source search architectural decision record](docs/adr/0008-bounded-source-search.md)
+for the search limits and current measurement.

assets/style.css

Mode 100644100644; object c7918a27dc347763f704b376

@@ -103,6 +103,7 @@
 }
 
 input,
+select,
 button {
   max-width: 100%;
   padding: 0.6rem 0.75rem;

docs/adr/0008-bounded-source-search.md

Mode 100644; object f734b4af0d8e

@@ -1,0 +1,59 @@
+# Architectural decision record 0008: bounded source search
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+`tit` must search the source at a selected ref. The first implementation must
+not have a permanent index. A search must have file, byte, result, and time
+limits.
+
+## Decision
+
+Add a fixed-text, case-sensitive search to the repository read service. Resolve
+the selected ref to a commit before the search starts. Search the tree of this
+commit in Git path order. Do not search a submodule. Do not search a blob that
+contains a null byte.
+
+Use these default limits:
+
+- Search a maximum of 10,000 files.
+- Read a maximum total of 64 MiB of blob content.
+- Return a maximum of 500 matching lines.
+- Accept a maximum of 256 query bytes.
+- Stop a search after 5 seconds.
+
+The file, byte, and time limits stop the request with an error. The result
+limit returns the first 500 matching lines and tells the user that more results
+exist. Check the cancellation signal and the time limit during the tree and
+line scans.
+
+Publish `/{owner}/{repository}/search`. Use an HTTP GET form that operates
+without JavaScript. The form supplies `q` and `ref` parameters. Accept only an
+exact ref name that the repository read service returns. Use the resolved full
+commit ID in each result link. Do not publish the search route for a private or
+archived repository.
+
+Do not add an index. The release-mode workload has 2,000 unique files of 4 KiB
+each. It searches 8,192,000 bytes and the last file. On the development host,
+the search took 18.8 ms on 2026-07-23. This result does not require the state,
+migration, and update work of an index. Keep the workload in
+`tests/git_reads.rs` so that a contributor can measure it again.
+
+## Evidence
+
+Repository read tests cover SHA-1, SHA-256, binary content, malformed UTF-8
+content, cancellation, and each search limit. The public-route test covers the
+form, exact ref selection, immutable result links, malformed UTF-8 content,
+large content, HTML escaping, GET, HEAD, empty repositories, and hidden
+repositories.
+
+## Consequences
+
+A search reads Git objects for each request. It does not create state that can
+become out of date. Large repositories can reach a limit and must use more
+specific search text or an external checkout. Add a derivable index only when
+repeat measurements show that repositories inside the specified limits cannot
+meet the search time limit.

scripts/check-m2-8

Mode 100755; object e79d489fd1ee

@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
+cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats

src/git/read.rs

Mode 100644100644; object 39012e4eb437620e263803a7

@@ -24,6 +24,11 @@
     pub(crate) max_diff_bytes: usize,
     pub(crate) max_archive_entries: usize,
     pub(crate) max_archive_bytes: usize,
+    pub(crate) max_search_files: usize,
+    pub(crate) max_search_bytes: usize,
+    pub(crate) max_search_results: usize,
+    pub(crate) max_search_query_bytes: usize,
+    pub(crate) max_search_duration: Duration,
     pub(crate) max_duration: Duration,
 }
 
@@ -40,6 +45,11 @@
             max_diff_bytes: 64 * 1024 * 1024,
             max_archive_entries: 100_000,
             max_archive_bytes: 256 * 1024 * 1024,
+            max_search_files: 10_000,
+            max_search_bytes: 64 * 1024 * 1024,
+            max_search_results: 500,
+            max_search_query_bytes: 256,
+            max_search_duration: Duration::from_secs(5),
             max_duration: Duration::from_secs(30),
         }
     }
@@ -125,6 +135,22 @@
 pub(crate) struct ArchiveStats {
     pub(crate) entries: usize,
     pub(crate) bytes: usize,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct SearchMatch {
+    pub(crate) path: Vec<u8>,
+    pub(crate) line_number: usize,
+    pub(crate) line: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct SearchOutcome {
+    pub(crate) commit: ObjectId,
+    pub(crate) matches: Vec<SearchMatch>,
+    pub(crate) files_scanned: usize,
+    pub(crate) bytes_scanned: usize,
+    pub(crate) truncated: bool,
 }
 
 impl RepositoryReadService {
@@ -467,10 +493,89 @@
         })
     }
 
+    pub(crate) fn search(
+        &self,
+        commit: ObjectId,
+        query: &[u8],
+        cancellation: &ReadCancellation,
+    ) -> Result<SearchOutcome, ReadError> {
+        if query.is_empty() || query.contains(&0) {
+            return Err(ReadError::InvalidSearchQuery);
+        }
+        if query.len() > self.limits.max_search_query_bytes {
+            return Err(ReadError::Limit("search query bytes"));
+        }
+        let budget = self.budget_for(cancellation, self.limits.max_search_duration);
+        let commit_info = self.read_commit(commit, &budget)?;
+        let files = self.flatten_tree(commit_info.tree, &budget)?;
+        let mut matches = Vec::new();
+        let mut files_scanned = 0_usize;
+        let mut bytes_scanned = 0_usize;
+        let mut truncated = false;
+
+        'files: for (path, file) in files {
+            budget.check()?;
+            if file.mode.is_commit() {
+                continue;
+            }
+            files_scanned += 1;
+            if files_scanned > self.limits.max_search_files {
+                return Err(ReadError::Limit("search files"));
+            }
+            let header = self
+                .repository
+                .objects
+                .header(file.id)
+                .map_err(|error| ReadError::Git(error.to_string()))?;
+            let size =
+                usize::try_from(header.size()).map_err(|_| ReadError::Limit("search bytes"))?;
+            bytes_scanned = checked_add(
+                bytes_scanned,
+                size,
+                self.limits.max_search_bytes,
+                "search bytes",
+            )?;
+            let blob = self.read_blob(file.id, file.mode, &budget)?;
+            if blob.data.contains(&0) {
+                continue;
+            }
+            for (index, line) in blob.data.split(|byte| *byte == b'\n').enumerate() {
+                budget.check()?;
+                if !contains_bytes(line, query) {
+                    continue;
+                }
+                if matches.len() == self.limits.max_search_results {
+                    truncated = true;
+                    break 'files;
+                }
+                matches.push(SearchMatch {
+                    path: path.clone(),
+                    line_number: index + 1,
+                    line: line.strip_suffix(b"\r").unwrap_or(line).to_vec(),
+                });
+            }
+        }
+        Ok(SearchOutcome {
+            commit,
+            matches,
+            files_scanned,
+            bytes_scanned,
+            truncated,
+        })
+    }
+
     fn budget<'a>(&self, cancellation: &'a ReadCancellation) -> ReadBudget<'a> {
+        self.budget_for(cancellation, self.limits.max_duration)
+    }
+
+    fn budget_for<'a>(
+        &self,
+        cancellation: &'a ReadCancellation,
+        duration: Duration,
+    ) -> ReadBudget<'a> {
         ReadBudget {
             cancellation,
-            deadline: Instant::now() + self.limits.max_duration,
+            deadline: Instant::now() + duration,
         }
     }
 
@@ -857,11 +962,22 @@
         || limits.max_diff_bytes == 0
         || limits.max_archive_entries == 0
         || limits.max_archive_bytes < 1024
+        || limits.max_search_files == 0
+        || limits.max_search_bytes == 0
+        || limits.max_search_results == 0
+        || limits.max_search_query_bytes == 0
+        || limits.max_search_duration.is_zero()
         || limits.max_duration.is_zero()
     {
         return Err(ReadError::InvalidLimits);
     }
     Ok(())
+}
+
+fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+    haystack
+        .windows(needle.len())
+        .any(|candidate| candidate == needle)
 }
 
 fn validate_path(path: &[u8], permit_empty: bool, maximum: usize) -> Result<(), ReadError> {
@@ -1031,6 +1147,8 @@
     InvalidLimits,
     #[error("repository path is not valid")]
     InvalidPath,
+    #[error("repository search query is not valid")]
+    InvalidSearchQuery,
     #[error("repository path does not exist: {0:?}")]
     PathNotFound(Vec<u8>),
     #[error("repository path is not a tree: {0:?}")]

src/http/public.rs

Mode 100644100644; object 7e249a3517bb86437b648d06

@@ -26,7 +26,7 @@
 use crate::git::packetline::MAX_REQUEST_BYTES;
 use crate::git::read::{
     BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
-    RepositoryReadService, TreeEntryInfo,
+    RepositoryReadService, SearchOutcome, TreeEntryInfo,
 };
 use crate::git::upload_pack::{ProtocolVersion, UploadPack};
 use crate::markdown::{self, RenderedMarkdown};
@@ -36,6 +36,7 @@
 
 const MAX_HISTORY_COMMITS: usize = 10_000;
 const MAX_SUMMARY_COMMITS: usize = 50;
+const MAX_SEARCH_QUERY_BYTES: usize = 256;
 
 #[derive(Clone)]
 pub(super) struct PublicWeb {
@@ -227,6 +228,7 @@
         .route("/{owner}/{repository}/refs", get(refs))
         .route("/{owner}/{repository}/atom.xml", get(atom_feed))
         .route("/{owner}/{repository}/rss.xml", get(rss_feed))
+        .route("/{owner}/{repository}/search", get(search))
         .route("/{owner}/{repository}/commit/{commit}", get(commit))
         .route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
         .route("/{owner}/{repository}/tree/{commit}", get(tree_root))
@@ -371,6 +373,44 @@
             let cancellation = ReadCancellation::default();
             let references = service.references(&cancellation)?;
             Ok(RepositoryPage::refs(record, references))
+        })
+        .await;
+    render_page(result, &request_id.0)
+}
+
+async fn search(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<SearchQuery>,
+) -> Response {
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    if query.query.as_ref().is_some_and(|query| {
+        query.is_empty() || query.len() > MAX_SEARCH_QUERY_BYTES || query.as_bytes().contains(&0)
+    }) {
+        return route_error(RouteError::InvalidRequest, &request_id.0);
+    }
+    let result = web
+        .read(path.owner, path.repository, move |record, service| {
+            let cancellation = ReadCancellation::default();
+            let references = service.references(&cancellation)?;
+            let selected = select_search_ref(&references, query.reference.as_deref())?;
+            let outcome = match (&query.query, selected.as_ref()) {
+                (Some(query), Some((_, commit))) => {
+                    Some(service.search(*commit, query.as_bytes(), &cancellation)?)
+                }
+                (Some(_), None) => return Err(RouteError::NotFound),
+                (None, _) => None,
+            };
+            Ok(RepositoryPage::search(
+                record,
+                references,
+                selected,
+                query.query.unwrap_or_default(),
+                outcome,
+            ))
         })
         .await;
     render_page(result, &request_id.0)
@@ -961,6 +1001,15 @@
     before: Option<i64>,
 }
 
+#[derive(Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SearchQuery {
+    #[serde(rename = "q")]
+    query: Option<String>,
+    #[serde(rename = "ref")]
+    reference: Option<String>,
+}
+
 #[derive(Debug, Deserialize, Eq, PartialEq)]
 struct CommitPath {
     owner: String,
@@ -1012,6 +1061,14 @@
     diffs: Vec<DiffView>,
     refs: Vec<RefView>,
     blame: Vec<BlameView>,
+    search_query: String,
+    search_ref: String,
+    search_refs: Vec<SearchRefView>,
+    search_done: bool,
+    search_truncated: bool,
+    search_files: usize,
+    search_bytes: usize,
+    search_matches: Vec<SearchMatchView>,
 }
 
 impl RepositoryPage {
@@ -1043,6 +1100,14 @@
             diffs: Vec::new(),
             refs: Vec::new(),
             blame: Vec::new(),
+            search_query: String::new(),
+            search_ref: String::new(),
+            search_refs: Vec::new(),
+            search_done: false,
+            search_truncated: false,
+            search_files: 0,
+            search_bytes: 0,
+            search_matches: Vec::new(),
         }
     }
 
@@ -1178,6 +1243,54 @@
         }
         page
     }
+
+    fn search(
+        record: RepositoryRecord,
+        references: Vec<crate::git::read::RefInfo>,
+        selected: Option<(Vec<u8>, ObjectId)>,
+        query: String,
+        outcome: Option<SearchOutcome>,
+    ) -> Self {
+        let mut page = Self::base(record, "search", "Search".to_owned());
+        page.search_query = query;
+        if let Some((name, commit)) = selected {
+            page.has_head = true;
+            page.search_ref = display_bytes(&name);
+            page.commit_id = commit.to_string();
+        }
+        page.search_refs = references
+            .into_iter()
+            .filter(|reference| {
+                reference.name == b"HEAD"
+                    || reference.name.starts_with(b"refs/heads/")
+                    || reference.name.starts_with(b"refs/tags/")
+            })
+            .filter_map(|reference| {
+                let name = std::str::from_utf8(&reference.name).ok()?.to_owned();
+                Some(SearchRefView {
+                    selected: name == page.search_ref,
+                    name,
+                })
+            })
+            .collect();
+        if let Some(outcome) = outcome {
+            page.search_done = true;
+            page.search_truncated = outcome.truncated;
+            page.search_files = outcome.files_scanned;
+            page.search_bytes = outcome.bytes_scanned;
+            page.search_matches = outcome
+                .matches
+                .into_iter()
+                .map(|item| SearchMatchView {
+                    path: display_bytes(&item.path),
+                    encoded_path: encode_path(&item.path),
+                    line_number: item.line_number,
+                    line: display_bytes(&item.line),
+                })
+                .collect();
+        }
+        page
+    }
 }
 
 #[derive(Default)]
@@ -1291,6 +1404,18 @@
     content: String,
 }
 
+struct SearchRefView {
+    name: String,
+    selected: bool,
+}
+
+struct SearchMatchView {
+    path: String,
+    encoded_path: String,
+    line_number: usize,
+    line: String,
+}
+
 impl BlameView {
     fn new(hunk: BlameHunk, lines: &[&str]) -> Self {
         let start = usize::try_from(hunk.start_line.saturating_sub(1)).unwrap_or(usize::MAX);
@@ -1333,10 +1458,43 @@
             | ReadError::NotTree(_)
             | ReadError::NotBlob(_)
             | ReadError::WrongObjectKind { .. } => Self::NotFound,
+            ReadError::InvalidSearchQuery => Self::InvalidRequest,
             ReadError::Limit(_) | ReadError::Cancelled | ReadError::Deadline => Self::Unavailable,
             _ => Self::Internal,
         }
     }
+}
+
+fn select_search_ref(
+    references: &[crate::git::read::RefInfo],
+    requested: Option<&str>,
+) -> Result<Option<(Vec<u8>, ObjectId)>, RouteError> {
+    if requested.is_some_and(|name| name.is_empty() || name.len() > 4096) {
+        return Err(RouteError::InvalidRequest);
+    }
+    let reference = match requested {
+        Some(requested) => references
+            .iter()
+            .find(|reference| reference.name == requested.as_bytes())
+            .ok_or(RouteError::NotFound)?,
+        None => match references
+            .iter()
+            .find(|reference| reference.name == b"HEAD")
+        {
+            Some(reference) => reference,
+            None => match references.iter().find(|reference| {
+                reference.name.starts_with(b"refs/heads/")
+                    || reference.name.starts_with(b"refs/tags/")
+            }) {
+                Some(reference) => reference,
+                None => return Ok(None),
+            },
+        },
+    };
+    Ok(Some((
+        reference.name.clone(),
+        reference.peeled.unwrap_or(reference.target),
+    )))
 }
 
 impl From<StoreError> for RouteError {

templates/repository.html

Mode 100644100644; object a236daaf5ebf933d7c5a09ff

@@ -8,6 +8,7 @@
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
       <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+      <a href="/{{ owner }}/{{ repository }}/search">Search</a>
 {% if has_head %}
       <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
       <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>
@@ -134,6 +135,37 @@
     <li value="{{ item.start_line }}"><a href="/{{ owner }}/{{ repository }}/commit/{{ item.commit_id }}"><code>{{ item.commit_id }}</code></a> lines {{ item.source_start_line }}–{{ item.end_line }}{% if item.source_path != "" %} from {{ item.source_path }}{% endif %}<pre>{{ item.content }}</pre></li>
 {% endfor %}
   </ol>
+{% endif %}
+{% endif %}
+
+{% if page_kind == "search" %}
+  <h2>Source search</h2>
+{% if has_head %}
+  <form method="get" action="/{{ owner }}/{{ repository }}/search">
+    <div class="field">
+      <label for="search-query">Text</label>
+      <input id="search-query" name="q" type="search" value="{{ search_query }}" required maxlength="256">
+    </div>
+    <div class="field">
+      <label for="search-ref">Ref</label>
+      <select id="search-ref" name="ref">
+{% for item in search_refs %}
+        <option value="{{ item.name }}"{% if item.selected %} selected{% endif %}>{{ item.name }}</option>
+{% endfor %}
+      </select>
+    </div>
+    <button type="submit">Search</button>
+  </form>
+{% if search_done %}
+  <p>Scanned {{ search_files }} files and {{ search_bytes }} bytes. Found {{ search_matches.len() }} matching lines.{% if search_truncated %} More results exist. Refine the search text.{% endif %}</p>
+  <ol class="search-results">
+{% for item in search_matches %}
+    <li><a href="/{{ owner }}/{{ repository }}/blob/{{ commit_id }}/{{ item.encoded_path }}">{{ item.path }}:{{ item.line_number }}</a><pre>{{ item.line }}</pre></li>
+{% endfor %}
+  </ol>
+{% endif %}
+{% else %}
+  <p>This repository has no commits to search.</p>
 {% endif %}
 {% endif %}
 {% endblock %}

tests/git_reads.rs

Mode 100644100644; object 351670cf77702beef387cfaf

@@ -9,7 +9,7 @@
 use std::fs;
 use std::path::{Path, PathBuf};
 use std::process::Command;
-use std::time::Duration;
+use std::time::{Duration, Instant};
 
 use gix::hash::ObjectId;
 use read::{ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
@@ -100,6 +100,28 @@
             .expect("find a README");
         assert_eq!(readme.path, b"README.md");
         assert_eq!(readme.blob.data, b"Tit read service\n");
+
+        let search = service
+            .search(fixture.second, b"changed", &cancellation)
+            .expect("search source");
+        assert_eq!(search.commit, fixture.second);
+        assert_eq!(search.matches.len(), 1);
+        assert_eq!(search.matches[0].path, b"src/lib.rs");
+        assert_eq!(search.matches[0].line_number, 2);
+        assert_eq!(search.matches[0].line, b"changed");
+        assert!(!search.truncated);
+        let non_utf8 = service
+            .search(fixture.second, b"needle", &cancellation)
+            .expect("search non-UTF-8 source");
+        assert_eq!(non_utf8.matches[0].path, b"docs/note.txt");
+        assert_eq!(non_utf8.matches[0].line, b"note \xff needle");
+        assert_eq!(
+            service
+                .search(fixture.second, b"binary", &cancellation)
+                .expect("skip binary content")
+                .matches,
+            []
+        );
     }
 }
 
@@ -276,6 +298,61 @@
         Err(ReadError::Limit("archive bytes"))
     ));
 
+    let limits = ReadLimits {
+        max_search_files: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.search(fixture.second, b"missing", &cancellation),
+        Err(ReadError::Limit("search files"))
+    ));
+
+    let limits = ReadLimits {
+        max_search_bytes: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.search(fixture.second, b"missing", &cancellation),
+        Err(ReadError::Limit("search bytes"))
+    ));
+
+    let limits = ReadLimits {
+        max_search_results: 1,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    let search = service
+        .search(fixture.second, b"e", &cancellation)
+        .expect("truncate search results");
+    assert_eq!(search.matches.len(), 1);
+    assert!(search.truncated);
+
+    let limits = ReadLimits {
+        max_search_query_bytes: 3,
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.search(fixture.second, b"four", &cancellation),
+        Err(ReadError::Limit("search query bytes"))
+    ));
+    assert!(matches!(
+        service.search(fixture.second, b"", &cancellation),
+        Err(ReadError::InvalidSearchQuery)
+    ));
+
+    let limits = ReadLimits {
+        max_search_duration: Duration::from_nanos(1),
+        ..ReadLimits::default()
+    };
+    let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+    assert!(matches!(
+        service.search(fixture.second, b"changed", &cancellation),
+        Err(ReadError::Deadline)
+    ));
+
     let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
         .expect("open the service");
     for path in [b"".as_slice(), b"/src", b"src/", b"src//lib.rs", b"../HEAD"] {
@@ -290,6 +367,10 @@
         service.history(fixture.second, &cancelled),
         Err(ReadError::Cancelled)
     ));
+    assert!(matches!(
+        service.search(fixture.second, b"changed", &cancelled),
+        Err(ReadError::Cancelled)
+    ));
 
     let limits = ReadLimits {
         max_duration: Duration::ZERO,
@@ -299,6 +380,56 @@
         RepositoryReadService::open(&fixture.bare, limits),
         Err(ReadError::InvalidLimits)
     ));
+}
+
+#[test]
+#[ignore = "M2.8 representative search measurement"]
+fn measures_bounded_search_without_an_index() {
+    let directory = TempDir::new().expect("create a measurement directory");
+    let worktree = directory.path().join("worktree");
+    run(Command::new("git")
+        .args(["init", "-q", "-b", "main"])
+        .arg(&worktree));
+    run(Command::new("git")
+        .args(["config", "commit.gpgsign", "false"])
+        .current_dir(&worktree));
+    for index in 0..2_000 {
+        let mut content = format!("file {index:05} needle-{index:05}\n").into_bytes();
+        content.resize(4_096, b'x');
+        fs::write(worktree.join(format!("file-{index:05}.txt")), content)
+            .expect("write a measurement file");
+    }
+    run(Command::new("git")
+        .args(["add", "."])
+        .current_dir(&worktree));
+    run(Command::new("git")
+        .args(["commit", "-q", "-m", "measurement"])
+        .env("GIT_AUTHOR_NAME", "Tit Test")
+        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
+        .env("GIT_COMMITTER_NAME", "Tit Test")
+        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
+        .current_dir(&worktree));
+    let head = revision(&worktree, "HEAD");
+    let bare = directory.path().join("repository.git");
+    run(Command::new("git")
+        .args(["clone", "-q", "--bare", "--no-local"])
+        .arg(&worktree)
+        .arg(&bare));
+    let service = RepositoryReadService::open(&bare, ReadLimits::default())
+        .expect("open the measurement repository");
+    let started = Instant::now();
+    let outcome = service
+        .search(head, b"needle-01999", &ReadCancellation::default())
+        .expect("search the measurement repository");
+    let elapsed = started.elapsed();
+    assert_eq!(outcome.files_scanned, 2_000);
+    assert_eq!(outcome.bytes_scanned, 2_000 * 4_096);
+    assert_eq!(outcome.matches.len(), 1);
+    assert!(!outcome.truncated);
+    eprintln!(
+        "searched {} files and {} bytes in {elapsed:?}",
+        outcome.files_scanned, outcome.bytes_scanned
+    );
 }
 
 fn fixture(object_format: &str) -> Fixture {
@@ -339,7 +470,7 @@
     fs::create_dir(worktree.join("docs")).expect("create a documentation directory");
     fs::write(worktree.join("src/lib.rs"), b"one\nchanged\nthree\n").expect("change source");
     fs::write(worktree.join("binary"), b"new\0binary").expect("change binary content");
-    fs::write(worktree.join("docs/note.txt"), b"note\n").expect("write documentation");
+    fs::write(worktree.join("docs/note.txt"), b"note \xff needle\n").expect("write documentation");
     let long_directory = worktree.join("docs").join("a".repeat(60));
     fs::create_dir(&long_directory).expect("create a long archive directory");
     fs::write(

tests/public_routes.rs

Mode 100644100644; object f8a6474db2c4ed4e71af7c17

@@ -81,6 +81,7 @@
         assert!(summary_text.contains("&#60;script&#62;alert(3)&#60;/script&#62;"));
         assert!(summary_text.contains("/alice/example/atom.xml"));
         assert!(summary_text.contains("/alice/example/rss.xml"));
+        assert!(summary_text.contains("/alice/example/search"));
 
         let mut feed_entry_ids = Vec::new();
         for (path, content_type) in [
@@ -183,6 +184,13 @@
         let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
         assert_eq!(empty.status, 200);
         assert!(empty.text().contains("This repository has no commits."));
+        let empty_search = request(server.address(), "GET", "/alice/empty/search", &[], &[]);
+        assert_eq!(empty_search.status, 200);
+        assert!(
+            empty_search
+                .text()
+                .contains("This repository has no commits to search.")
+        );
 
         let alias = request(server.address(), "GET", "/alice/example.git", &[], &[]);
         assert_eq!(alias.status, 308);
@@ -190,6 +198,7 @@
 
         let routes = [
             "/alice/example/refs".to_owned(),
+            "/alice/example/search".to_owned(),
             format!("/alice/example/commit/{}", fixture.head),
             format!("/alice/example/tree/{}", fixture.head),
             format!("/alice/example/tree/{}/nested", fixture.head),
@@ -211,6 +220,68 @@
                 response.body.len().to_string()
             );
         }
+
+        let search = request(
+            server.address(),
+            "GET",
+            "/alice/example/search?q=second%20line&ref=HEAD",
+            &[],
+            &[],
+        );
+        assert_eq!(search.status, 200);
+        assert_html_policy(&search);
+        let search_text = search.text();
+        assert!(search_text.contains("Found 1 matching lines."));
+        assert!(search_text.contains("nested/file.txt:2"));
+        assert!(search_text.contains(&format!(
+            "/alice/example/blob/{}/nested/file.txt",
+            fixture.head
+        )));
+        assert!(search_text.contains("<option value=\"HEAD\" selected>HEAD</option>"));
+
+        let malformed = request(
+            server.address(),
+            "GET",
+            "/alice/example/search?q=needle&ref=refs%2Fheads%2Fmain",
+            &[],
+            &[],
+        );
+        assert_eq!(malformed.status, 200);
+        assert!(malformed.text().contains("malformed.txt:1"));
+        assert!(malformed.text().contains("start � needle"));
+
+        let escaped = request(
+            server.address(),
+            "GET",
+            "/alice/example/search?q=%3Cscript%3E&ref=HEAD",
+            &[],
+            &[],
+        );
+        assert_eq!(escaped.status, 200);
+        assert!(!escaped.text().contains("value=\"<script>\""));
+        assert!(escaped.text().contains("value=\"&#60;script&#62;\""));
+        assert_eq!(
+            request(
+                server.address(),
+                "GET",
+                "/alice/example/search?q=&ref=HEAD",
+                &[],
+                &[],
+            )
+            .status,
+            400
+        );
+        assert_eq!(
+            request(
+                server.address(),
+                "GET",
+                "/alice/example/search?q=text&ref=refs%2Fheads%2Fmissing",
+                &[],
+                &[],
+            )
+            .status,
+            404
+        );
 
         let tree = request(
             server.address(),
@@ -249,6 +320,18 @@
         );
         assert_eq!(binary.status, 200);
         assert!(binary.text().contains("Binary content cannot be shown."));
+
+        let large = request(
+            server.address(),
+            "GET",
+            &format!("/alice/example/blob/{}/large.txt", fixture.head),
+            &[],
+            &[],
+        );
+        assert_eq!(large.status, 200);
+        assert!(large.body.len() > 2 * 1024 * 1024);
+        assert!(large.text().contains("large content starts"));
+        assert!(large.text().contains("large content ends"));
 
         let raw = request(
             server.address(),
@@ -463,6 +546,7 @@
         format!("/alice/example/raw/{head}/README.md"),
         "/alice/example/atom.xml".to_owned(),
         "/alice/example/rss.xml".to_owned(),
+        "/alice/example/search?q=text".to_owned(),
         "/alice/example/info/refs?service=git-upload-pack".to_owned(),
     ] {
         let response = request(address, "GET", &route, &[], &[]);
@@ -507,6 +591,13 @@
         fs::create_dir(worktree.join("nested")).expect("create a nested directory");
         fs::write(worktree.join("nested/file.txt"), b"first line\n").expect("write the text file");
         fs::write(worktree.join("binary.dat"), b"binary\0content").expect("write the binary file");
+        let mut large = vec![b'x'; 2 * 1024 * 1024];
+        let prefix = b"large content starts\n";
+        large[..prefix.len()].copy_from_slice(prefix);
+        let suffix = b"\nlarge content ends\n";
+        let suffix_start = large.len() - suffix.len();
+        large[suffix_start..].copy_from_slice(suffix);
+        fs::write(worktree.join("large.txt"), large).expect("write the large file");
         fs::write(
             worktree.join("<img src=x onerror=alert(4)>.txt"),
             b"escaped path\n",
@@ -514,6 +605,8 @@
         .expect("write the hostile path");
         fs::write(worktree.join("non-å.txt"), b"non-UTF-8 path\n")
             .expect("write the percent-encoded path");
+        fs::write(worktree.join("malformed.txt"), b"start \xff needle\n")
+            .expect("write malformed UTF-8 content");
         commit_all(&worktree, "first commit");
         let parent = rev_parse(&worktree, "HEAD");
         fs::write(