michal/tit
Browse tree · Show commit · Download archive
Diff
f4ebe18477cf → 87bb8999bb6a
README.md
Mode 100644 → 100644; object d0a82b44e2ec → e71fe9ba9287
@@ -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 100644 → 100644; object c7918a27dc34 → 7763f704b376
@@ -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 100644 → 100644; object 39012e4eb437 → 620e263803a7
@@ -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 100644 → 100644; object 7e249a3517bb → 86437b648d06
@@ -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 100644 → 100644; object a236daaf5ebf → 933d7c5a09ff
@@ -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 100644 → 100644; object 351670cf7770 → 2beef387cfaf
@@ -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 100644 → 100644; object f8a6474db2c4 → ed4e71af7c17
@@ -81,6 +81,7 @@
assert!(summary_text.contains("<script>alert(3)</script>"));
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=\"<script>\""));
+ 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(