Tahinli/forge
public0 watching
★ 0
Milestone 2: repo browsing and the raw domain (contract between workers)
Scope comes from docs/architecture.md §5.5, §7.4 and "M2". Global rules from docs/m1-interfaces.md still apply: latest dependencies added to root [workspace.dependencies] with { workspace = true } in crates, no shell, no unwrap in non-test code, and clippy -D warnings must be clean.
Rules for user-supplied revisions
- The git CLI only ever receives full hex OIDs. User input like a branch, tag or short SHA is resolved to a full commit OID first with
browse::resolve_commit(gix, in-process). - Accepted rev input is either a ref name that exists under
refs/heads/orrefs/tags/, or a 4–64-char hex OID prefix. Anything else (..,@{,:,^,~, a leading-, control characters) resolves toNone. - Paths inside a repo are
/-separated, with no empty segment,.or.., no NUL, and at most 4096 bytes. - Symlinks: blobs are read from the object database, never from the filesystem, so symlinks can't be followed. A symlink entry (mode 120000) is shown as its target text and is never resolved.
forge-git/src/browse.rs (gix, blocking; callers use tokio::task::spawn_blocking)
pub enum RefKind { Branch, Tag }
pub struct RefInfo { pub name: String, pub full_name: String, pub target: String, pub kind: RefKind } // target = peeled commit oid
pub fn list_refs(repo: &Path) -> Result<Vec<RefInfo>>;
pub fn default_branch_commit(repo: &Path, branch: &str) -> Result<Option<String>>;
pub fn resolve_commit(repo: &Path, rev: &str) -> Result<Option<String>>; // full commit oid
/// "feature/x/src/main.rs": longest existing branch/tag prefix wins; else a leading hex oid segment.
/// Returns (ref_as_given, commit_oid, path) with path possibly "".
pub fn split_ref_and_path(repo: &Path, rest: &str) -> Result<Option<(String, String, String)>>;
pub enum EntryKind { Blob, Executable, Symlink, Tree, Submodule }
pub struct TreeEntry { pub name: String, pub kind: EntryKind, pub oid: String, pub size: Option<u64> }
pub fn read_tree(repo: &Path, commit: &str, path: &str) -> Result<Option<Vec<TreeEntry>>>; // dirs first, then name order
pub struct BlobData { pub oid: String, pub kind: EntryKind, pub size: u64, pub data: Vec<u8>, pub truncated: bool, pub is_binary: bool }
pub fn read_blob(repo: &Path, commit: &str, path: &str, max_bytes: usize) -> Result<Option<BlobData>>;
pub struct CommitInfo { pub oid: String, pub parents: Vec<String>, pub author_name: String, pub author_email: String,
pub author_time: i64, pub committer_name: String, pub committer_email: String, pub committer_time: i64,
pub summary: String, pub message: String }
pub fn read_commit(repo: &Path, oid: &str) -> Result<Option<CommitInfo>>;
pub fn validate_repo_path(path: &str) -> Result<()>; // the rules above
is_binary means a NUL byte appears in the first 8000 bytes.
forge-git/src/history.rs (git CLI, async, OIDs only)
pub async fn log(env: &GitEnv, repo: &Path, commit: &str, path: Option<&str>, skip: usize, limit: usize) -> Result<Vec<browse::CommitInfo>>;
pub enum DiffStatus { Added, Deleted, Modified, Renamed, Copied, TypeChanged }
pub enum LineKind { Context, Add, Del, NoNewline }
pub struct DiffLine { pub kind: LineKind, pub old_no: Option<u32>, pub new_no: Option<u32>, pub text: String }
pub struct Hunk { pub header: String, pub lines: Vec<DiffLine> }
pub struct FileDiff { pub old_path: Option<String>, pub new_path: Option<String>, pub status: DiffStatus,
pub is_binary: bool, pub additions: u32, pub deletions: u32, pub hunks: Vec<Hunk>, pub truncated: bool }
pub struct DiffLimits { pub max_files: usize, pub max_lines_per_file: usize, pub max_total_lines: usize }
pub struct CommitDiff { pub files: Vec<FileDiff>, pub truncated_files: bool }
pub async fn commit_diff(env: &GitEnv, repo: &Path, oid: &str, limits: DiffLimits) -> Result<CommitDiff>;
// vs first parent (root commit vs empty tree), rename detection -M, --no-color, --no-ext-diff, --no-textconv
pub async fn last_commits(env: &GitEnv, repo: &Path, commit: &str, dir: &str, names: &[String], max_walk: usize)
-> Result<HashMap<String, browse::CommitInfo>>; // one `git log` walk, not N calls
pub enum ArchiveFormat { TarGz, Zip }
pub fn archive_command(env: &GitEnv, repo: &Path, commit: &str, format: ArchiveFormat, prefix: &str) -> Result<GitCommand>;
forge-core/src/raw_token.rs (signed URLs for private raw files)
pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String; // "<expires>.<base64url(hmac_sha256)>"
pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool; // constant-time; rejects expired
The key is derived for this purpose: HMAC over "raw-v1:" + repo_id + ":" + expires.
forge-web/src/render/ (pure functions)
// markdown.rs: comrak (GFM: tables, tasklists, strikethrough, autolink, footnotes, alerts), then ammonia
pub struct LinkContext { pub tree_base: String, pub blob_base: String, pub raw_base: String } // each ends at the current dir, with no trailing slash
pub fn render_markdown(src: &str, ctx: Option<&LinkContext>) -> String;
// Relative links: page links go to blob_base/tree_base, images to raw_base. Absolute http(s) and mailto are kept;
// any other scheme is dropped. Headings get id="user-content-<slug>". No inline styles (the CSP has no 'unsafe-inline').
// highlight.rs: syntect with ClassedHTMLGenerator (class-based, never inline style)
pub fn highlight_lines(file_name: &str, text: &str) -> Option<Vec<String>>; // one HTML fragment per line; None if unknown or >512 KiB or >20k lines
pub fn highlight_css() -> String; // light + dark (prefers-color-scheme), served at /assets/highlight.css
Web routes (app origin)
/{owner}/{repo}: repo home. Shows the default-branch tree plus the rendered README (README.md, README, readme.markdown), or the empty-repo help./{owner}/{repo}/tree/{*rest}: a directory listing with the last commit per entry. A breadcrumb links each path segment. The branch/tag selector is a plain list of links./{owner}/{repo}/blob/{*rest}: a file view.- Text files are highlighted with line numbers and
#L12anchors. - Markdown files are rendered, with a toggle to see the source.
- Images get an
<img>that points at the raw origin. - Binary files get a "download raw" link.
- Large files are truncated with a link to raw.
- Text files are highlighted with line numbers and
/{owner}/{repo}/commits/{*rest}: commit history for a ref and optional path, paginated with?page=, 50 per page./{owner}/{repo}/commit/{oid}: commit metadata plus the diff (file list with stats, unified hunks). Large or binary diffs are collapsed./{owner}/{repo}/archive/{ref}.tar.gzand.zip: streamed, withContent-Disposition: attachment./{owner}/{repo}/branchesand/{owner}/{repo}/tags.- Every route authorizes Read through
forge_core::authz::authorize. A missing repo or one the user can't see returns 404.
Raw origin (separate listener user_content_listen, explicit routes, NOT .discover())
GET /{owner}/{repo}/{*rest}:restis<ref>/<path>, split withsplit_ref_and_path.- Public repo: no auth. Private repo: requires
?token=that passesverify_raw(the app embeds signed links valid for 10 min). No cookies are read or set on this origin. - Headers:
X-Content-Type-Options: nosniffContent-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandboxCross-Origin-Resource-Policy: cross-originReferrer-Policy: no-referrerCache-Control: private, max-age=300(or public for public repos)
- Content-Type:
- png, jpg, jpeg, gif, webp, avif and ico are served as their image types
- other text is
text/plain; charset=utf-8 - everything else is
application/octet-stream
- Additionally
Content-Disposition: attachmentfor svg, html, htm, xhtml, xml and any non-image binary. - Missing, hidden or bad token: 404.