forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.8 KiB

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/ or refs/tags/, or a 4–64-char hex OID prefix. Anything else (.., @{, :, ^, ~, a leading -, control characters) resolves to None.
  • 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 #L12 anchors.
    • 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.
  • /{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.gz and .zip: streamed, with Content-Disposition: attachment.
  • /{owner}/{repo}/branches and /{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}: rest is <ref>/<path>, split with split_ref_and_path.
  • 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.
  • Headers:
    • X-Content-Type-Options: nosniff
    • Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox
    • Cross-Origin-Resource-Policy: cross-origin
    • Referrer-Policy: no-referrer
    • Cache-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: attachment for svg, html, htm, xhtml, xml and any non-image binary.
  • Missing, hidden or bad token: 404.