forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.6 KiB
1# Milestone 1: shared interfaces (contract between workers)
2
3Every worker codes against these names and signatures. If a signature must change, keep the change minimal and say so in your report.
4The full plan is at `/home/tahinli/.claude/plans/purring-prancing-wirth.md`. The research behind it is in `docs/research.md`.
5
6## Global rules
7- Edition 2024, resolver 3. **Every dependency at its latest release**, added with `cargo add` into `[workspace.dependencies]`; crates use `{ workspace = true }`.
8- No `unsafe`. No `unwrap()`/`expect()` in non-test code, except at boot with a clear message.
9- Errors: `thiserror` inside library crates, `forge_core::Error` across crates.
10- 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()`.
11- Never invoke a shell. Never pass user input to git except through `forge_git::GitCommand`.
12
13## Module and file ownership
14```
15crates/forge-core/src/
16 lib.rs mod declarations + re-exports
17 error.rs Error enum
18 time.rs now_stamp(), stamp_after(Duration)
19 model.rs domain types (below)
20 config.rs Config + load()
21 secret.rs load_or_create_key(), seal(), open()
22 names.rs normalize_name(), validate_name(), RESERVED
23 password.rs hash_password(), verify_password()
24 token.rs generate_token(), hash_token(), constant_time_eq()
25 session.rs new_session_token(), new_csrf_token()
26 authz.rs Action, Denied, authorize()
27 store/mod.rs Store (all DB methods)
28 store/migrate.rs migration runner (schema_migrations table)
29crates/forge-core/migrations/0001_init.sql
30
31crates/forge-git/src/
32 lib.rs, error.rs
33 command.rs GitCommand builder
34 version.rs git_version(), require_min_version()
35 pktline.rs encode/flush helpers
36 repo.rs repo_path(), init_bare(), list_branches()
37 setup.rs write_managed_gitconfig(), write_hook_shims()
38 service.rs GitService, service_command()
39
40crates/forge-ssh/src/
41 lib.rs, error.rs
42 backend.rs GitBackend trait
43 parse.rs parse_exec()
44 server.rs run(), host key load/create, russh Handler
45
46crates/forge-web/src/
47 main.rs arg dispatch: serve (default) | hook <name> | admin create-user
48 boot.rs serve(): config, store, git checks, gitconfig/hooks, routers, ssh task
49 state.rs AppState
50 auth.rs session cookie, current_user(), CSRF check, Basic-auth parse, token auth
51 git_http.rs smart-HTTP routes
52 internal.rs internal hook API router (unix socket)
53 hook_cmd.rs `hook` subcommand client
54 ssh_backend.rs impl forge_ssh::GitBackend
55 admin_cli.rs `admin create-user`
56 views/mod.rs layout + shared view helpers
57 pages/{setup,login,dashboard,repo,new_repo,settings_keys,settings_tokens,settings_sessions,admin_users}.rs
58```
59
60## forge-core types
61```rust
62pub type Id = String;
63pub enum Visibility { Public, Private } // stored as "public" / "private"
64pub enum Role { Read, Triage, Write, Maintain, Admin } // stored lowercase
65pub struct User { pub id: Id, pub username: String, pub email: Option<String>, pub password_hash: String, pub is_admin: bool, pub created_at: String }
66pub 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 }
67pub 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> }
68pub 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> }
69pub 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> }
70```
71`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.
72
73## forge-core functions
74```rust
75// config.rs
76pub struct Config { pub database: PathBuf, pub data_dir: PathBuf, pub listen: SocketAddr, pub base_url: String,
77 pub user_content_base_url: String, pub ssh_listen: SocketAddr, pub ssh_public_host: String,
78 pub ssh_public_port: u16, pub registration: Registration }
79pub enum Registration { Closed, Invite, Open }
80impl Config { pub fn load(path: &Path) -> Result<Config> } // writes dev defaults if the file is missing
81
82// secret.rs (XChaCha20-Poly1305)
83pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> // 0600, 32 random bytes
84pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> String // "v1:" + base64(nonce || ciphertext)
85pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>>
86
87// names.rs
88pub fn normalize_name(input: &str) -> String // NFKC + lowercase
89pub fn validate_name(input: &str) -> Result<String> // returns the folded name or Error::InvalidName
90
91// password.rs (argon2id)
92pub fn hash_password(pw: &str) -> Result<String>
93pub fn verify_password(pw: &str, hash: &str) -> bool
94
95// token.rs
96pub fn generate_token() -> String // "forge_" + base64url(32 random bytes)
97pub fn hash_token(token: &str) -> String // hex sha256
98pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool
99
100// session.rs
101pub fn new_session_token() -> String // base64url(32 random bytes)
102pub fn new_csrf_token() -> String
103
104// authz.rs (pure, no IO)
105pub enum Action { Read, Write, Admin }
106pub enum Denied { NotFound, Forbidden } // NotFound when the actor can't even see the repo
107pub fn authorize(actor: Option<&User>, repo: &Repo, collaborator_role: Option<Role>, action: Action) -> Result<(), Denied>
108// Rules: owner or instance admin → all. Public repo → Read for anyone, including anonymous.
109// Private repo with no role → NotFound. Role Read/Triage → Read. Write/Maintain → Read+Write. Admin → all.
110// Can see but lacks the right → Forbidden. Archived repos deny Write (Forbidden).
111```
112
113## forge-core Store (async, `Clone`, wraps the turso connection)
114```rust
115impl Store {
116 pub async fn open(path: &Path) -> Result<Store>; // WAL, synchronous=FULL, foreign_keys=ON, busy_timeout, runs migrations
117 // users
118 pub async fn user_count(&self) -> Result<u64>;
119 pub async fn create_user(&self, username: &str, email: Option<&str>, password_hash: &str, is_admin: bool) -> Result<User>;
120 pub async fn user_by_id(&self, id: &str) -> Result<Option<User>>;
121 pub async fn user_by_username(&self, username: &str) -> Result<Option<User>>; // normalized lookup
122 pub async fn list_users(&self) -> Result<Vec<User>>;
123 // sessions
124 pub async fn create_session(&self, user_id: &str, token_hash: &str, csrf_token: &str, expires_at: &str, user_agent: Option<&str>) -> Result<Session>;
125 pub async fn session_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>>; // only unexpired
126 pub async fn touch_session(&self, id: &str) -> Result<()>;
127 pub async fn delete_session(&self, id: &str) -> Result<()>;
128 pub async fn delete_user_sessions(&self, user_id: &str) -> Result<()>;
129 pub async fn list_sessions(&self, user_id: &str) -> Result<Vec<Session>>;
130 // ssh keys
131 pub async fn add_ssh_key(&self, user_id: &str, title: &str, algorithm: &str, key_blob: &[u8], fingerprint: &str) -> Result<SshKey>;
132 pub async fn ssh_key_by_blob(&self, key_blob: &[u8]) -> Result<Option<SshKey>>;
133 pub async fn list_ssh_keys(&self, user_id: &str) -> Result<Vec<SshKey>>;
134 pub async fn delete_ssh_key(&self, user_id: &str, key_id: &str) -> Result<bool>;
135 pub async fn touch_ssh_key(&self, key_id: &str) -> Result<()>;
136 // tokens
137 pub async fn create_token(&self, user_id: &str, name: &str, token_hash: &str, scopes: &[String], expires_at: Option<&str>) -> Result<AccessToken>;
138 pub async fn token_by_hash(&self, token_hash: &str) -> Result<Option<AccessToken>>; // only unexpired
139 pub async fn list_tokens(&self, user_id: &str) -> Result<Vec<AccessToken>>;
140 pub async fn delete_token(&self, user_id: &str, token_id: &str) -> Result<bool>;
141 pub async fn touch_token(&self, token_id: &str) -> Result<()>;
142 // repos
143 pub async fn create_repo(&self, owner_id: &str, name: &str, description: &str, visibility: Visibility, default_branch: &str) -> Result<Repo>;
144 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
145 pub async fn repo_by_id(&self, id: &str) -> Result<Option<Repo>>;
146 pub async fn list_repos_by_owner(&self, owner_id: &str) -> Result<Vec<Repo>>;
147 pub async fn set_repo_archived(&self, repo_id: &str, archived: bool) -> Result<()>;
148 pub async fn collaborator_role(&self, repo_id: &str, user_id: &str) -> Result<Option<Role>>;
149 // events
150 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<()>;
151}
152```
153
154## forge-git
155```rust
156pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf } // gitconfig at data_dir/gitconfig, hooks at data_dir/hooks
157pub struct GitCommand { .. }
158impl GitCommand {
159 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
160 pub fn arg(self, a: &'static str) -> Self; // trusted static flags only
161 pub fn git_dir(self, p: &Path) -> Self; // --git-dir=<p>, placed before the subcommand
162 pub fn user_revs(self, revs: &[&str]) -> Self; // appends --end-of-options then the revs; rejects revs starting with '-'
163 pub fn paths(self, paths: &[&str]) -> Self; // appends -- then the paths
164 pub fn env(self, k: &'static str, v: impl AsRef<OsStr>) -> Self;
165 pub fn into_tokio(self) -> tokio::process::Command;
166}
167pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf; // data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git (lowercased ULID)
168pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()>;
169pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>>;
170pub async fn git_version(env: &GitEnv) -> Result<(u32, u32, u32)>;
171pub async fn require_min_version(env: &GitEnv, min: (u32, u32, u32)) -> Result<()>; // min (2,38,0)
172pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()>;
173pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()>; // pre-receive, post-receive; 0755
174pub enum GitService { UploadPack, ReceivePack } // "git-upload-pack" / "git-receive-pack"
175impl GitService { pub fn from_name(s: &str) -> Option<Self>; pub fn name(&self) -> &'static str; }
176pub fn service_command(env: &GitEnv, svc: GitService, repo: &Path, stateless_rpc: bool, advertise_refs: bool, git_protocol: Option<&str>) -> GitCommand;
177pub fn pkt_line(data: &str) -> Vec<u8>; pub const FLUSH_PKT: &[u8] = b"0000";
178```
179
180## forge-ssh
181```rust
182pub struct SshConfig { pub listen: SocketAddr, pub host_key_path: PathBuf, pub auth_timeout: Duration, pub inactivity_timeout: Duration }
183pub trait GitBackend: Send + Sync + 'static {
184 /// Exact-match lookup of the raw public key blob → user id
185 fn authenticate_key(&self, algorithm: &str, key_blob: &[u8]) -> impl Future<Output = Option<String>> + Send;
186 /// Authorize and build the git process (stdin/stdout piped by the caller). Err → message sent on stderr, exit 1.
187 fn prepare(&self, user_id: &str, service: forge_git::GitService, repo_path: &str, git_protocol: Option<&str>)
188 -> impl Future<Output = Result<tokio::process::Command, String>> + Send;
189}
190pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>; // "git-upload-pack '/owner/repo.git'"
191pub async fn run<B: GitBackend>(config: SshConfig, backend: Arc<B>) -> Result<()>;
192```
193
194## Hook protocol (forge-web internal API, over a unix socket at data_dir/internal.sock)
195- receive-pack runs with these env vars: `FORGE_INTERNAL_SOCKET`, `FORGE_INTERNAL_TOKEN`, `FORGE_ACTOR_ID`, `FORGE_REPO_ID`, `FORGE_PROTOCOL` (`http` | `ssh`).
196- `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`.
197- Response JSON is `{allow: bool, message: String}`. The hook prints `message` to stderr and exits 0 if allowed, 1 if not.