forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.6 KiB

Milestone 1: shared interfaces (contract between workers)

Every worker codes against these names and signatures. If a signature must change, keep the change minimal and say so in your report. The full plan is at /home/tahinli/.claude/plans/purring-prancing-wirth.md. The research behind it is in docs/research.md.

Global rules

  • Edition 2024, resolver 3. Every dependency at its latest release, added with cargo add into [workspace.dependencies]; crates use { workspace = true }.
  • No unsafe. No unwrap()/expect() in non-test code, except at boot with a clear message.
  • Errors: thiserror inside library crates, forge_core::Error across crates.
  • IDs are ULID strings (ulid crate). Timestamps are UTC RFC 3339 strings with 9 fractional digits and a Z suffix, produced by forge_core::time::now_stamp().
  • Never invoke a shell. Never pass user input to git except through forge_git::GitCommand.

Module and file ownership

crates/forge-core/src/
  lib.rs           mod declarations + re-exports
  error.rs         Error enum
  time.rs          now_stamp(), stamp_after(Duration)
  model.rs         domain types (below)
  config.rs        Config + load()
  secret.rs        load_or_create_key(), seal(), open()
  names.rs         normalize_name(), validate_name(), RESERVED
  password.rs      hash_password(), verify_password()
  token.rs         generate_token(), hash_token(), constant_time_eq()
  session.rs       new_session_token(), new_csrf_token()
  authz.rs         Action, Denied, authorize()
  store/mod.rs     Store (all DB methods)
  store/migrate.rs migration runner (schema_migrations table)
crates/forge-core/migrations/0001_init.sql

crates/forge-git/src/
  lib.rs, error.rs
  command.rs       GitCommand builder
  version.rs       git_version(), require_min_version()
  pktline.rs       encode/flush helpers
  repo.rs          repo_path(), init_bare(), list_branches()
  setup.rs         write_managed_gitconfig(), write_hook_shims()
  service.rs       GitService, service_command()

crates/forge-ssh/src/
  lib.rs, error.rs
  backend.rs       GitBackend trait
  parse.rs         parse_exec()
  server.rs        run(), host key load/create, russh Handler

crates/forge-web/src/
  main.rs          arg dispatch: serve (default) | hook <name> | admin create-user
  boot.rs          serve(): config, store, git checks, gitconfig/hooks, routers, ssh task
  state.rs         AppState
  auth.rs          session cookie, current_user(), CSRF check, Basic-auth parse, token auth
  git_http.rs      smart-HTTP routes
  internal.rs      internal hook API router (unix socket)
  hook_cmd.rs      `hook` subcommand client
  ssh_backend.rs   impl forge_ssh::GitBackend
  admin_cli.rs     `admin create-user`
  views/mod.rs     layout + shared view helpers
  pages/{setup,login,dashboard,repo,new_repo,settings_keys,settings_tokens,settings_sessions,admin_users}.rs

forge-core types

pub type Id = String;
pub enum Visibility { Public, Private }               // stored as "public" / "private"
pub enum Role { Read, Triage, Write, Maintain, Admin } // stored lowercase
pub struct User { pub id: Id, pub username: String, pub email: Option<String>, pub password_hash: String, pub is_admin: bool, pub created_at: String }
pub struct Repo { pub id: Id, pub owner_id: Id, pub owner_name: String, pub name: String, pub description: String, pub visibility: Visibility, pub default_branch: String, pub archived: bool, pub created_at: String }
pub struct SshKey { pub id: Id, pub user_id: Id, pub title: String, pub algorithm: String, pub key_blob: Vec<u8>, pub fingerprint: String, pub created_at: String, pub last_used_at: Option<String> }
pub struct AccessToken { pub id: Id, pub user_id: Id, pub name: String, pub scopes: Vec<String>, pub expires_at: Option<String>, pub created_at: String, pub last_used_at: Option<String> }
pub struct Session { pub id: Id, pub user_id: Id, pub csrf_token: String, pub created_at: String, pub expires_at: String, pub last_seen: String, pub user_agent: Option<String> }

key_blob is the raw SSH wire-format public key blob, the bytes base64-encoded in an authorized_keys line. Lookup is an exact byte comparison.

forge-core functions

// config.rs
pub struct Config { pub database: PathBuf, pub data_dir: PathBuf, pub listen: SocketAddr, pub base_url: String,
                    pub user_content_base_url: String, pub ssh_listen: SocketAddr, pub ssh_public_host: String,
                    pub ssh_public_port: u16, pub registration: Registration }
pub enum Registration { Closed, Invite, Open }
impl Config { pub fn load(path: &Path) -> Result<Config> }   // writes dev defaults if the file is missing

// secret.rs (XChaCha20-Poly1305)
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]>  // 0600, 32 random bytes
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> String     // "v1:" + base64(nonce || ciphertext)
pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>>

// names.rs
pub fn normalize_name(input: &str) -> String                // NFKC + lowercase
pub fn validate_name(input: &str) -> Result<String>         // returns the folded name or Error::InvalidName

// password.rs (argon2id)
pub fn hash_password(pw: &str) -> Result<String>
pub fn verify_password(pw: &str, hash: &str) -> bool

// token.rs
pub fn generate_token() -> String                           // "forge_" + base64url(32 random bytes)
pub fn hash_token(token: &str) -> String                    // hex sha256
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool

// session.rs
pub fn new_session_token() -> String                        // base64url(32 random bytes)
pub fn new_csrf_token() -> String

// authz.rs (pure, no IO)
pub enum Action { Read, Write, Admin }
pub enum Denied { NotFound, Forbidden }                     // NotFound when the actor can't even see the repo
pub fn authorize(actor: Option<&User>, repo: &Repo, collaborator_role: Option<Role>, action: Action) -> Result<(), Denied>
// Rules: owner or instance admin → all. Public repo → Read for anyone, including anonymous.
// Private repo with no role → NotFound. Role Read/Triage → Read. Write/Maintain → Read+Write. Admin → all.
// Can see but lacks the right → Forbidden. Archived repos deny Write (Forbidden).

forge-core Store (async, Clone, wraps the turso connection)

impl Store {
  pub async fn open(path: &Path) -> Result<Store>;           // WAL, synchronous=FULL, foreign_keys=ON, busy_timeout, runs migrations
  // users
  pub async fn user_count(&self) -> Result<u64>;
  pub async fn create_user(&self, username: &str, email: Option<&str>, password_hash: &str, is_admin: bool) -> Result<User>;
  pub async fn user_by_id(&self, id: &str) -> Result<Option<User>>;
  pub async fn user_by_username(&self, username: &str) -> Result<Option<User>>;   // normalized lookup
  pub async fn list_users(&self) -> Result<Vec<User>>;
  // sessions
  pub async fn create_session(&self, user_id: &str, token_hash: &str, csrf_token: &str, expires_at: &str, user_agent: Option<&str>) -> Result<Session>;
  pub async fn session_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>>;  // only unexpired
  pub async fn touch_session(&self, id: &str) -> Result<()>;
  pub async fn delete_session(&self, id: &str) -> Result<()>;
  pub async fn delete_user_sessions(&self, user_id: &str) -> Result<()>;
  pub async fn list_sessions(&self, user_id: &str) -> Result<Vec<Session>>;
  // ssh keys
  pub async fn add_ssh_key(&self, user_id: &str, title: &str, algorithm: &str, key_blob: &[u8], fingerprint: &str) -> Result<SshKey>;
  pub async fn ssh_key_by_blob(&self, key_blob: &[u8]) -> Result<Option<SshKey>>;
  pub async fn list_ssh_keys(&self, user_id: &str) -> Result<Vec<SshKey>>;
  pub async fn delete_ssh_key(&self, user_id: &str, key_id: &str) -> Result<bool>;
  pub async fn touch_ssh_key(&self, key_id: &str) -> Result<()>;
  // tokens
  pub async fn create_token(&self, user_id: &str, name: &str, token_hash: &str, scopes: &[String], expires_at: Option<&str>) -> Result<AccessToken>;
  pub async fn token_by_hash(&self, token_hash: &str) -> Result<Option<AccessToken>>;  // only unexpired
  pub async fn list_tokens(&self, user_id: &str) -> Result<Vec<AccessToken>>;
  pub async fn delete_token(&self, user_id: &str, token_id: &str) -> Result<bool>;
  pub async fn touch_token(&self, token_id: &str) -> Result<()>;
  // repos
  pub async fn create_repo(&self, owner_id: &str, name: &str, description: &str, visibility: Visibility, default_branch: &str) -> Result<Repo>;
  pub async fn repo_by_owner_and_name(&self, owner: &str, name: &str) -> Result<Option<Repo>>;  // normalized; a trailing ".git" on name stripped by caller
  pub async fn repo_by_id(&self, id: &str) -> Result<Option<Repo>>;
  pub async fn list_repos_by_owner(&self, owner_id: &str) -> Result<Vec<Repo>>;
  pub async fn set_repo_archived(&self, repo_id: &str, archived: bool) -> Result<()>;
  pub async fn collaborator_role(&self, repo_id: &str, user_id: &str) -> Result<Option<Role>>;
  // events
  pub async fn record_push_event(&self, repo_id: &str, actor_id: &str, ref_name: &str, old_oid: &str, new_oid: &str, protocol: &str) -> Result<()>;
}

forge-git

pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf }   // gitconfig at data_dir/gitconfig, hooks at data_dir/hooks
pub struct GitCommand { .. }
impl GitCommand {
  pub fn new(env: &GitEnv, subcommand: &'static str) -> Self;  // env_clear(); PATH=/usr/bin:/bin; HOME=data_dir; GIT_CONFIG_NOSYSTEM=1; GIT_CONFIG_GLOBAL=data_dir/gitconfig
  pub fn arg(self, a: &'static str) -> Self;                   // trusted static flags only
  pub fn git_dir(self, p: &Path) -> Self;                      // --git-dir=<p>, placed before the subcommand
  pub fn user_revs(self, revs: &[&str]) -> Self;               // appends --end-of-options then the revs; rejects revs starting with '-'
  pub fn paths(self, paths: &[&str]) -> Self;                  // appends -- then the paths
  pub fn env(self, k: &'static str, v: impl AsRef<OsStr>) -> Self;
  pub fn into_tokio(self) -> tokio::process::Command;
}
pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf;   // data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git (lowercased ULID)
pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()>;
pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>>;
pub async fn git_version(env: &GitEnv) -> Result<(u32, u32, u32)>;
pub async fn require_min_version(env: &GitEnv, min: (u32, u32, u32)) -> Result<()>;   // min (2,38,0)
pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()>;
pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()>;                // pre-receive, post-receive; 0755
pub enum GitService { UploadPack, ReceivePack }                // "git-upload-pack" / "git-receive-pack"
impl GitService { pub fn from_name(s: &str) -> Option<Self>; pub fn name(&self) -> &'static str; }
pub fn service_command(env: &GitEnv, svc: GitService, repo: &Path, stateless_rpc: bool, advertise_refs: bool, git_protocol: Option<&str>) -> GitCommand;
pub fn pkt_line(data: &str) -> Vec<u8>;  pub const FLUSH_PKT: &[u8] = b"0000";

forge-ssh

pub struct SshConfig { pub listen: SocketAddr, pub host_key_path: PathBuf, pub auth_timeout: Duration, pub inactivity_timeout: Duration }
pub trait GitBackend: Send + Sync + 'static {
  /// Exact-match lookup of the raw public key blob → user id
  fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> impl Future<Output = Option<String>> + Send;
  /// Authorize and build the git process (stdin/stdout piped by the caller). Err → message sent on stderr, exit 1.
  fn prepare(&self, user_id: &str, service: forge_git::GitService, repo_path: &str, git_protocol: Option<&str>)
     -> impl Future<Output = Result<tokio::process::Command, String>> + Send;
}
pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>;  // "git-upload-pack '/owner/repo.git'"
pub async fn run<B: GitBackend>(config: SshConfig, backend: Arc<B>) -> Result<()>;

Hook protocol (forge-web internal API, over a unix socket at data_dir/internal.sock)

  • receive-pack runs with these env vars: FORGE_INTERNAL_SOCKET, FORGE_INTERNAL_TOKEN, FORGE_ACTOR_ID, FORGE_REPO_ID, FORGE_PROTOCOL (http | ssh).
  • forge-web hook pre-receive|post-receive reads stdin lines <old> <new> <ref>. It POSTs JSON {repo_id, actor_id, protocol, updates:[{old,new,ref}], quarantine_env:{GIT_QUARANTINE_PATH, GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES}} to /hook/pre-receive or /hook/post-receive, with header X-Forge-Internal-Token.
  • Response JSON is {allow: bool, message: String}. The hook prints message to stderr and exits 0 if allowed, 1 if not.