Tahinli/forge
public0 watching
★ 0
| 1 | # Milestone 2: repo browsing and the raw domain (contract between workers) |
| 2 | |
| 3 | 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. |
| 4 | |
| 5 | ## Rules for user-supplied revisions |
| 6 | - **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). |
| 7 | - Accepted rev input is either a ref name that exists under `refs/heads/` or `refs/tags/`, or a 4–64-char hex OID prefix. Anything else (`..`, `@{`, `:`, `^`, `~`, a leading `-`, control characters) resolves to `None`. |
| 8 | - Paths inside a repo are `/`-separated, with no empty segment, `.` or `..`, no NUL, and at most 4096 bytes. |
| 9 | - **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. |
| 10 | |
| 11 | ## forge-git/src/browse.rs (gix, blocking; callers use `tokio::task::spawn_blocking`) |
| 12 | ```rust |
| 13 | pub enum RefKind { Branch, Tag } |
| 14 | pub struct RefInfo { pub name: String, pub full_name: String, pub target: String, pub kind: RefKind } // target = peeled commit oid |
| 15 | pub fn list_refs(repo: &Path) -> Result<Vec<RefInfo>>; |
| 16 | pub fn default_branch_commit(repo: &Path, branch: &str) -> Result<Option<String>>; |
| 17 | pub fn resolve_commit(repo: &Path, rev: &str) -> Result<Option<String>>; // full commit oid |
| 18 | /// "feature/x/src/main.rs": longest existing branch/tag prefix wins; else a leading hex oid segment. |
| 19 | /// Returns (ref_as_given, commit_oid, path) with path possibly "". |
| 20 | pub fn split_ref_and_path(repo: &Path, rest: &str) -> Result<Option<(String, String, String)>>; |
| 21 | pub enum EntryKind { Blob, Executable, Symlink, Tree, Submodule } |
| 22 | pub struct TreeEntry { pub name: String, pub kind: EntryKind, pub oid: String, pub size: Option<u64> } |
| 23 | pub fn read_tree(repo: &Path, commit: &str, path: &str) -> Result<Option<Vec<TreeEntry>>>; // dirs first, then name order |
| 24 | pub struct BlobData { pub oid: String, pub kind: EntryKind, pub size: u64, pub data: Vec<u8>, pub truncated: bool, pub is_binary: bool } |
| 25 | pub fn read_blob(repo: &Path, commit: &str, path: &str, max_bytes: usize) -> Result<Option<BlobData>>; |
| 26 | pub struct CommitInfo { pub oid: String, pub parents: Vec<String>, pub author_name: String, pub author_email: String, |
| 27 | pub author_time: i64, pub committer_name: String, pub committer_email: String, pub committer_time: i64, |
| 28 | pub summary: String, pub message: String } |
| 29 | pub fn read_commit(repo: &Path, oid: &str) -> Result<Option<CommitInfo>>; |
| 30 | pub fn validate_repo_path(path: &str) -> Result<()>; // the rules above |
| 31 | ``` |
| 32 | `is_binary` means a NUL byte appears in the first 8000 bytes. |
| 33 | |
| 34 | ## forge-git/src/history.rs (git CLI, async, OIDs only) |
| 35 | ```rust |
| 36 | pub async fn log(env: &GitEnv, repo: &Path, commit: &str, path: Option<&str>, skip: usize, limit: usize) -> Result<Vec<browse::CommitInfo>>; |
| 37 | pub enum DiffStatus { Added, Deleted, Modified, Renamed, Copied, TypeChanged } |
| 38 | pub enum LineKind { Context, Add, Del, NoNewline } |
| 39 | pub struct DiffLine { pub kind: LineKind, pub old_no: Option<u32>, pub new_no: Option<u32>, pub text: String } |
| 40 | pub struct Hunk { pub header: String, pub lines: Vec<DiffLine> } |
| 41 | pub struct FileDiff { pub old_path: Option<String>, pub new_path: Option<String>, pub status: DiffStatus, |
| 42 | pub is_binary: bool, pub additions: u32, pub deletions: u32, pub hunks: Vec<Hunk>, pub truncated: bool } |
| 43 | pub struct DiffLimits { pub max_files: usize, pub max_lines_per_file: usize, pub max_total_lines: usize } |
| 44 | pub struct CommitDiff { pub files: Vec<FileDiff>, pub truncated_files: bool } |
| 45 | pub async fn commit_diff(env: &GitEnv, repo: &Path, oid: &str, limits: DiffLimits) -> Result<CommitDiff>; |
| 46 | // vs first parent (root commit vs empty tree), rename detection -M, --no-color, --no-ext-diff, --no-textconv |
| 47 | pub async fn last_commits(env: &GitEnv, repo: &Path, commit: &str, dir: &str, names: &[String], max_walk: usize) |
| 48 | -> Result<HashMap<String, browse::CommitInfo>>; // one `git log` walk, not N calls |
| 49 | pub enum ArchiveFormat { TarGz, Zip } |
| 50 | pub fn archive_command(env: &GitEnv, repo: &Path, commit: &str, format: ArchiveFormat, prefix: &str) -> Result<GitCommand>; |
| 51 | ``` |
| 52 | |
| 53 | ## forge-core/src/raw_token.rs (signed URLs for private raw files) |
| 54 | ```rust |
| 55 | pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String; // "<expires>.<base64url(hmac_sha256)>" |
| 56 | pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool; // constant-time; rejects expired |
| 57 | ``` |
| 58 | The key is derived for this purpose: HMAC over `"raw-v1:" + repo_id + ":" + expires`. |
| 59 | |
| 60 | ## forge-web/src/render/ (pure functions) |
| 61 | ```rust |
| 62 | // markdown.rs: comrak (GFM: tables, tasklists, strikethrough, autolink, footnotes, alerts), then ammonia |
| 63 | 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 |
| 64 | pub fn render_markdown(src: &str, ctx: Option<&LinkContext>) -> String; |
| 65 | // Relative links: page links go to blob_base/tree_base, images to raw_base. Absolute http(s) and mailto are kept; |
| 66 | // any other scheme is dropped. Headings get id="user-content-<slug>". No inline styles (the CSP has no 'unsafe-inline'). |
| 67 | // highlight.rs: syntect with ClassedHTMLGenerator (class-based, never inline style) |
| 68 | 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 |
| 69 | pub fn highlight_css() -> String; // light + dark (prefers-color-scheme), served at /assets/highlight.css |
| 70 | ``` |
| 71 | |
| 72 | ## Web routes (app origin) |
| 73 | - `/{owner}/{repo}`: repo home. Shows the default-branch tree plus the rendered README (README.md, README, readme.markdown), or the empty-repo help. |
| 74 | - `/{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. |
| 75 | - `/{owner}/{repo}/blob/{*rest}`: a file view. |
| 76 | - Text files are highlighted with line numbers and `#L12` anchors. |
| 77 | - Markdown files are rendered, with a toggle to see the source. |
| 78 | - Images get an `<img>` that points at the **raw origin**. |
| 79 | - Binary files get a "download raw" link. |
| 80 | - Large files are truncated with a link to raw. |
| 81 | - `/{owner}/{repo}/commits/{*rest}`: commit history for a ref and optional path, paginated with `?page=`, 50 per page. |
| 82 | - `/{owner}/{repo}/commit/{oid}`: commit metadata plus the diff (file list with stats, unified hunks). Large or binary diffs are collapsed. |
| 83 | - `/{owner}/{repo}/archive/{ref}.tar.gz` and `.zip`: streamed, with `Content-Disposition: attachment`. |
| 84 | - `/{owner}/{repo}/branches` and `/{owner}/{repo}/tags`. |
| 85 | - Every route authorizes Read through `forge_core::authz::authorize`. A missing repo or one the user can't see returns **404**. |
| 86 | |
| 87 | ## Raw origin (separate listener `user_content_listen`, explicit routes, NOT `.discover()`) |
| 88 | - `GET /{owner}/{repo}/{*rest}`: `rest` is `<ref>/<path>`, split with `split_ref_and_path`. |
| 89 | - Public repo: no auth. Private repo: requires `?token=` that passes `verify_raw` (the app embeds signed links valid for 10 min). No cookies are read or set on this origin. |
| 90 | - Headers: |
| 91 | - `X-Content-Type-Options: nosniff` |
| 92 | - `Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox` |
| 93 | - `Cross-Origin-Resource-Policy: cross-origin` |
| 94 | - `Referrer-Policy: no-referrer` |
| 95 | - `Cache-Control: private, max-age=300` (or public for public repos) |
| 96 | - Content-Type: |
| 97 | - png, jpg, jpeg, gif, webp, avif and ico are served as their image types |
| 98 | - other text is `text/plain; charset=utf-8` |
| 99 | - everything else is `application/octet-stream` |
| 100 | - Additionally `Content-Disposition: attachment` for svg, html, htm, xhtml, xml and any non-image binary. |
| 101 | - Missing, hidden or bad token: 404. |