Tahinli/forge
public0 watching
★ 0
📝 docs: rewrite readme for humans and drop internal agent notes
9093535cd05ec06f58f6d55f2f1dc5f04ad5d347
Author Ahmet Kaan Gümüş ·
Parent: 3c314e0
24 changed files +214 -3005
- .gitignore +1 -0
- README.md +143 -198
- docs/architecture.md +57 -58
- docs/backlog.md +5 -5
- docs/backup-interfaces.md +0 -51
- docs/backup.md +1 -1
- docs/branch-protection-interfaces.md +0 -60
- docs/ci-builtin-executor.md +0 -104
- docs/ci-interfaces.md +0 -234
- docs/ci-sandbox-v2.md +0 -122
- docs/ci.md +7 -0
- docs/m1-interfaces.md +0 -197
- docs/m2-interfaces.md +0 -101
- docs/m3-interfaces.md +0 -189
- docs/m4-interfaces.md +0 -120
- docs/m4b-interfaces.md +0 -119
- docs/mirrors-interfaces.md +0 -110
- docs/notifications-interfaces.md +0 -86
- docs/profile-interfaces.md +0 -66
- docs/research.md +0 -249
- docs/topcoat-0.10-notes.md +0 -499
- docs/wave-b-interfaces.md +0 -121
- docs/wave-c-interfaces.md +0 -248
- docs/webhooks-interfaces.md +0 -67
.gitignore| @@ -10,3 +10,4 @@ ssh_host_ed25519_key | ||
| 10 | 10 | |
| 11 | 11 | # local config; copy config/forge.example.toml |
| 12 | 12 | /config/forge.toml |
| 13 | +.~lock.*# |
README.md| @@ -1,31 +1,26 @@ | ||
| 1 | 1 | # forge |
| 2 | 2 | |
| 3 | -forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released. | |
| 3 | +forge is a self-hosted git forge. You run it on your own server, so your code stays on machines you control and no third-party service sits in the middle. It ships as one binary, `forge`, which serves the web UI, git over HTTPS, and git over SSH. Repositories are plain bare git repositories on disk, and account data lives in one SQLite database. | |
| 4 | 4 | |
| 5 | -It runs as a single process (`forge`) that serves the web UI, git over HTTPS (smart HTTP), and git over SSH. Repositories are plain bare git repositories on disk. Account and repo metadata live in a SQLite/Turso database. | |
| 5 | +Try the live instance at https://git.tahinli.com. | |
| 6 | 6 | |
| 7 | -## Status | |
| 7 | +forge is a working name, and it is not released yet. | |
| 8 | 8 | |
| 9 | -forge is at **Milestone 2** ("repo browsing and raw files"). It currently provides: | |
| 9 | +## Features | |
| 10 | 10 | |
| 11 | -- local accounts (argon2id passwords), with the first user created as admin | |
| 12 | -- sessions, CSRF protection on every POST, and a `__Host-` session cookie | |
| 13 | -- SSH public keys and personal access tokens (`forge_...`) | |
| 14 | -- creating repos, with public or private visibility, collaborators, and an archive flag | |
| 15 | -- clone and push over HTTPS and SSH | |
| 16 | -- basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes | |
| 17 | -- repo browsing: the repo home page with the rendered README, directory listings with the last commit per entry, file views with syntax highlighting and `#L12` line anchors, Markdown rendering with a source toggle, commit history (paginated), commit views with diffs, branch and tag lists, and `.tar.gz` / `.zip` archives | |
| 18 | -- raw files served from a separate user-content origin (see [Raw user content](#raw-user-content)) | |
| 19 | - | |
| 20 | -Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). | |
| 21 | - | |
| 22 | -## Requirements | |
| 23 | - | |
| 24 | -- Rust stable, edition 2024 | |
| 25 | -- git 2.38 or newer (boot refuses to start with an older git) | |
| 11 | +- **Accounts:** local accounts with argon2id passwords, two-factor login (TOTP) with recovery codes, SSH keys, and personal access tokens. | |
| 12 | +- **Repositories:** public or private repos, collaborators, archiving, and a trash for deleted repos. | |
| 13 | +- **Git:** clone and push over HTTPS and SSH. The default branch cannot be deleted or force-pushed, and archived repos reject pushes. | |
| 14 | +- **Browsing:** rendered READMEs, syntax-highlighted files with line links, Markdown source view, commit history and diffs, branches and tags, and `.tar.gz` or `.zip` downloads. | |
| 15 | +- **Collaboration:** issues, pull requests with reviews, notifications, and releases. | |
| 16 | +- **Automation:** webhooks, push mirrors, replication to a standby, and CI runners. | |
| 17 | +- **Large files:** Git LFS. | |
| 18 | +- **Backups:** scheduled backup sets with verification and restore. | |
| 26 | 19 | |
| 27 | 20 | ## Quick start |
| 28 | 21 | |
| 22 | +You need Rust (stable, edition 2024) and git 2.38 or newer. forge refuses to start with an older git. | |
| 23 | + | |
| 29 | 24 | 1. Copy the example config. forge reads `config/forge.toml` by default. |
| 30 | 25 | |
| 31 | 26 | ```sh |
| @@ -38,13 +33,13 @@ Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). | ||
| 38 | 33 | cargo run -p forge-web |
| 39 | 34 | ``` |
| 40 | 35 | |
| 41 | -3. Create the admin account. Either open http://127.0.0.1:7660/setup (it works only while there are zero users), or use the CLI: | |
| 36 | +3. Open http://127.0.0.1:7660/setup and create the admin account (its password must be at least 12 characters). This page works only while there are no users. You can also create the account from the shell: | |
| 42 | 37 | |
| 43 | 38 | ```sh |
| 44 | - cargo run -p forge-web -- admin create-user <username> --admin | |
| 39 | + cargo run -p forge-web -- admin create-user tahinli --admin | |
| 45 | 40 | ``` |
| 46 | 41 | |
| 47 | - The CLI reads the password from `FORGE_PASSWORD` if set, or prompts for it twice. Passwords must be at least 12 characters. The first user is always an admin, even without `--admin`. | |
| 42 | + The command reads the password from `FORGE_PASSWORD` if it is set, or asks for it twice. Passwords must be at least 12 characters. | |
| 48 | 43 | |
| 49 | 44 | 4. Add an SSH key at `/settings/keys`. Use an ed25519 key: |
| 50 | 45 | |
| @@ -52,114 +47,31 @@ Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). | ||
| 52 | 47 | ssh-keygen -t ed25519 |
| 53 | 48 | ``` |
| 54 | 49 | |
| 55 | - Paste the contents of the `.pub` file. RSA keys are rejected with a message asking for ed25519. The reason is that `ssh-rsa` keys sign with SHA-1, and forge disables SHA-1 signatures. ECDSA and security-key (`sk-`) variants are also accepted by the form. | |
| 56 | - | |
| 57 | -5. To change only the capitalization of your username (for example `tahinli` to `Tahinli`), open `/settings/profile`. Any other change is refused. The instance operator can do the same from a shell with `cargo run -p forge-web -- admin set-username <current> <NewCase>`. | |
| 50 | + RSA keys are rejected, because forge turns off SHA-1 signatures. | |
| 58 | 51 | |
| 59 | -6. Create an access token at `/settings/tokens`. The token starts with `forge_` and is shown once. Store it somewhere safe. | |
| 52 | +5. Create a token at `/settings/tokens` for HTTPS pushes. It starts with `forge_` and is shown only once. | |
| 60 | 53 | |
| 61 | -7. Account security lives at `/settings/security`: | |
| 62 | - - **Password:** change it with the current password. The new one needs 10 to 1024 characters. Your other sessions are signed out; the one you are using stays signed in. Wrong current passwords count toward the same per-user login limit as failed logins. | |
| 63 | - - **Email:** change it with the current password. The address must contain one `@`, have no spaces, and be at most 254 characters. | |
| 64 | - - **Two-factor authentication (TOTP):** click "Enable 2FA", add the secret or `otpauth://` link to an authenticator app, and confirm with a 6-digit code. Ten single-use recovery codes are then shown once, so save them. Turning 2FA off needs the password and a code. Regenerating recovery codes needs a code. Codes are TOTP (RFC 6238, SHA-1, 6 digits, 30-second steps), and a code from one step either side of the current time is accepted. Each code works only once. With 2FA on, a correct password leads to `/login/2fa`, which asks for a code or a recovery code. The pending login lasts 5 minutes. Each user gets 10 second-factor attempts per 15 minutes. | |
| 65 | - - **Git over HTTPS is unaffected by 2FA.** It authenticates with access tokens (step 6), not with passwords or TOTP codes. Revoke a token at `/settings/tokens` if it leaks. | |
| 66 | - | |
| 67 | -7. Create a repo at `/new`, then push over either transport: | |
| 54 | +6. Create a repo at `/new`, then push to it: | |
| 68 | 55 | |
| 69 | 56 | ```sh |
| 70 | 57 | # SSH |
| 71 | - git remote add origin ssh://git@127.0.0.1:2222/<owner>/<repo>.git | |
| 58 | + git remote add origin ssh://git@127.0.0.1:2222/tahinli/my-repo.git | |
| 72 | 59 | git push -u origin main |
| 73 | 60 | |
| 74 | - # HTTPS: username and token as the password | |
| 75 | - git remote add origin http://127.0.0.1:7660/<owner>/<repo>.git | |
| 61 | + # HTTPS: use your username, and the token as the password | |
| 62 | + git remote add origin http://127.0.0.1:7660/tahinli/my-repo.git | |
| 76 | 63 | git push -u origin main |
| 77 | 64 | ``` |
| 78 | 65 | |
| 79 | -## Configuration | |
| 80 | - | |
| 81 | -The file is TOML. Every key in `config/forge.example.toml` is listed below. Paths are relative to the working directory unless absolute. | |
| 82 | - | |
| 83 | -| Key | Example value | Meaning | | |
| 84 | -|---|---|---| | |
| 85 | -| `database` | `"./data/forge.db"` | SQLite/Turso database file. `admin create-user` creates its parent directory. | | |
| 86 | -| `data_dir` | `"./data"` | Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. | | |
| 87 | -| `listen` | `"127.0.0.1:7660"` | HTTP listen address for the web UI and smart HTTP. | | |
| 88 | -| `base_url` | `"http://127.0.0.1:7660"` | Public base URL used in links and clone URLs. Set this to your public `https://` URL in production. | | |
| 89 | -| `user_content_base_url` | `"http://127.0.0.1:7661"` | Separate origin for raw user content. Set it to a different registrable domain in production (see [Raw user content](#raw-user-content)). | | |
| 90 | -| `user_content_listen` | `"127.0.0.1:7661"` | Address of the listener that serves raw user content. Your reverse proxy forwards the user-content hostname to it. | | |
| 91 | -| `ssh_listen` | `"0.0.0.0:2222"` | SSH listen address for git over SSH. | | |
| 92 | -| `ssh_public_host` | `"127.0.0.1"` | Host shown in SSH clone URLs. | | |
| 93 | -| `ssh_public_port` | `2222` | Port shown in SSH clone URLs. | | |
| 94 | -| `registration` | `"closed"` | Self-registration policy: `closed`, `invite`, or `open`. Default is `closed`. M1 has no sign-up page, so accounts come from `/setup`, the CLI, or `/admin/users`. | | |
| 95 | -| `trusted_proxies` | `[]` | Addresses or CIDR networks (for example `["127.0.0.1"]`) whose `X-Forwarded-For` header is trusted. Set it to the address your reverse proxy connects from. Empty means the TCP peer is the client, so a spoofed header cannot change the client IP. Used for login and git throttling. | | |
| 96 | - | |
| 97 | -Production config normally lives at `/etc/forge/forge.toml` (see the systemd unit below). | |
| 98 | - | |
| 99 | -### Command line and environment | |
| 100 | - | |
| 101 | -```text | |
| 102 | -forge [--config <path>] [serve | hook <pre-receive|post-receive> | runner <register|run|check> ... | admin create-user <username> [--admin] | admin set-username <current> <NewCase> | admin reset-password <username> | admin trash-list | admin trash-purge --older-than-days <N> | admin audit-purge --older-than-days <N> | admin delete-user <username> [--yes] | admin backup --out <DIR> [--include-key] [--repos-only | --db-only] | admin backup-verify <PATH> | admin fsck [--full] | admin lfs-gc | admin restore --from <SET> --data-dir <NEW_DIR>] | |
| 103 | -``` | |
| 104 | - | |
| 105 | -- `--config <path>` sets the config file. Precedence: `--config`, then `FORGE_CONFIG`, then `config/forge.toml`. | |
| 106 | -- `serve` (the default when no subcommand is given) starts the server. | |
| 107 | -- `hook <stage>` is called by git hook shims. You do not run it by hand. | |
| 108 | -- `admin create-user <username> [--admin]` creates a user from the CLI. | |
| 109 | -- `admin set-username <current> <NewCase>` changes only the capitalization of a user's username (`current` matches any case). Example: `forge admin set-username tahinli Tahinli`. | |
| 110 | -- `admin reset-password <username>` sets a random 20-character temporary password, prints it once, and signs the user out of every session. Give the password to the user, who should change it at `/settings/security`. Nothing else changes. Use it when a user is locked out of their account. Two-factor settings are not changed, so the user still needs a code. | |
| 111 | -- `admin trash-list` lists deleted repos in `data_dir/trash`, oldest first, as `<deleted_at>\t<repo id>\t<path>`. | |
| 112 | -- `admin trash-purge --older-than-days N` permanently removes trash entries deleted at least N days ago. Nothing else is removed, and this cannot be undone. | |
| 113 | -- `FORGE_PASSWORD` supplies the password for `admin create-user` without a prompt. | |
| 114 | -- `RUST_LOG` sets the log filter (default `info`). Logs go to stderr. | |
| 115 | -- `GET /healthz` returns `ok <build sha>`. | |
| 66 | +Account settings live at `/settings/security`. There you can change your password or email, or turn on two-factor login (save the ten recovery codes it shows). Git over HTTPS uses access tokens, not your password or 2FA codes, so revoke a token at `/settings/tokens` if it leaks. You can change only the capitalization of your username, at `/settings/profile`. | |
| 116 | 67 | |
| 117 | -## Data layout | |
| 68 | +## Deploy on a server | |
| 118 | 69 | |
| 119 | -Everything under `data_dir`: | |
| 70 | +Run forge behind a TLS reverse proxy for any use beyond localhost. The session cookie is marked `Secure`, so logging in over plain HTTP will not work outside localhost. | |
| 120 | 71 | |
| 121 | -| Path | Purpose | | |
| 122 | -|---|---| | |
| 123 | -| `forge.db`, `forge.db-wal`, `forge.db-shm` | Metadata database (users, sessions, keys, tokens, repos). Sits next to `database` in the example config. | | |
| 124 | -| `forge.key` | Instance key. Encrypts sealed values. Created on first boot. | | |
| 125 | -| `ssh_host_ed25519_key` | SSH host key. Created on first boot. | | |
| 126 | -| `gitconfig` | Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. | | |
| 127 | -| `hooks/` | `pre-receive` and `post-receive` shims that run `forge hook <name>`. Rewritten at boot. | | |
| 128 | -| `repos/ab/cd/<id>.git` | Bare repositories. `<id>` is the lowercased ULID; the path does not depend on the repo name. | | |
| 129 | -| `internal.sock` | Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. | | |
| 130 | -| `trash/<id>-<unix_ts>.git` | Repos deleted from the settings page (`/{owner}/{repo}/settings`). Each is a plain bare repository, moved here by a rename. The database rows are gone, so the repo no longer appears in forge. | | |
| 72 | +The easiest way is a system service, using the unit in `deploy/forge.service`. You can also run forge as a dedicated user with a systemd user service. Then enable linger (`sudo loginctl enable-linger forge`) so it keeps running after logout. | |
| 131 | 73 | |
| 132 | -### Recovering a deleted repo | |
| 133 | - | |
| 134 | -A deleted repo is not erased at once. Its bare directory is moved to `data_dir/trash/`, and only `admin trash-purge` removes it. To recover it, list the trash with `forge admin trash-list` and clone from the directory, which works without the server: | |
| 135 | - | |
| 136 | -```sh | |
| 137 | -git clone --mirror /var/lib/forge/trash/<id>-<unix_ts>.git recovered.git | |
| 138 | -``` | |
| 139 | - | |
| 140 | -The clone restores all branches and tags. The owner, visibility, description and collaborators are not restored, so re-create the repo under the same name and push the mirror to it with `git push --mirror`. | |
| 141 | - | |
| 142 | -### Backups | |
| 143 | - | |
| 144 | -See [`docs/backup.md`](docs/backup.md) for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill. | |
| 145 | - | |
| 146 | -- **`forge.key` must be backed up separately and stored safely, offline.** Without it, sealed values cannot be decrypted, and they are lost. Keep it out of the same backup set as the database if you can. | |
| 147 | -- **Never copy a live `forge.db`** (or its `-wal`/`-shm` files) with `cp` or `rsync`. Take a consistent snapshot with `VACUUM INTO`, for example from the `sqlite3` shell while forge is running: | |
| 148 | - | |
| 149 | - ```sql | |
| 150 | - VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; | |
| 151 | - ``` | |
| 152 | - | |
| 153 | -- **Repositories**: do not copy bare repos while pushes are in progress. Use `git bundle create --all` or `git push --mirror` to a copy. | |
| 154 | -- **Unix socket path length**: `data_dir/internal.sock` must be shorter than 108 bytes, the unix socket path limit. Boot fails with an error otherwise. Choose a short `data_dir` (for example `/var/lib/forge`). | |
| 155 | - | |
| 156 | -## Deployment | |
| 157 | - | |
| 158 | -### systemd | |
| 159 | - | |
| 160 | -`deploy/forge.service` runs `/usr/local/bin/forge serve` as a dedicated `forge` user, with `FORGE_CONFIG=/etc/forge/forge.toml`, working directory `/var/lib/forge`, and a hardened sandbox (`ProtectSystem=strict`, `/var/lib/forge` and, if present, `/var/backups/forge` writable, no capabilities, `UMask=0077`). | |
| 161 | - | |
| 162 | -Typical setup: | |
| 74 | +System service setup: | |
| 163 | 75 | |
| 164 | 76 | ```sh |
| 165 | 77 | sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge |
| @@ -170,66 +82,30 @@ sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service | ||
| 170 | 82 | sudo systemctl daemon-reload && sudo systemctl enable forge |
| 171 | 83 | ``` |
| 172 | 84 | |
| 173 | -Edit `/etc/forge/forge.toml` before starting: | |
| 85 | +Before you start it, edit `/etc/forge/forge.toml`: | |
| 174 | 86 | |
| 175 | -- `base_url`: public `https://` URL. | |
| 176 | -- `user_content_base_url`: a separate registrable domain, not a subdomain of `base_url`. | |
| 177 | -- `ssh_public_host` and `ssh_public_port`: public hostname and port. | |
| 87 | +- `base_url`: your public `https://` URL. | |
| 88 | +- `user_content_base_url`: a separate domain, not a subdomain of `base_url` (see below). | |
| 89 | +- `ssh_public_host` and `ssh_public_port`: your public host name and port. | |
| 178 | 90 | - `database` and `data_dir`: absolute paths under `/var/lib/forge`. |
| 179 | -- `trusted_proxies = ["127.0.0.1"]` when the reverse proxy runs on this host. Otherwise every client shares the proxy's IP for login and git throttling. | |
| 180 | -- `[backup] dir`: under `/var/lib/forge` or `/var/backups/forge`. The unit makes only those writable. Create the latter with `sudo install -d -o forge -g forge -m 0700 /var/backups/forge`. | |
| 91 | +- `trusted_proxies = ["127.0.0.1"]` if the reverse proxy runs on the same host. Without this, every client shares the proxy's IP for login throttling. | |
| 92 | +- `[backup] dir`: under `/var/lib/forge` or `/var/backups/forge`. | |
| 181 | 93 | |
| 182 | -Create the admin before starting. `/setup` lets the first visitor claim the admin account while there are no users, so create the admin before the reverse proxy exposes the site. | |
| 94 | +Create the admin account (password at least 12 characters) before the reverse proxy makes the site public. `/setup` lets the first visitor claim the admin account while there are no users. | |
| 183 | 95 | |
| 184 | 96 | ```sh |
| 185 | -sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user <name> --admin | |
| 97 | +sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user tahinli --admin | |
| 186 | 98 | sudo systemctl start forge |
| 187 | 99 | ``` |
| 188 | 100 | |
| 189 | -Open the SSH port (2222 by default) in the firewall. | |
| 190 | - | |
| 191 | -### Reverse proxy and TLS | |
| 192 | - | |
| 193 | -Run forge behind a TLS reverse proxy for any non-localhost use. The session cookie is named `__Host-forge_session` and is marked `Secure`, so browsers send it only over HTTPS. Logging in over plain HTTP outside localhost will not work. Set `base_url` to the public HTTPS URL. | |
| 194 | - | |
| 195 | -Git routes need a proxy that does not buffer or time out early. The example below targets [Ferron](https://ferron.sh) 3.0.0-rc.7, the newest release found (a pre-release; the latest stable, 2.8.1, was not checked against this config). Ferron uses its own `ferron.conf` format, not KDL. | |
| 196 | - | |
| 197 | -- Buffering: `buffer_request` and `buffer_response` are off by default. The example sets both to `0` so they stay off, because git smart HTTP streams packs in both directions. | |
| 198 | -- Timeouts: `connection_timeout` (default 2s) and `idle_timeout` (default 60s) are per upstream. The example raises `idle_timeout` to `3600s`, because `git upload-pack` can stay silent while it builds a pack. Ferron's proxy docs list no total-request timeout, so there is no separate read/send timeout to raise. | |
| 199 | -- Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (`/<owner>/<repo>/git-receive-pack`, `git-upload-pack`). | |
| 200 | -- Forwarded headers: Ferron sets `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` and `Forwarded` itself and keeps the incoming `Host`, so the example adds no header lines. | |
| 201 | -- SSH: port 2222 is not proxied. Ferron handles HTTP(S) only, so forge serves SSH directly on `ssh_listen`. Open that port on the host. | |
| 202 | - | |
| 203 | -### Brute-force throttling | |
| 204 | - | |
| 205 | -Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window: | |
| 206 | - | |
| 207 | -- Login: 10 failed attempts per username, and 30 per client IP. A throttled attempt gets HTTP 429 with the message "Too many attempts. Try again later.", and the password is not checked. A successful login clears that username's counter. | |
| 208 | -- Git HTTP (Basic auth with a token): 30 failed attempts per client IP. The next request gets HTTP 429 with `Retry-After`. Anonymous requests and successful logins are not counted. | |
| 209 | -- The client IP is the TCP peer, unless the peer is in `trusted_proxies`. Then it is the right-most `X-Forwarded-For` address that is not a trusted proxy. IPv6 addresses are counted per /64. | |
| 210 | -- Throttling events are logged at `warn` with the IP and the folded username. Counters are lost on restart and are not shared between instances. | |
| 101 | +Open the SSH port (2222 by default) in your firewall. The reverse proxy does not forward SSH, so forge serves it directly. | |
| 211 | 102 | |
| 212 | -### Raw user content | |
| 103 | +### Reverse proxy | |
| 213 | 104 | |
| 214 | -forge runs a third listener, `user_content_listen` (default `127.0.0.1:7661`). It serves only the raw files of repos, on the origin `user_content_base_url`. It has its own explicit routes and does not share the app's router. | |
| 215 | - | |
| 216 | -Put it on its own registrable domain in production, for example `usercontent.example.net` next to `forge.example.com`. Browsers treat that as a separate site, so a file a user uploads cannot run script against the app origin. The raw origin is also built to be inert: | |
| 217 | - | |
| 218 | -- No cookies are read or set on it, so app sessions never reach it. | |
| 219 | -- Every response has `Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. | |
| 220 | -- SVG, HTML, XML and other non-image binaries are sent with `Content-Disposition: attachment`. | |
| 221 | - | |
| 222 | -Private repos need a signed link. The app puts a `?token=` on raw links for private repos, and that token is valid for 10 minutes. Public repos need no token. A missing, hidden, or bad token gets 404. | |
| 223 | - | |
| 224 | -Example Ferron 3.0.0-rc.7 config (`ferron.conf`) with two hostnames. A bare host block already gets automatic ACME TLS; the `tls` block only sets the account contact: | |
| 105 | +Git needs a proxy that does not buffer and allows long silent periods, because `git upload-pack` can pause while it builds a pack. This example uses [Ferron](https://ferron.sh) 3.0.0-rc.7, a pre-release. Ferron uses its own `ferron.conf` format. A bare host block gets automatic TLS. | |
| 225 | 106 | |
| 226 | 107 | ``` |
| 227 | 108 | forge.example.com { |
| 228 | - tls { | |
| 229 | - provider acme | |
| 230 | - challenge http-01 | |
| 231 | - contact admin@example.com | |
| 232 | - } | |
| 233 | 109 | buffer_request 0 |
| 234 | 110 | buffer_response 0 |
| 235 | 111 | proxy { |
| @@ -240,11 +116,6 @@ forge.example.com { | ||
| 240 | 116 | } |
| 241 | 117 | |
| 242 | 118 | usercontent.example.net { |
| 243 | - tls { | |
| 244 | - provider acme | |
| 245 | - challenge http-01 | |
| 246 | - contact admin@example.com | |
| 247 | - } | |
| 248 | 119 | buffer_request 0 |
| 249 | 120 | buffer_response 0 |
| 250 | 121 | proxy { |
| @@ -253,47 +124,121 @@ usercontent.example.net { | ||
| 253 | 124 | } |
| 254 | 125 | ``` |
| 255 | 126 | |
| 256 | -The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies. | |
| 127 | +Set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"` to match. Turn off compression on git paths too. forge does not compress responses yet. | |
| 257 | 128 | |
| 258 | -With that setup, set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"`. | |
| 129 | +#### User content domain | |
| 259 | 130 | |
| 260 | -### Limits | |
| 131 | +Raw repo files are served from `user_content_base_url`, which must be a different domain from `base_url` (for example `usercontent.example.net` next to `forge.example.com`). Browsers then treat it as a separate site, so a file a user uploads cannot run script against your app. The raw origin reads no cookies and sends risky file types as downloads. Links to private repo files carry a `?token=` that expires after 10 minutes. | |
| 261 | 132 | |
| 262 | -- Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file. | |
| 263 | -- Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting. | |
| 264 | -- Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed. | |
| 265 | -- Commit diffs: at most 300 files, 2,000 lines per file, and 20,000 lines in total. Anything past that is cut off with a notice. | |
| 133 | +### Automatic updates | |
| 266 | 134 | |
| 267 | -Compression is currently **off for every route**. Git smart-HTTP responses must not be compressed, and the server cannot yet switch compression off per route, so it is disabled globally (`TODO(M1)` in `boot.rs`). Let the proxy do no compression on git paths either. | |
| 135 | +forge can update itself. When CI passes on a push to the forge repo, the server swaps in the new binary and restarts. Runners do the same when they are idle. This needs the user units in `deploy/user/`, because the binary lives in `~/bin`. Use `forge` for the server and `ci` for a runner, each a dedicated user with linger enabled. The system unit `deploy/forge.service` is for a root-managed install with the binary in `/usr/local/bin`, which the `forge` user cannot write, so it does not self-update. Use it or the user units, not both. | |
| 268 | 136 | |
| 269 | -## Security notes | |
| 137 | +As the `forge` user, install the server units (the runner host does the same as `ci`, with `forge-runner.service` and `forge-runner-rollback.service`): | |
| 270 | 138 | |
| 271 | -From `docs/architecture.md`. These are the properties forge is designed for. Not all are fully implemented in M1. | |
| 139 | +```sh | |
| 140 | +install -d ~/bin ~/.config/systemd/user | |
| 141 | +install -m 0755 target/release/forge ~/bin/forge | |
| 142 | +install -m 0755 deploy/user/forge-rollback ~/bin/forge-rollback | |
| 143 | +install -m 0644 deploy/user/forge.service deploy/user/forge-rollback.service ~/.config/systemd/user/ | |
| 144 | +systemctl --user daemon-reload && systemctl --user enable --now forge.service | |
| 145 | +``` | |
| 272 | 146 | |
| 273 | -- Authorization goes through a single function, `authorize()`. A private repo that the actor cannot see returns 404, so its existence is not revealed. | |
| 274 | -- Passwords use argon2id. Tokens and session tokens are stored as SHA-256 hashes and compared in constant time. Tokens are shown once and kept out of URLs and logs. | |
| 275 | -- SSH keys must match exactly. `ssh-rsa` with SHA-1 is disabled, and the SSH server accepts only `GIT_PROTOCOL` from env requests. Handshake and inactivity timeouts apply. | |
| 276 | -- No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments. | |
| 277 | -- Git runs with `receive.fsckObjects=true`, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected. | |
| 278 | -- Users cannot write hooks or repo-local config. Submodules are not recursed. | |
| 279 | -- Run as a dedicated service user, and keep the git binary patched. | |
| 280 | -- Keep `forge.key` and `data_dir` private. Back up `forge.key` separately. | |
| 147 | +Turn on updates in `forge.toml`: | |
| 281 | 148 | |
| 282 | -## Roadmap | |
| 149 | +```toml | |
| 150 | +[self_update] | |
| 151 | +enabled = true | |
| 152 | +repo = "Tahinli/forge" | |
| 153 | +branch = "main" | |
| 154 | +job = "build" | |
| 155 | +artifact = "dist_forge" | |
| 156 | +``` | |
| 283 | 157 | |
| 284 | -From `docs/architecture.md` (section 10). | |
| 158 | +Runners update by default (`auto_update = true` in `runner.toml`). Set it to `false` to pin a runner's binary by hand. | |
| 285 | 159 | |
| 286 | -- **M1: clone and push work.** Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI. | |
| 287 | -- **M2 (current): repo browsing and raw domain.** Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on `user_content_base_url`. | |
| 288 | -- **M3: issues.** Issues, comments, labels, milestones, with permissions through `authorize()`. | |
| 289 | -- **M4: pull requests.** PR refs, mergeability via `merge-tree`, reviews, merge strategies. | |
| 290 | -- **M5: webhooks, push mirrors, replication, backups, export.** Including `VACUUM INTO` snapshots, integrity checks, and restore drills. | |
| 291 | -- **M6: custom CI runner and commit-status API.** Required checks enforced on push as well as PR merge. | |
| 160 | +If a new binary crashes on start three times within a minute, systemd gives up and runs `forge-rollback`. That script keeps the old binary as `~/bin/forge.prev`, so it puts it back, records the bad build's sha256 in `~/bin/forge.bad`, and starts the unit again. A forge or runner skips any hash listed there, so it will not install the same build again. Check the journal (`journalctl --user -u forge -e`) to see what happened. | |
| 292 | 161 | |
| 293 | -## License | |
| 162 | +Rollback does not undo database migrations. If the new version migrated the database, the old binary runs against the new schema. Take a backup before you rely on updates (see [Data and backups](#data-and-backups)). | |
| 163 | + | |
| 164 | +## Configuration | |
| 165 | + | |
| 166 | +Settings live in TOML. `config/forge.example.toml` lists every key with comments. Relative paths are relative to the working directory. The keys you are most likely to change: | |
| 167 | + | |
| 168 | +| Key | Example | Meaning | | |
| 169 | +|---|---|---| | |
| 170 | +| `listen` | `"127.0.0.1:7660"` | Address for the web UI and git over HTTPS. | | |
| 171 | +| `base_url` | `"https://forge.example.com"` | Public URL used in links and clone addresses. | | |
| 172 | +| `user_content_base_url` | `"https://usercontent.example.net"` | Public URL of the raw file origin. | | |
| 173 | +| `user_content_listen` | `"127.0.0.1:7661"` | Address of the raw file listener. | | |
| 174 | +| `ssh_listen` | `"0.0.0.0:2222"` | Address for git over SSH. | | |
| 175 | +| `ssh_public_host` / `ssh_public_port` | `"forge.example.com"` / `2222` | Host and port shown in SSH clone addresses. | | |
| 176 | +| `registration` | `"closed"` | `closed`, `invite`, or `open`. Accounts come from `/setup`, the CLI, or `/admin/users`. | | |
| 177 | +| `trusted_proxies` | `["127.0.0.1"]` | Proxy addresses whose `X-Forwarded-For` header is trusted. Leave empty if there is no proxy. | | |
| 178 | + | |
| 179 | +The config file is `config/forge.toml` by default. Pick another path with `--config` or the `FORGE_CONFIG` variable. | |
| 180 | + | |
| 181 | +## Admin commands | |
| 294 | 182 | |
| 295 | -forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). | |
| 183 | +Run `forge admin <command>`, or `cargo run -p forge-web -- admin <command>` from a checkout. | |
| 184 | + | |
| 185 | +| Command | What it does | | |
| 186 | +|---|---| | |
| 187 | +| `admin create-user <name> [--admin]` | Create a user. The first user is always an admin. | | |
| 188 | +| `admin set-username <current> <NewCase>` | Change only the capitalization of a username. | | |
| 189 | +| `admin reset-password <name>` | Set a random temporary password and sign the user out everywhere. | | |
| 190 | +| `admin delete-user <name> [--yes]` | Delete a user. | | |
| 191 | +| `admin trash-list` | List deleted repos, oldest first. | | |
| 192 | +| `admin trash-purge --older-than-days <N>` | Permanently remove trashed repos older than N days. | | |
| 193 | +| `admin audit-purge --older-than-days <N>` | Delete audit log entries older than N days. | | |
| 194 | +| `admin backup --out <DIR> [--include-key] [--repos-only \| --db-only]` | Write a backup set. | | |
| 195 | +| `admin backup-verify <PATH>` | Check a backup set. | | |
| 196 | +| `admin restore --from <SET> --data-dir <NEW_DIR>` | Restore a backup set into a new data directory. | | |
| 197 | +| `admin fsck [--full]` | Run `git fsck` on every repo and check the database. | | |
| 198 | +| `admin lfs-gc` | Delete unused LFS objects that are at least a day old. | | |
| 199 | + | |
| 200 | +`serve` is the default command. `hook` and `runner` are for git hooks and CI runners. Set `RUST_LOG` to change the log level. `GET /healthz` returns `ok` and the build commit. | |
| 201 | + | |
| 202 | +## Data and backups | |
| 203 | + | |
| 204 | +Everything lives under `data_dir`: | |
| 205 | + | |
| 206 | +| Path | What it is | | |
| 207 | +|---|---| | |
| 208 | +| `forge.db` | Metadata: users, sessions, keys, tokens, repos. | | |
| 209 | +| `forge.key` | Instance key. It encrypts stored secrets. Created on first boot. | | |
| 210 | +| `ssh_host_ed25519_key` | SSH host key. Created on first boot. | | |
| 211 | +| `repos/` | Bare repositories. | | |
| 212 | +| `trash/` | Repos deleted from their settings page, until you purge them. | | |
| 213 | + | |
| 214 | +Back up `forge.key` separately, and keep it offline. Without it, encrypted values such as mirror credentials cannot be read again. | |
| 215 | + | |
| 216 | +Never copy a live database file with `cp` or `rsync`. Take a consistent copy with `VACUUM INTO`, which is safe while forge runs: | |
| 217 | + | |
| 218 | +```sql | |
| 219 | +VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; | |
| 220 | +``` | |
| 221 | + | |
| 222 | +For a full backup, use `forge admin backup --out <DIR>`. See [`docs/backup.md`](docs/backup.md) for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill. | |
| 223 | + | |
| 224 | +#### Recovering a deleted repo | |
| 225 | + | |
| 226 | +Deleted repos stay in the trash until `admin trash-purge` runs. Find one with `forge admin trash-list`, then clone it with `git clone --mirror <path> recovered.git`. This restores all branches and tags, but not the owner, visibility, description, or collaborators. | |
| 227 | + | |
| 228 | +## CI runners | |
| 229 | + | |
| 230 | +CI workflows are TOML files in `.forge/ci.toml` in your repo. Jobs run on runners, which are `forge runner` processes on machines you control. The forge never connects to a runner; runners pull jobs over HTTPS. Each job runs in its own rootless sandbox, with its own workspace and a read-only token, and no container tool is needed. See [`docs/ci.md`](docs/ci.md) for the workflow format, the security model, and runner setup. | |
| 231 | + | |
| 232 | +## Security | |
| 233 | + | |
| 234 | +- Passwords use argon2id. Tokens are stored as SHA-256 hashes. | |
| 235 | +- Private repos you cannot see return 404, so their existence is not revealed. | |
| 236 | +- No shell runs for git, and pushes are checked for object integrity. Users cannot add hooks or repo-level git config. | |
| 237 | +- Login is throttled: 10 failed attempts per username and 30 per IP, over 15 minutes. | |
| 238 | +- Run forge as a dedicated user, keep the git binary patched, and keep `forge.key` and `data_dir` private. | |
| 239 | + | |
| 240 | +## License | |
| 296 | 241 | |
| 297 | -### Fonts | |
| 242 | +forge is free software, licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). | |
| 298 | 243 | |
| 299 | -The web UI bundles two fonts, served from the forge origin under `/assets/fonts/`: Figtree (UI) and IBM Plex Mono (code). Both are licensed under the SIL Open Font License 1.1. The license texts are in `style/fonts/` and are also served at `/assets/fonts/Figtree-OFL.txt` and `/assets/fonts/IBMPlexMono-OFL.txt`. Sources, pinned versions and checksums are listed in `style/fonts/README.md`. | |
| 244 | +The web UI bundles the Figtree and IBM Plex Mono fonts, both under the SIL Open Font License 1.1. Licenses and checksums are in `style/fonts/`. |
docs/architecture.md| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | # Forge Architecture |
| 2 | 2 | |
| 3 | -Status: milestones 1 and 2 are implemented. Later milestones are outlined here and not built yet. | |
| 3 | +Status: repo hosting and browsing are implemented. Later features are outlined here and not built yet. | |
| 4 | 4 | "Forge" is a placeholder name. Renaming is a find-and-replace. |
| 5 | 5 | |
| 6 | -Sources: the M1 plan (`~/.claude/plans/purring-prancing-wirth.md`), the interface contract (`docs/m1-interfaces.md`), and the research notes (`docs/research.md`, sections 1-16). Items marked *(unverified)* in the research are not confirmed. | |
| 7 | 6 | |
| 8 | 7 | --- |
| 9 | 8 | |
| @@ -27,11 +26,11 @@ Sources: the M1 plan (`~/.claude/plans/purring-prancing-wirth.md`), the interfac | ||
| 27 | 26 | | Git smart-HTTP | Routes in Topcoat, backed by the `git` binary | No axum. The git child process streams request and response bodies directly. | |
| 28 | 27 | | Metadata DB | Turso | Embedded SQLite-compatible store. Holds all metadata. WAL with `synchronous=FULL` (section 8.3). | |
| 29 | 28 | | Git serving | `git` binary (`upload-pack`, `receive-pack`, merges, blame, verify, maintenance) | Git's own implementation is the only trustworthy server side. | |
| 30 | -| Git reads | `gix` in-process | Object, tree, and blob reads, rev-walk, diffs, rename detection, archives (research §7). | | |
| 29 | +| Git reads | `gix` in-process | Object, tree, and blob reads, rev-walk, diffs, rename detection, archives. | | |
| 31 | 30 | | SSH | russh 0.64.x | Built-in SSH server for git over SSH. No OpenSSH dependency. | |
| 32 | 31 | | Hooks | `forge hook` subcommand over a unix socket | Hook policy lives in the app, not in files users can edit. | |
| 33 | 32 | |
| 34 | -Not used: `git2` (adds a C dependency, research §7). Dumb HTTP (disabled, research §1). | |
| 33 | +Not used: `git2` (adds a C dependency). Dumb HTTP (disabled). | |
| 35 | 34 | |
| 36 | 35 | --- |
| 37 | 36 | |
| @@ -42,7 +41,7 @@ Cargo workspace at the repo root. Edition 2024, resolver 3. | ||
| 42 | 41 | ``` |
| 43 | 42 | project_git/ |
| 44 | 43 | Cargo.toml .gitignore config/forge.toml deploy/forge.service style/main.scss |
| 45 | - docs/{research.md, architecture.md, m1-interfaces.md} | |
| 44 | + docs/architecture.md | |
| 46 | 45 | crates/ |
| 47 | 46 | forge-core/ domain types, store, migrations, config, secret key, authz, names, passwords, tokens, sessions |
| 48 | 47 | forge-git/ safe git command builder, process spawn and streaming, pkt-line, repo init, managed gitconfig, version check |
| @@ -80,7 +79,7 @@ Store API (`Store`: async, `Clone`, wraps the Turso connection). `open()` sets W | ||
| 80 | 79 | |
| 81 | 80 | ## 4. Data model |
| 82 | 81 | |
| 83 | -### 4.1 Milestone 1 tables (`0001_init.sql`) | |
| 82 | +### 4.1 Core tables (`0001_init.sql`) | |
| 84 | 83 | |
| 85 | 84 | Conventions: ids are ULID text. Timestamps are UTC RFC 3339 with 9 fractional digits and a `Z` suffix. Booleans are integers 0/1. Foreign keys are enforced. |
| 86 | 85 | |
| @@ -120,7 +119,7 @@ Conventions: ids are ULID text. Timestamps are UTC RFC 3339 with 9 fractional di | ||
| 120 | 119 | - `name` TEXT NOT NULL |
| 121 | 120 | - `token_hash` TEXT NOT NULL UNIQUE (hex sha256; the token is shown once) |
| 122 | 121 | - `scopes` TEXT NOT NULL (serialized list) |
| 123 | -- `expires_at` TEXT NULL (default expiry is 30 days, research §10) | |
| 122 | +- `expires_at` TEXT NULL (default expiry is 30 days) | |
| 124 | 123 | - `created_at` TEXT NOT NULL, `last_used_at` TEXT NULL |
| 125 | 124 | |
| 126 | 125 | **`repos`** |
| @@ -150,24 +149,24 @@ Conventions: ids are ULID text. Timestamps are UTC RFC 3339 with 9 fractional di | ||
| 150 | 149 | - `protocol` TEXT NOT NULL CHECK (`http` or `ssh`) |
| 151 | 150 | - `created_at` TEXT NOT NULL |
| 152 | 151 | |
| 153 | -Secrets (`forge.key` is not in the DB). Values sealed with XChaCha20-Poly1305 are stored as `v1:` plus base64(nonce || ciphertext). M1 has no sealed column yet. Mirror credentials and TOTP secrets are the first candidates (M5 and later). | |
| 152 | +Secrets (`forge.key` is not in the DB). Values sealed with XChaCha20-Poly1305 are stored as `v1:` plus base64(nonce || ciphertext). No column is sealed yet. Mirror credentials and TOTP secrets are the first candidates. | |
| 154 | 153 | |
| 155 | 154 | ### 4.2 Later tables (outline) |
| 156 | 155 | |
| 157 | -| Table | Milestone | Purpose | | |
| 158 | -|---|---|---| | |
| 159 | -| `issues` | M3 | Number per repo, title, body, state, author, timestamps | | |
| 160 | -| `comments` | M3 | Issue and PR comments, polymorphic by parent, edit history | | |
| 161 | -| `labels` | M3 | Per-repo labels, plus an `issue_labels` join | | |
| 162 | -| `milestones` | M3 | Per-repo milestones, due date, state | | |
| 163 | -| `pulls` | M4 | PR number, base and head refs, head oid, merge state, outdated flag | | |
| 164 | -| `reviews` | M4 | Reviewer, state (approve, request changes, comment), commit oid | | |
| 165 | -| `review_comments` | M4 | Commit oid, path, line, side, diff hunk, outdated flag (kept, never deleted) | | |
| 166 | -| `webhooks` | M5 | Target URL, events, encrypted secret (sealed) | | |
| 167 | -| `deliveries` | M5 | Delivery id, attempt, status, request and response, retry state | | |
| 168 | -| `mirrors` | M5 | Pull and push mirrors, interval, backoff, sealed credentials | | |
| 169 | -| `commit_statuses` | M6 | Commit oid, context, state, target URL, creator | | |
| 170 | -| `replication_state` | M5 | Per-repo, per-replica last ref checksum, last success and failure | | |
| 156 | +| Table | Purpose | | |
| 157 | +|---|---| | |
| 158 | +| `issues` | Number per repo, title, body, state, author, timestamps | | |
| 159 | +| `comments` | Issue and PR comments, polymorphic by parent, edit history | | |
| 160 | +| `labels` | Per-repo labels, plus an `issue_labels` join | | |
| 161 | +| `milestones` | Per-repo milestones, due date, state | | |
| 162 | +| `pulls` | PR number, base and head refs, head oid, merge state, outdated flag | | |
| 163 | +| `reviews` | Reviewer, state (approve, request changes, comment), commit oid | | |
| 164 | +| `review_comments` | Commit oid, path, line, side, diff hunk, outdated flag (kept, never deleted) | | |
| 165 | +| `webhooks` | Target URL, events, encrypted secret (sealed) | | |
| 166 | +| `deliveries` | Delivery id, attempt, status, request and response, retry state | | |
| 167 | +| `mirrors` | Pull and push mirrors, interval, backoff, sealed credentials | | |
| 168 | +| `commit_statuses` | Commit oid, context, state, target URL, creator | | |
| 169 | +| `replication_state` | Per-repo, per-replica last ref checksum, last success and failure | | |
| 171 | 170 | |
| 172 | 171 | --- |
| 173 | 172 | |
| @@ -220,16 +219,16 @@ Push: | ||
| 220 | 219 | - reject ref names with a leading `-` or containing `..` |
| 221 | 220 | 5. The response is `{allow, message}`. The hook prints `message` to stderr and exits 0 if allowed, 1 if not. A non-zero exit rejects the whole push. |
| 222 | 221 | 6. On accept, git moves the objects out of quarantine and updates refs. Git updates refs in one transaction. |
| 223 | -7. Post-receive: `hook post-receive` POSTs the same updates to `/hook/post-receive`. The handler calls `record_push_event` for each ref. Replication (M5), webhooks (M5), PR ref updates (M4), and cache invalidation (M2) attach here. Post-receive cannot reject. | |
| 222 | +7. Post-receive: `hook post-receive` POSTs the same updates to `/hook/post-receive`. The handler calls `record_push_event` for each ref. Replication, webhooks, PR ref updates, and cache invalidation attach here. Post-receive cannot reject. | |
| 224 | 223 | 8. Hook failures are fail-closed for pre-receive. If the socket is unreachable, the push is rejected. |
| 225 | 224 | |
| 226 | -### 5.5 User-content domain for raw files (M2) | |
| 225 | +### 5.5 User-content domain for raw files | |
| 227 | 226 | |
| 228 | 227 | Raw and user-uploaded files are served from `user_content_base_url` (for example `raw.<domain>`), a separate origin from the app. |
| 229 | 228 | |
| 230 | 229 | 1. Request arrives on the user-content host. The path is parsed as owner, repo, ref, and file path. |
| 231 | 230 | 2. Repo lookup, then `authorize(actor, repo, role, Action::Read)`. Anonymous is allowed for public repos. A private repo the caller cannot see gives 404. |
| 232 | -3. Every path component is checked for symlinks before reading, not only the last one (research §4). | |
| 231 | +3. Every path component is checked for symlinks before reading, not only the last one. | |
| 233 | 232 | 4. The blob is read through `gix` and streamed. HTML and SVG are served with `Content-Disposition: attachment`. |
| 234 | 233 | 5. Every response carries `X-Content-Type-Options: nosniff` and a strict CSP. |
| 235 | 234 | 6. Cookies from the app origin are never sent to this origin. Session auth is not accepted on this host. |
| @@ -268,7 +267,7 @@ Rules (in order): | ||
| 268 | 267 | |
| 269 | 268 | Status mapping: `NotFound` becomes 404 everywhere, including git and SSH. `Forbidden` becomes 403 on web and after authentication. For git over HTTP, an unauthenticated request that needs auth gets 401 with the Basic challenge. A private repo is never revealed by a 403. Any future go-get-style endpoints follow the same rule. |
| 270 | 269 | |
| 271 | -Roles: `Read`, `Triage`, `Write`, `Maintain`, `Admin` (per repo, from `collaborators`), plus instance admin. Org teams come later (research §10). | |
| 270 | +Roles: `Read`, `Triage`, `Write`, `Maintain`, `Admin` (per repo, from `collaborators`), plus instance admin. Org teams come later. | |
| 272 | 271 | |
| 273 | 272 | --- |
| 274 | 273 | |
| @@ -286,7 +285,7 @@ Rules: | ||
| 286 | 285 | - `paths()` appends `--` then the paths. |
| 287 | 286 | - `env()` takes only `&'static str` keys. |
| 288 | 287 | - User input reaches git only through `GitCommand`. |
| 289 | -- Repo-local config is not trusted (research §4). Submodules are never recursed. | |
| 288 | +- Repo-local config is not trusted. Submodules are never recursed. | |
| 290 | 289 | |
| 291 | 290 | ### 7.2 Managed gitconfig (`<data_dir>/gitconfig`) |
| 292 | 291 | |
| @@ -294,32 +293,32 @@ Written at boot by `write_managed_gitconfig()`. Keys: | ||
| 294 | 293 | |
| 295 | 294 | - `core.hooksPath=<data_dir>/hooks` |
| 296 | 295 | - `receive.fsckObjects=true`, `transfer.fsckObjects=true` |
| 297 | -- `core.fsync` and `core.fsyncMethod` set explicitly. The default `committed,-loose-object` risks losing recent work (research §16). | |
| 296 | +- `core.fsync` and `core.fsyncMethod` set explicitly. The default `committed,-loose-object` risks losing recent work. | |
| 298 | 297 | - `uploadpack.allowFilter=true`, `uploadpack.allowRefInWant=true` |
| 299 | 298 | - `receive.advertisePushOptions=true` |
| 300 | -- `protocol.file.allow=user` (research §4) | |
| 301 | -- Later: `uploadpack.hideRefs` and `transfer.hideRefs` for internal refs (M4 `refs/pull/*` as needed) | |
| 299 | +- `protocol.file.allow=user` | |
| 300 | +- Later: `uploadpack.hideRefs` and `transfer.hideRefs` for internal refs (`refs/pull/*` as needed) | |
| 302 | 301 | |
| 303 | 302 | ### 7.3 Repo path layout |
| 304 | 303 | |
| 305 | 304 | `<data_dir>/repos/<id[0..2]>/<id[2..4]>/<id>.git`, where `<id>` is the lowercased ULID. Created with `git init --bare` through the builder. Renames are DB-only, because the path depends on the id, not the name. |
| 306 | 305 | |
| 307 | -### 7.4 Maintenance and caches (M2 and later) | |
| 306 | +### 7.4 Maintenance and caches | |
| 308 | 307 | |
| 309 | 308 | - `git maintenance` with incremental repack and loose-objects, or `git repack --geometric=2 --write-midx`. |
| 310 | 309 | - Commit-graph with changed-path Bloom filters (makes `git log -- path` fast). |
| 311 | 310 | - Bitmaps for clone speed. |
| 312 | -- A pool of long-running `git cat-file --batch-command` processes, one per repo, for hot reads (research §7). | |
| 313 | -- "Last commit per file" cache keyed by `(commit, path)` for tree views (research §5). | |
| 311 | +- A pool of long-running `git cat-file --batch-command` processes, one per repo, for hot reads. | |
| 312 | +- "Last commit per file" cache keyed by `(commit, path)` for tree views. | |
| 314 | 313 | - Archive cache, with no promise of stable checksums across git versions. |
| 315 | -- Forks: full copies first. Alternates or object pools are deferred, and pools must never be pruned (research §5). | |
| 314 | +- Forks: full copies first. Alternates or object pools are deferred, and pools must never be pruned. | |
| 316 | 315 | - Ref updates use `git update-ref --stdin` for atomicity. |
| 317 | 316 | |
| 318 | 317 | ### 7.5 Minimum git version |
| 319 | 318 | |
| 320 | 319 | - Boot calls `require_min_version(env, (2,38,0))`. Boot fails if git is older. |
| 321 | -- 2.38 is required for `merge-tree --write-tree` (M4). | |
| 322 | -- In practice, track the latest git release for security fixes. Keep it patched (CVE-2024-32002 and CVE-2025-48384 are submodule hook-execution bugs, research §4). | |
| 320 | +- 2.38 is required for `merge-tree --write-tree`. | |
| 321 | +- In practice, track the latest git release for security fixes. Keep it patched (CVE-2024-32002 and CVE-2025-48384 are submodule hook-execution bugs). | |
| 323 | 322 | |
| 324 | 323 | --- |
| 325 | 324 | |
| @@ -330,11 +329,11 @@ Written at boot by `write_managed_gitconfig()`. Keys: | ||
| 330 | 329 | - **Primary** (writes, serves users). |
| 331 | 330 | - **Standby** at a different provider. It receives git mirrors, DB snapshots, and bundles. |
| 332 | 331 | - **Immutable backups** at a third location and provider. |
| 333 | -- Provider diversity covers provider, payment method, contact email, and jurisdiction. Research §11 notes that account termination can happen without warning. Lesson from SourceHut (2024): the standby was under 30s behind, but backups were up to 12h old with no off-site copy. Lesson from OVH Strasbourg (2021): backups at the same site burned with the primary. | |
| 332 | +- Provider diversity covers provider, payment method, contact email, and jurisdiction. Account termination can happen without warning. Lesson from SourceHut (2024): the standby was under 30s behind, but backups were up to 12h old with no off-site copy. Lesson from OVH Strasbourg (2021): backups at the same site burned with the primary. | |
| 334 | 333 | |
| 335 | 334 | ### 8.2 Git replication (single writer) |
| 336 | 335 | |
| 337 | -1. After post-receive, a replication job takes a per-repo lock and runs `git push --mirror` to each replica over SSH. Failures are recorded and retried with backoff. Push mirrors run after each push (M5). | |
| 336 | +1. After post-receive, a replication job takes a per-repo lock and runs `git push --mirror` to each replica over SSH. Failures are recorded and retried with backoff. Push mirrors run after each push. | |
| 338 | 337 | 2. Hourly reconcile: compare a ref checksum (sha256 of sorted `git for-each-ref --format='%(objectname) %(refname)'`) between primary and replica. On mismatch, re-mirror and run `git fsck`. |
| 339 | 338 | 3. Daily `git bundle create --all` per repo to off-provider storage, then `git bundle verify`. |
| 340 | 339 | 4. Weekly `git fsck --full` on replicas from cron. `git maintenance` has no fsck task. |
| @@ -345,7 +344,7 @@ Written at boot by `write_managed_gitconfig()`. Keys: | ||
| 345 | 344 | - Turso supports only `synchronous` OFF or FULL. Use WAL with FULL. |
| 346 | 345 | - Litestream is not verified on Turso files, and Turso says mixed SQLite and Turso access across processes is unsupported. Spike before relying on it (open question 2). |
| 347 | 346 | - Turso Sync and CDC are documented only for Turso Cloud. Do not depend on them for self-hosting. |
| 348 | -- Verified path: `VACUUM INTO` snapshots every 5-15 minutes. Ship each snapshot to the standby and to backups. Keep a JSON or F3 export as a second format (F3: friendly forge format, JSON plus attachments, research §9). | |
| 347 | +- Verified path: `VACUUM INTO` snapshots every 5-15 minutes. Ship each snapshot to the standby and to backups. Keep a JSON or F3 export as a second format (F3: friendly forge format, JSON plus attachments). | |
| 349 | 348 | - Integrity: `PRAGMA quick_check` daily. `integrity_check` plus `foreign_key_check` weekly. Alert on any result other than `ok`. |
| 350 | 349 | |
| 351 | 350 | ### 8.4 Integrity, backups, and drills |
| @@ -360,7 +359,7 @@ Written at boot by `write_managed_gitconfig()`. Keys: | ||
| 360 | 359 | - Dead-man switch: healthchecks.io pings with `/start` and `/fail` for each backup and integrity job. A missed ping alerts. |
| 361 | 360 | - Filesystem: btrfs (or ZFS with at least 4 GB RAM) for checksums, scrub, and snapshots. ext4 or XFS with tested backups is acceptable on small VPSes. |
| 362 | 361 | - App files: write temp file, fsync it, rename it, fsync the directory. |
| 363 | -- After any unclean shutdown, run `git fsck` (GitLab runbook, research §11). | |
| 362 | +- After any unclean shutdown, run `git fsck` (GitLab runbook). | |
| 364 | 363 | |
| 365 | 364 | ### 8.5 Manual failover with fencing |
| 366 | 365 | |
| @@ -380,9 +379,9 @@ Do: | ||
| 380 | 379 | - Put `--end-of-options` before user revisions and `--` before paths. Reject names and refs that start with `-`. |
| 381 | 380 | - Route all git calls through `GitCommand`. |
| 382 | 381 | - Check every path component for symlinks when rendering, archiving, or writing. |
| 383 | -- Use exact, case-sensitive key lookup (Gitea auth bypass, research §2). | |
| 384 | -- Disable `ssh-rsa` with SHA-1. Set handshake and auth timeouts (Gogs DoS, research §2). | |
| 385 | -- Accept only `GIT_PROTOCOL` from SSH env requests (Gogs CVE-2024-39930, research §2). | |
| 382 | +- Use exact, case-sensitive key lookup (Gitea auth bypass). | |
| 383 | +- Disable `ssh-rsa` with SHA-1. Set handshake and auth timeouts (Gogs DoS). | |
| 384 | +- Accept only `GIT_PROTOCOL` from SSH env requests (Gogs CVE-2024-39930). | |
| 386 | 385 | - Store tokens and session tokens as sha256 hashes. Compare in constant time. Show tokens once. |
| 387 | 386 | - Keep tokens out of URLs and logs. |
| 388 | 387 | - Return 404 for both missing and forbidden private repos. |
| @@ -402,16 +401,16 @@ Don't: | ||
| 402 | 401 | - Don't use dumb HTTP or `http.getanyfile`. |
| 403 | 402 | - Don't recurse into submodules or trust repo-local config. |
| 404 | 403 | - Don't let users write hooks or config. |
| 405 | -- Don't hard-delete users. Reassign their issues and comments to a ghost user (M3). | |
| 404 | +- Don't hard-delete users. Reassign their issues and comments to a ghost user. | |
| 406 | 405 | - Don't let the browser use the app origin for raw content. |
| 407 | -- Don't bypass branch rules through a non-PR path (the Gitea gap: required checks only applied to PR merges, research §3). | |
| 406 | +- Don't bypass branch rules through a non-PR path (the Gitea gap: required checks only applied to PR merges). | |
| 408 | 407 | - Don't rely on Turso Sync or Litestream until spiked. |
| 409 | 408 | |
| 410 | 409 | --- |
| 411 | 410 | |
| 412 | -## 10. Milestones | |
| 411 | +## 10. Roadmap | |
| 413 | 412 | |
| 414 | -### M1: clone and push work (this plan) | |
| 413 | +### Clone and push | |
| 415 | 414 | |
| 416 | 415 | - Scaffold the workspace (`cargo add`, latest versions), config template, `.gitignore`, systemd unit, SCSS build. |
| 417 | 416 | - Local accounts with argon2id. First user becomes admin, via `admin create-user` or `/setup` while zero users exist. Registration is `closed` by default. |
| @@ -421,7 +420,7 @@ Don't: | ||
| 421 | 420 | - Pre-receive policy (archived, default-branch delete and force-push, bad ref names) and post-receive `push_events`. Minimal UI: setup, login, dashboard, new repo, repo page, settings, admin users, `/healthz`. |
| 422 | 421 | - Tests: unit tests in core and git; end-to-end `git_e2e.rs` over HTTPS and SSH, with negative cases (bad token, cross-user private 404, forced default-branch push rejected, unknown SSH command rejected, traversal rejected, POST without CSRF rejected). |
| 423 | 422 | |
| 424 | -### M2: repo browsing and raw domain (implemented) | |
| 423 | +### Repo browsing and raw files | |
| 425 | 424 | |
| 426 | 425 | Built: |
| 427 | 426 | |
| @@ -457,15 +456,15 @@ Known gaps: | ||
| 457 | 456 | - Symlink-safe path resolution, and `cat-file --batch-command` pool. |
| 458 | 457 | - Caches: last commit per file, archives. Maintenance schedule and commit-graph. |
| 459 | 458 | |
| 460 | -### M3: issues | |
| 459 | +### Issues | |
| 461 | 460 | |
| 462 | 461 | - Issues with per-repo numbers, state, author, timestamps, and edit history. |
| 463 | 462 | - Comments on issues (polymorphic `comments` table), with ghost-user reassignment on account deletion. |
| 464 | 463 | - Labels and milestones, with per-repo scope. |
| 465 | 464 | - Issue list and detail pages, filters, and permissions through `authorize()`. |
| 466 | -- Markdown bodies sanitized as in M2. | |
| 465 | +- Markdown bodies are sanitized the same way as repo READMEs. | |
| 467 | 466 | |
| 468 | -### M4: pull requests | |
| 467 | +### Pull requests | |
| 469 | 468 | |
| 470 | 469 | - PR head stored as `refs/pull/N/head` in the base repo, updated on every push to the head branch in post-receive. |
| 471 | 470 | - Mergeability checked with `git merge-tree --write-tree`, recomputed when base or head moves. Optional `refs/pull/N/merge`. |
| @@ -474,7 +473,7 @@ Known gaps: | ||
| 474 | 473 | - Merge strategies: merge commit (`commit-tree` from merge-tree output), squash, rebase, fast-forward only. |
| 475 | 474 | - `range-diff` for "what changed since the last review." AGit (`refs/for/<branch>`) is considered later. |
| 476 | 475 | |
| 477 | -### M5: webhooks, push mirrors, replication, backups, export | |
| 476 | +### Webhooks, push mirrors, replication, backups, export | |
| 478 | 477 | |
| 479 | 478 | - Webhooks: HMAC-SHA256 signature over the raw body, delivery id header, 10s timeout, retry queue, delivery log. |
| 480 | 479 | - Pull mirrors on an interval with a minimum; push mirrors with sealed credentials, run after each push with backoff. SSRF rules from section 9 apply. |
| @@ -483,12 +482,12 @@ Known gaps: | ||
| 483 | 482 | - Backups per section 8.4: immutable tier, append-only push, restore drills, dead-man alerts. |
| 484 | 483 | - Manual failover runbook per section 8.5, tested once end to end. |
| 485 | 484 | |
| 486 | -### M6: custom CI runner and commit-status API | |
| 485 | +### Custom CI runner and commit-status API | |
| 487 | 486 | |
| 488 | -- Commit-status API (`commit_statuses`: commit oid, context, state, target URL, creator). Required checks enforced on push and PR merge, not only on PR merge (research §3). | |
| 489 | -- Webhooks from M5 drive CI job dispatch. | |
| 487 | +- Commit-status API (`commit_statuses`: commit oid, context, state, target URL, creator). Required checks enforced on push and PR merge, not only on PR merge. | |
| 488 | +- Webhooks drive CI job dispatch. | |
| 490 | 489 | - Custom runner: a separate process with a narrow protocol, that reports status back through the API. Runner credentials are scoped tokens. |
| 491 | -- Consider a Gitea-compatible API subset later, which would let Woodpecker, Renovate, and `tea` work (research §9). Decide when the API surface is known. | |
| 490 | +- Consider a Gitea-compatible API subset later, which would let Woodpecker, Renovate, and `tea` work. Decide when the API surface is known. | |
| 492 | 491 | - Log retention and secret masking for job output. |
| 493 | 492 | |
| 494 | 493 | --- |
| @@ -496,5 +495,5 @@ Known gaps: | ||
| 496 | 495 | ## 11. Open questions |
| 497 | 496 | |
| 498 | 497 | 1. **Project name.** "forge" is a placeholder. Renaming is a find-and-replace, but pick it before public URLs and crate names are used. |
| 499 | -2. **Does Litestream work with Turso files?** Unverified. Turso says mixed SQLite and Turso access across processes is unsupported. Run a spike before relying on it for replication. Fallback is the `VACUUM INTO` path in section 8.3. | |
| 500 | -3. **Hosting providers.** Primary, standby, and backup providers must differ in provider, payment method, contact email, and jurisdiction (section 8.1). Candidates are not chosen. Confirm after reviewing the provider risk notes in research §11 (some are unverified). | |
| 498 | +2. **Does Litestream work with Turso files?** Not yet tested. Turso says mixed SQLite and Turso access across processes is unsupported. Run a spike before relying on it for replication. Fallback is the `VACUUM INTO` path in section 8.3. | |
| 499 | +3. **Hosting providers.** Primary, standby, and backup providers must differ in provider, payment method, contact email, and jurisdiction (section 8.1). Candidates are not chosen. Confirm after reviewing the provider risk notes. |
docs/backlog.md| @@ -35,7 +35,7 @@ Status: `[ ]` todo, `[~]` in progress, `[x]` done. | ||
| 35 | 35 | - [x] Webhooks (HMAC-SHA256, delivery log, retries, SSRF guard) |
| 36 | 36 | - [x] Webhook follow-ups: audit entries, sent headers in the delivery log |
| 37 | 37 | - [x] Notifications (in-app inbox for mentions, assignments, comments on watched issues) |
| 38 | -- [x] Push and pull mirrors (contract: `docs/mirrors-interfaces.md`) | |
| 38 | +- [x] Push and pull mirrors | |
| 39 | 39 | - [x] Mirror follow-ups: add a pull mirror in settings to an empty repo; e2e pull over real HTTP; pin git's DNS to the checked address (rebinding race) |
| 40 | 40 | - [x] Backups: verified sets (DB snapshot + bundles), scheduler with retention and healthchecks, `fsck` schedule, `/admin/backups`, restore command, `docs/backup.md` |
| 41 | 41 | - [x] Replication to the standby (`push --mirror` after each push, hourly reconcile by refs checksum, DB snapshots; `/admin/replication`; runbook in `docs/backup.md`) |
| @@ -60,7 +60,7 @@ Status: `[ ]` todo, `[~]` in progress, `[x]` done. | ||
| 60 | 60 | - [x] Signed commit verification (SSH/GPG) badges |
| 61 | 61 | - [x] Compression for HTML pages (tower-http layer with a text-only, path-excluding policy; `docs/architecture.md` §5.6) |
| 62 | 62 | |
| 63 | -## M6: CI (contract: `docs/ci-interfaces.md`) | |
| 63 | +## M6: CI (see `docs/ci.md`) | |
| 64 | 64 | - [x] `forge-ci` crate: `.forge/ci.toml` parsing and validation, triggers, runner protocol types, secret masking |
| 65 | 65 | - [x] Schema and store: runners, registration tokens, runs, jobs, artifacts, sealed secrets |
| 66 | 66 | - [x] Scheduling on push, tag, PR and manual runs; fork PR approval; commit statuses; lost-runner reaper; retention |
| @@ -70,17 +70,17 @@ Status: `[ ]` todo, `[~]` in progress, `[x]` done. | ||
| 70 | 70 | - [x] `forge-runner` binary: rootless podman executor, workspace checkout, log streaming, artifacts, timeouts |
| 71 | 71 | - [x] CI end-to-end test and `docs/ci.md` |
| 72 | 72 | |
| 73 | -## Runner without podman (contract: `docs/ci-builtin-executor.md`) | |
| 73 | +## Runner without podman (see `docs/ci.md`) | |
| 74 | 74 | - [x] Images: in-process registry pull, digest-verified blob cache, safe layer unpacking (whiteouts, no escapes), GC |
| 75 | 75 | - [x] Sandbox: `forge-runner __sandbox` helper on youki `libcontainer` (rootless userns, pure-Rust seccomp via seccompiler, no caps, overlay rootfs, cgroup limits) |
| 76 | 76 | - [x] `BuiltinExecutor` as the default, `forge-runner check`, runner config and `docs/ci.md` updates |
| 77 | 77 | |
| 78 | -## CI sandbox v2 (contract: `docs/ci-sandbox-v2.md`) | |
| 78 | +## CI sandbox v2 | |
| 79 | 79 | - [x] Subordinate id mapping via newuidmap/newgidmap, auto fallback to single uid, `__sandbox-rm` |
| 80 | 80 | - [x] Image layers unpacked with real ownership inside the mapped userns (`__sandbox-unpack`) |
| 81 | 81 | - [x] Sandbox, executor, config (`[sandbox] userns`), `check` and docs use the mapping |
| 82 | 82 | |
| 83 | -## Wave C (contract: `docs/wave-c-interfaces.md`) | |
| 83 | +## Wave C | |
| 84 | 84 | - [x] O1 organizations core (accounts of kind org, members, teams, authz) |
| 85 | 85 | - [x] O2 organizations UI (org pages, owner select, transfer to org) |
| 86 | 86 | - [x] M1 mail core (lettre, outbox worker, admin mail settings) |
docs/backup-interfaces.md| @@ -1,51 +0,0 @@ | ||
| 1 | -# Backups, phase 1 (contract between workers) | |
| 2 | - | |
| 3 | -The background is in `docs/architecture.md` §8.2–8.4. Phase 1 makes complete, verifiable backups **locally**: a directory that an external tool (restic, rclone, or borg with append-only keys) ships off-site. Shipping it, standby replication and push mirrors come later. | |
| 4 | - | |
| 5 | -The global rules apply: | |
| 6 | -- No unwrap in non-test code. | |
| 7 | -- Tests use `tempfile::TempDir`. | |
| 8 | -- Never put cargo target dirs or big files in /tmp. | |
| 9 | - | |
| 10 | -## Backup set layout | |
| 11 | -``` | |
| 12 | -<out>/forge-backup-<UTC yyyymmddThhmmssZ>/ | |
| 13 | - manifest.json # format version, forge version, created_at, instance base_url, files [{path, bytes, sha256}], repos [{id, owner, name, refs_sha256, bundle | null (empty repo)}] | |
| 14 | - db/forge.db # consistent snapshot | |
| 15 | - repos/<repo id>.bundle # `git bundle create --all`, then `git bundle verify` before the manifest is written | |
| 16 | - keys/forge.key # only with --include-key; otherwise the manifest records "key_included": false | |
| 17 | -``` | |
| 18 | -- `refs_sha256` is the sha256 of sorted `git for-each-ref --format='%(objectname) %(refname)'` (the reconcile checksum from architecture §8.2). It's computed from the bundle-time ref list, so a restore can compare against it. | |
| 19 | -- The directory is written as `.partial-…` and renamed atomically when complete. A crash never leaves something that looks like a finished backup. | |
| 20 | -- Files are 0600 and directories 0700. | |
| 21 | - | |
| 22 | -## DB snapshot | |
| 23 | -- Use `VACUUM INTO '<path>'` if Turso 0.8.x supports it; the worker must verify this with a test. | |
| 24 | -- **Fallback:** hold the store's connection mutex so there are no concurrent writers, run `PRAGMA wal_checkpoint(TRUNCATE)` (or the Turso equivalent), copy the DB file (and the WAL, if any is left), then release. | |
| 25 | -- Verify the result by opening it read-only with a fresh connection and running `PRAGMA integrity_check` (or `quick_check`), with the migrations version check. | |
| 26 | - | |
| 27 | -## Commands (`forge-web admin …`) | |
| 28 | -- `backup --out DIR [--include-key] [--repos-only | --db-only]`: prints the path and a summary, and exits non-zero on any failure. | |
| 29 | -- `backup-verify PATH`: recomputes every sha256, runs `git bundle verify` on each bundle (in a scratch repo under PATH's parent, **not /tmp**), opens the DB snapshot read-only and runs `integrity_check`, and checks the manifest's repo list against the DB snapshot's `repos` table. | |
| 30 | -- `restore --from PATH --data-dir NEW_DIR`: refuses a non-empty NEW_DIR. Copies the DB, then for each repo runs `git clone --mirror <bundle>` into `repo_path(NEW_DIR, id)` (or `init --bare` + `fetch` from the bundle with `+refs/*:refs/*`) and removes the remote. Then writes the managed gitconfig and hooks the same way boot does, compares `refs_sha256` per repo, and writes `forge.key` if the set has one (otherwise warns loudly that the key must be restored by hand). | |
| 31 | -- `fsck [--full]`: `git fsck` (`--full` if asked) on every repo plus the DB `integrity_check`. Reports per repo, and exits non-zero on any problem. | |
| 32 | - | |
| 33 | -## Scheduler (config `[backup]`) | |
| 34 | -```toml | |
| 35 | -[backup] | |
| 36 | -dir = "" # empty = disabled | |
| 37 | -interval_hours = 24 # full backup set | |
| 38 | -keep = 7 # keep the newest N complete sets in dir (delete older complete sets only; never touch other files) | |
| 39 | -healthcheck_url = "" # optional; GET <url>/start, then <url> on success or <url>/fail with the error (healthchecks.io style) | |
| 40 | -fsck_interval_days = 7 | |
| 41 | -``` | |
| 42 | -- A tokio task started at boot runs the same code as the CLI. Healthcheck pings use a plain reqwest client with a 10s timeout; the URL is admin-configured and trusted. | |
| 43 | -- `GET /admin/backups` (instance admins): the last 20 runs (time, duration, size, ok or error), backup and fsck statuses, and a "Run backup now" POST with CSRF. Runs are recorded in a table `backup_runs(id, kind 'backup'|'fsck', started_at, finished_at, ok, path, bytes, error)`, migration `0014_backup_runs.sql`. | |
| 44 | - | |
| 45 | -## Docs | |
| 46 | -`docs/backup.md` covers: | |
| 47 | -- what is in a set and what isn't (the key); | |
| 48 | -- how to ship it off-site with restic append-only or rclone, plus a sample systemd timer; | |
| 49 | -- how to restore, step by step; | |
| 50 | -- a quarterly restore drill; | |
| 51 | -- the fencing reminder from architecture §8.5. |
docs/backup.md| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Backups |
| 2 | 2 | |
| 3 | -This is the operator guide for forge backups. The design is in `docs/architecture.md` §8 and the format is in `docs/backup-interfaces.md`. Read this page before you rely on backups, and run the restore drill below at least once a quarter. | |
| 3 | +This is the operator guide for forge backups. The design is in `docs/architecture.md` §8. Read this page before you rely on backups, and run the restore drill below at least once a quarter. | |
| 4 | 4 | |
| 5 | 5 | ## What a backup set contains |
| 6 | 6 |
docs/branch-protection-interfaces.md| @@ -1,60 +0,0 @@ | ||
| 1 | -# Branch protection (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from `docs/m1-interfaces.md` apply. | |
| 4 | - | |
| 5 | -## Migration `crates/forge-core/migrations/0003_branch_protection.sql` | |
| 6 | -```sql | |
| 7 | -CREATE TABLE branch_protections ( | |
| 8 | - id TEXT PRIMARY KEY, -- ULID | |
| 9 | - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 10 | - pattern TEXT NOT NULL, -- "main", "release/*", "**" | |
| 11 | - allow_force_push INTEGER NOT NULL DEFAULT 0, | |
| 12 | - allow_deletion INTEGER NOT NULL DEFAULT 0, | |
| 13 | - created_at TEXT NOT NULL, | |
| 14 | - UNIQUE (repo_id, pattern) | |
| 15 | -); | |
| 16 | -``` | |
| 17 | -`Store::delete_repo` must delete these rows too, in the same transaction. | |
| 18 | - | |
| 19 | -## forge-core `src/protection.rs` | |
| 20 | -```rust | |
| 21 | -#[derive(Debug, Clone, PartialEq)] | |
| 22 | -pub struct BranchRule { pub id: String, pub repo_id: String, pub pattern: String, | |
| 23 | - pub allow_force_push: bool, pub allow_deletion: bool, pub created_at: String } | |
| 24 | - | |
| 25 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 26 | -pub struct Protection { pub allow_force_push: bool, pub allow_deletion: bool } | |
| 27 | - | |
| 28 | -/// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything | |
| 29 | -/// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no | |
| 30 | -/// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix. | |
| 31 | -pub fn validate_pattern(p: &str) -> Result<()>; | |
| 32 | -pub fn pattern_matches(pattern: &str, branch: &str) -> bool; | |
| 33 | - | |
| 34 | -/// Rules for `branch` (short name, without refs/heads/): | |
| 35 | -/// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the | |
| 36 | -/// longest pattern wins, and ties go to the earliest created_at. | |
| 37 | -/// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}. | |
| 38 | -/// 3. Otherwise: unprotected {force_push: true, deletion: true}. | |
| 39 | -pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection; | |
| 40 | -``` | |
| 41 | - | |
| 42 | -## Store (forge-core) | |
| 43 | -```rust | |
| 44 | -pub async fn list_branch_rules(&self, repo_id: &str) -> Result<Vec<BranchRule>>; // ordered by pattern | |
| 45 | -pub async fn upsert_branch_rule(&self, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool) -> Result<BranchRule>; | |
| 46 | -pub async fn delete_branch_rule(&self, repo_id: &str, rule_id: &str) -> Result<()>; // scoped by repo_id | |
| 47 | -``` | |
| 48 | - | |
| 49 | -## Enforcement (forge-web `internal.rs` pre-receive) | |
| 50 | -For each updated `refs/heads/<branch>`, compute `effective(rules, repo.default_branch, branch)`: | |
| 51 | -- deletion (new = zero oid) with `!allow_deletion` → reject: `deleting protected branch '<b>' is not allowed` | |
| 52 | -- non-fast-forward (the existing `rev-list` check) with `!allow_force_push` → reject: `force-push to protected branch '<b>' is not allowed` | |
| 53 | -- Pushes to the archived repo and the ref-name checks stay as they are. Tags are not covered by these rules. | |
| 54 | - | |
| 55 | -## UI (forge-web `pages/repo_settings.rs`) | |
| 56 | -- On `/{owner}/{repo}/settings`, a "Branch protection" section above the Danger zone: | |
| 57 | - - A table of rules: pattern, force push allowed (yes/no), deletion allowed (yes/no), and a Remove button (POST with CSRF). | |
| 58 | - - If no rule matches the default branch, show a muted row: "`<default>` (default branch): protected by default; add a rule for it to change this." | |
| 59 | - - An add/update form: pattern (prefilled with the default branch), checkboxes "Allow force pushes" and "Allow deletion", and a Save button. Saving an existing pattern updates it. | |
| 60 | -- Who can manage rules: `Action::Admin` (the repo owner, instance admins, and collaborators with the Admin role). Others get 404 or 403, like the existing settings. |
docs/ci-builtin-executor.md| @@ -1,104 +0,0 @@ | ||
| 1 | -# Built-in executor for forge-runner (contract between workers) | |
| 2 | - | |
| 3 | -**Goal:** `forge-runner` runs jobs in containers with **no external container tool**. There's no podman: images are pulled and unpacked in-process, and the job runs in a rootless sandbox built with youki's `libcontainer` crate. `PodmanExecutor` stays as an option. Runner config gains `executor = "builtin"` (the default) or `"podman"`. | |
| 4 | - | |
| 5 | -The global rules apply: | |
| 6 | -- No unwrap in non-test code. | |
| 7 | -- Tests use `tempfile::TempDir`. | |
| 8 | -- Never put cargo target dirs or big files in /tmp. | |
| 9 | -- Crates go in at their latest version via `cargo add`, recorded in the root `[workspace.dependencies]`. | |
| 10 | -- Only rustfmt your own files. | |
| 11 | - | |
| 12 | -Crates (latest): `oci-client`, `oci-spec`, `libcontainer` (youki; **check its 0.7 API in ~/.cargo/registry/src/*/libcontainer-*/ after adding it**), `tar`, `flate2`, `zstd` (if layers can be zstd), `sha2`, `nix` or `rustix`. | |
| 13 | - | |
| 14 | -## Design decisions (fixed) | |
| 15 | -- **Self re-exec.** libcontainer forks and clones, which is unsafe inside a multi-threaded tokio process. So the runner never calls libcontainer in its own process. It spawns `/proc/self/exe __sandbox` (a hidden subcommand in main.rs) as a single-threaded child **with no tokio runtime**. The child reads a JSON `SandboxRequest` from **stdin** (the secrets travel there, never in argv or files), builds the container, runs it, forwards stdout and stderr, and exits with the job's exit code. Killing the helper (SIGTERM, then SIGKILL after a grace period) must tear the container down: the helper handles SIGTERM by killing and deleting the container. | |
| 16 | -- **User namespace, v1: single-uid mapping.** Container uid 0 maps to the runner's uid, with no `newuidmap` needed. Layers are unpacked with ownership flattened to the runner's user, and setuid/setgid bits are stripped. Document that package managers which `chown` to other users may fail. (v2, later and not in this task: a subuid range through `newuidmap` when available.) | |
| 17 | -- **Namespaces:** user, mount, pid, ipc, uts, cgroup. **Network:** `network = false` gets a new empty net namespace (loopback only). `network = true` **shares the host network namespace**, because rootless isolated networking needs pasta or slirp, which are external tools. Document this clearly in docs/ci.md: on `network = true` jobs can reach services on the runner host's localhost. | |
| 18 | -- **Hardening:** | |
| 19 | - - no capabilities, in any of the sets; | |
| 20 | - - `no_new_privileges`; | |
| 21 | - - a seccomp profile: default allow, deny with EPERM for `keyctl`, `add_key`, `request_key`, `bpf`, `perf_event_open`, `ptrace`, `mount`, `umount2`, `pivot_root`, `swapon`, `swapoff`, `reboot`, `kexec_load`, `kexec_file_load`, `init_module`, `finit_module`, `delete_module`, `unshare`, `setns`, `open_by_handle_at`, `userfaultfd`, `acct`, `settimeofday`, `clock_settime`, `syslog`, `iopl`, `ioperm`, `quotactl`, `name_to_handle_at`, `lookup_dcookie`; | |
| 22 | - - masked paths and read-only paths as docker does (`/proc/kcore`, `/proc/keys`, `/proc/sys` read-only, and so on); | |
| 23 | - - rlimits: `RLIMIT_NPROC` set from `limits.pids`, and `RLIMIT_NOFILE` 65536. | |
| 24 | -- **Limits:** use a cgroup v2 rootless cgroup through libcontainer (systemd or fs driver, whichever works rootless) for memory, cpus and pids from `[limits]`. **If cgroups can't be set up, run anyway**, and log one warning line into the job log ("resource limits unavailable: …"). The job timeout still applies. | |
| 25 | -- **Filesystem:** | |
| 26 | - - The rootfs is an **overlay** mount with the cached image rootfs as `lowerdir`, plus `upperdir`/`workdir` in `<work_dir>/jobs/<job_id>/overlay/`. Rootless overlayfs inside the user namespace needs kernel ≥ 5.11, and this host has 7.2. | |
| 27 | - - If the overlay mount fails, fall back to a full copy of the rootfs into the job dir (try a reflink copy first) and log that. | |
| 28 | - - `/workspace` is a bind mount of the job workspace (rw); `/tmp` is a tmpfs; `/proc`, `/dev` (tmpfs plus the standard null, zero, full, random, urandom and tty device bind mounts from the host), `/dev/pts`, `/dev/shm`, `/sys` (ro). | |
| 29 | - - `/etc/resolv.conf` is bind-mounted from the host (ro) when `network = true`. `/etc/hosts` and `/etc/hostname` are generated. | |
| 30 | - - Everything under `jobs/<job_id>` is deleted after the job. | |
| 31 | -- **Process:** `sh -eu -c <run>`, cwd `/workspace`. | |
| 32 | - - Env: the image config's `Env`, then `HOME=/root`, `CI=true`, `FORGE_CI=true`, then the job env, then the secrets. | |
| 33 | - - PATH comes from the image config, or the default `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`. | |
| 34 | - | |
| 35 | -## Worker E1: images, `crates/forge-runner/src/oci/{mod.rs, pull.rs, unpack.rs, store.rs}` | |
| 36 | -```rust | |
| 37 | -pub struct ImageStore { root: PathBuf } // <work_dir>/oci | |
| 38 | -impl ImageStore { | |
| 39 | - pub fn new(root: PathBuf) -> Self; | |
| 40 | - /// Resolves `image` ("docker.io/rust:1", "alpine", "ghcr.io/o/i@sha256:…"), pulls the manifest for linux/<host arch>, | |
| 41 | - /// downloads missing layer blobs (verified by sha256 digest, written temp+rename), unpacks once per manifest digest, | |
| 42 | - /// and returns the rootfs path + image config. Concurrent calls for the same image must not corrupt (lock file per digest). | |
| 43 | - pub async fn ensure(&self, image: &str, auth: Option<&RegistryAuth>, log: &mpsc::Sender<Vec<u8>>) -> Result<PreparedImage, ImageError>; | |
| 44 | - /// Deletes unpacked rootfs dirs and blobs not used for `keep_days` (by an mtime "last used" stamp file). | |
| 45 | - pub fn gc(&self, keep_days: u32) -> Result<GcStats, ImageError>; | |
| 46 | -} | |
| 47 | -pub struct PreparedImage { pub rootfs: PathBuf, pub digest: String, pub env: Vec<String>, pub working_dir: Option<String>, pub user: Option<String> } | |
| 48 | -pub struct RegistryAuth { pub username: String, pub password: String } // Debug redacts | |
| 49 | -``` | |
| 50 | -- **Image references:** `alpine` normalizes to `docker.io/library/alpine:latest`. Use oci-client's `Reference`. | |
| 51 | -- **Pull policy:** always check the manifest digest (a HEAD or GET manifest), and reuse the unpacked rootfs when the digest matches. When the registry is unreachable and a rootfs for that tag was pulled before, use it and log a warning. To support this, keep a `tags/<sanitized ref>` file holding the last digest. | |
| 52 | -- **Unpacking (security-critical):** | |
| 53 | - - Process the layers in order. Support gzip, zstd and uncompressed layers. | |
| 54 | - - **Never write outside the rootfs.** Reject entries whose normalized path is absolute or contains `..`. | |
| 55 | - - Resolve every parent component **without following symlinks that lead out**: create files with `openat`-style handling, or check each parent with `symlink_metadata`, and never follow a symlink in a parent path. | |
| 56 | - - Hardlinks must point inside the rootfs. | |
| 57 | - - Whiteouts: `.wh.<name>` deletes `<name>`; `.wh..wh..opq` clears the directory's earlier-layer contents. | |
| 58 | - - Skip device and fifo nodes, logging the count. | |
| 59 | - - Strip setuid/setgid. Keep the other permission bits, and set the owner to the current user. | |
| 60 | - - Cap total unpacked size at `[limits] image_max_gb` (default 20). | |
| 61 | -- **Tests:** | |
| 62 | - - craft tar layers in the test (with the `tar` builder) to cover traversal (`../x`, `/abs`), a symlink-parent escape (`a -> /etc`, then `a/passwd`), a hardlink escape, whiteouts and opaque dirs, setuid stripping, and gzip and zstd; | |
| 63 | - - a reference-normalization test; | |
| 64 | - - one `#[ignore]` test that pulls `docker.io/library/alpine:latest` for real. | |
| 65 | - | |
| 66 | -## Worker E2: sandbox, `crates/forge-runner/src/sandbox/{mod.rs, spec.rs, helper.rs}` | |
| 67 | -```rust | |
| 68 | -#[derive(Serialize, Deserialize)] | |
| 69 | -pub struct SandboxRequest { pub job_id: String, pub rootfs: PathBuf, pub job_dir: PathBuf /* overlay + container state */, pub workspace: PathBuf, | |
| 70 | - pub env: Vec<(String, String)>, pub run: String, pub network: bool, pub limits: Limits /* existing config::Limits */ } | |
| 71 | -pub fn build_spec(req: &SandboxRequest, uid: u32, gid: u32, rootfs_mount: &Path) -> Result<oci_spec::runtime::Spec, SandboxError>; // pure, unit-tested | |
| 72 | -pub fn helper_main() -> i32; // entry for `forge-runner __sandbox`: read SandboxRequest JSON from stdin, set up overlay (or copy fallback), run via libcontainer, forward output, return exit code (125 = sandbox setup failure, with the reason on stderr prefixed "forge-sandbox: ") | |
| 73 | -``` | |
| 74 | -- Use libcontainer's rootless support. **Check what libcontainer 0.7 needs**: whether it requires a `bundle` dir with `config.json` (write it under `job_dir`; the env, including secrets, is inside it, so write it 0600 in a 0700 dir and delete it right after start if libcontainer allows, otherwise when the job ends), its state dir (`job_dir/state`), and its stdio handling (inherit the helper's stdout and stderr). | |
| 75 | -- The helper must be **single-threaded with no tokio runtime**. In main.rs, intercept `__sandbox` before building the tokio runtime: switch `#[tokio::main]` to a manual runtime build after argument parsing. | |
| 76 | -- **Tests:** | |
| 77 | - - `build_spec` unit tests: namespaces, the uid map, no capabilities, no_new_privileges, seccomp contents, mounts, net namespace on and off, env order, and secrets never in `process.args`; | |
| 78 | - - a **real sandbox test with no network or registry**: | |
| 79 | - - build a tiny rootfs in a TempDir by copying `/bin/sh` (or busybox if present) and the shared libraries `ldd` lists for it (parse the ldd output); | |
| 80 | - - run `echo hi; id -u; cat /proc/self/status | grep CapEff; touch /workspace/f` through the helper (via `cargo`'s built test binary? It's simpler to invoke `helper_main` logic through a spawned `env!("CARGO_BIN_EXE_forge-runner") __sandbox` from an integration test in `crates/forge-runner/tests/sandbox.rs`); | |
| 81 | - - assert that the output contains hi, uid 0, `CapEff: 0000000000000000`, and that the workspace file exists on the host; | |
| 82 | - - a second test with `network = false` asserts there's no non-loopback interface (`cat /proc/net/dev`); | |
| 83 | - - a third asserts that writing to `/proc/sys/kernel/hostname` fails; | |
| 84 | - - if user namespaces are unavailable on the machine (check `/proc/sys/kernel/unprivileged_userns_clone`, or a failing `unshare -U`), skip with a message. | |
| 85 | - | |
| 86 | -## Worker E3: wiring, after E1 and E2: `BuiltinExecutor` in `crates/forge-runner/src/executor.rs`, plus config, CLI, docs | |
| 87 | -- `BuiltinExecutor { images: ImageStore, limits: Limits, helper: PathBuf /* std::env::current_exe() */ }` implements `Executor`: | |
| 88 | - 1. `ensure` the image, logging "pulling …" lines into the job log; | |
| 89 | - 2. spawn the helper with a piped stdin carrying `SandboxRequest`, and piped stdout and stderr, through the existing `forward_lines`; | |
| 90 | - 3. on cancel or timeout send SIGTERM, wait `STOP_GRACE`, then SIGKILL; | |
| 91 | - 4. map the outcome: exit 0 is Success; 125 with a "forge-sandbox:" stderr line is Error; anything else is Failure with the exit code. | |
| 92 | -- **Runner config:** | |
| 93 | - - `executor = "builtin" | "podman"`, default builtin; | |
| 94 | - - `[limits] image_max_gb = 20`; | |
| 95 | - - `[images] keep_days = 7`, with optional `[[registry]] host, username, password`; the password is only in the 0600 runner.toml; | |
| 96 | - - GC runs at startup and daily. | |
| 97 | -- **`forge-runner check`:** a new subcommand that reports whether user namespaces, overlayfs in a userns, cgroup delegation (which controllers) and seccomp work, so operators can diagnose a host. | |
| 98 | -- **docs/ci.md:** | |
| 99 | - - replace "rootless podman" as the requirement with "Linux ≥ 5.11 with unprivileged user namespaces; cgroup v2 delegation for limits (systemd does this for user sessions)"; | |
| 100 | - - podman becomes optional; | |
| 101 | - - document the `network = true` host-namespace caveat and the single-uid caveat. | |
| 102 | -- **Tests:** | |
| 103 | - - a flow test using `BuiltinExecutor` with a locally prepared rootfs; inject a fake `ImageStore` result, or add an `ImageSource` trait so tests can supply a rootfs dir; | |
| 104 | - - the existing flow tests stay as they are. |
docs/ci-interfaces.md| @@ -1,234 +0,0 @@ | ||
| 1 | -# M6 CI (contract between workers) | |
| 2 | - | |
| 3 | -**Decided with the user (don't re-open):** | |
| 4 | -- Workflow file `.forge/ci.toml`. **No YAML anywhere.** | |
| 5 | -- Jobs run in **rootless podman**. | |
| 6 | -- The runner is a **separate `forge-runner` binary that pulls jobs** over HTTPS. The forge never connects to a runner. | |
| 7 | -- v1 has **secrets, artifacts and live logs**. No cache. | |
| 8 | - | |
| 9 | -**Global rules:** | |
| 10 | -- No JS and no inline styles. | |
| 11 | -- No unwrap in non-test code. | |
| 12 | -- Tests use `tempfile::TempDir`. | |
| 13 | -- Never put cargo target dirs or big files in /tmp. | |
| 14 | -- New crates go in at their latest version with `cargo add`, recorded in the root `[workspace.dependencies]`. | |
| 15 | -- User values come after `--end-of-options` when passed to git. | |
| 16 | - | |
| 17 | -## 0. Crates | |
| 18 | -- **New `crates/forge-ci`** (library; deps: serde, toml, serde_json, globset, thiserror). It is shared by forge-web and forge-runner, and has **no** turso, topcoat or tokio dependency. | |
| 19 | -- **New `crates/forge-runner`** (binary). Depends on forge-ci, reqwest (rustls, stream), tokio, clap, serde, toml, tempfile, sha2 and tracing. **Not** forge-core or forge-web. | |
| 20 | -- Add both to the root `members`. | |
| 21 | - | |
| 22 | -## 1. `forge-ci` (worker W1) | |
| 23 | -```rust | |
| 24 | -// config.rs : parse .forge/ci.toml | |
| 25 | -pub struct CiConfig { pub on: Triggers, pub jobs: BTreeMap<String, JobSpec> } // job names: ^[a-z0-9][a-z0-9_-]{0,39}$, max 20 jobs | |
| 26 | -pub struct Triggers { pub push: Vec<String> /* branch globs, e.g. "main", "release/*" */, pub tags: Vec<String> /* tag globs */, | |
| 27 | - pub pull_request: bool, pub manual: bool /* default true */ } | |
| 28 | -pub struct JobSpec { pub image: String, pub run: String, pub needs: Vec<String>, pub secrets: Vec<String> /* ^[A-Z_][A-Z0-9_]{0,63}$ */, | |
| 29 | - pub env: BTreeMap<String, String>, pub artifacts: Vec<String> /* relative globs, no "..", no leading "/" */, | |
| 30 | - pub runs_on: Vec<String> /* runner labels, all must match; default [] */, pub timeout_minutes: u32 /* default 60, 1..=360 */, | |
| 31 | - pub network: bool /* default true */ } | |
| 32 | -pub fn parse_config(text: &str) -> Result<CiConfig, ConfigError> // deny_unknown_fields; validates names, needs refer to existing jobs, no cycles, image non-empty without whitespace, run non-empty, ≤ 64 KiB file | |
| 33 | -pub enum Event<'a> { Push { branch: &'a str }, Tag { tag: &'a str }, PullRequest, Manual } | |
| 34 | -impl CiConfig { pub fn triggered_by(&self, ev: &Event) -> bool; pub fn job_order(&self) -> Vec<&str> /* topological, stable by name */ } | |
| 35 | - | |
| 36 | -// proto.rs : runner <-> forge JSON (serde, snake_case) | |
| 37 | -pub struct RegisterRequest { pub token: String, pub name: String, pub labels: Vec<String> } | |
| 38 | -pub struct RegisterResponse { pub runner_id: String, pub runner_secret: String } | |
| 39 | -pub struct ClaimRequest { pub labels: Vec<String> } // POST, long-poll ≤ 30s; 204 = nothing | |
| 40 | -pub struct JobAssignment { pub job_id: String, pub job_token: String, pub repo: String /* owner/name */, pub clone_url: String, | |
| 41 | - pub commit: String, pub git_ref: String, pub job_name: String, pub image: String, pub run: String, | |
| 42 | - pub env: BTreeMap<String,String>, pub secrets: BTreeMap<String,String>, pub artifacts: Vec<String>, | |
| 43 | - pub timeout_minutes: u32, pub network: bool } | |
| 44 | -pub struct HeartbeatResponse { pub cancel: bool } | |
| 45 | -pub struct FinishRequest { pub outcome: Outcome, pub exit_code: Option<i32>, pub message: Option<String> } | |
| 46 | -pub enum Outcome { Success, Failure, Cancelled, Error } | |
| 47 | - | |
| 48 | -// mask.rs | |
| 49 | -pub struct Masker // new(secrets: impl IntoIterator<Item=String>); ignores values shorter than 4 chars; mask(&str) -> String replaces every | |
| 50 | - // occurrence (and the base64 standard encoding of each value) with "***"; must handle a value split across two | |
| 51 | - // chunks: `MaskStream` with push(&[u8]) -> Vec<u8> that holds back max_len-1 bytes, and finish() -> Vec<u8> | |
| 52 | -``` | |
| 53 | -Unit tests cover every validation error, glob triggers, topological order, cycle detection, and masking across chunk boundaries. | |
| 54 | - | |
| 55 | -## 2. Schema and store (migration `0024_ci`, worker W2) | |
| 56 | -```sql | |
| 57 | -CREATE TABLE ci_runners (id TEXT PRIMARY KEY, name TEXT NOT NULL, labels TEXT NOT NULL /* json */, secret_hash TEXT NOT NULL UNIQUE, | |
| 58 | - created_at TEXT NOT NULL, last_seen_at TEXT, disabled INTEGER NOT NULL DEFAULT 0); | |
| 59 | -CREATE TABLE ci_registration_tokens (token_hash TEXT PRIMARY KEY, created_by TEXT NOT NULL REFERENCES users(id), | |
| 60 | - created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT); | |
| 61 | -CREATE TABLE ci_runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id), number INTEGER NOT NULL, | |
| 62 | - trigger TEXT NOT NULL /* push|tag|pull_request|manual */, git_ref TEXT NOT NULL, commit_oid TEXT NOT NULL, pull_number INTEGER, | |
| 63 | - actor_id TEXT NOT NULL, state TEXT NOT NULL /* waiting_approval|queued|running|success|failure|cancelled|error */, | |
| 64 | - config_error TEXT, created_at TEXT NOT NULL, finished_at TEXT, UNIQUE (repo_id, number)); | |
| 65 | -CREATE TABLE ci_jobs (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE, name TEXT NOT NULL, | |
| 66 | - spec TEXT NOT NULL /* JobSpec json */, state TEXT NOT NULL /* blocked|queued|running|success|failure|cancelled|error|skipped */, | |
| 67 | - runner_id TEXT, job_token_hash TEXT, exit_code INTEGER, message TEXT, queued_at TEXT, started_at TEXT, finished_at TEXT, | |
| 68 | - heartbeat_at TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, log_bytes INTEGER NOT NULL DEFAULT 0, UNIQUE (run_id, name)); | |
| 69 | -CREATE INDEX ci_jobs_queue ON ci_jobs(state, queued_at); | |
| 70 | -CREATE TABLE ci_artifacts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES ci_jobs(id) ON DELETE CASCADE, name TEXT NOT NULL, | |
| 71 | - size INTEGER NOT NULL, sha256 TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (job_id, name)); | |
| 72 | -CREATE TABLE ci_secrets (repo_id TEXT NOT NULL REFERENCES repos(id), name TEXT NOT NULL, sealed BLOB NOT NULL, | |
| 73 | - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (repo_id, name)); | |
| 74 | -``` | |
| 75 | -`crates/forge-core/src/ci.rs` holds the models. `crates/forge-core/src/store/ci.rs` holds the functions below; they follow the read-pool rules in `~/.cache/forge-orch/read-pool-rules.md`, with pure SELECTs on `read_conn()`. | |
| 76 | -```rust | |
| 77 | -pub enum RunState { WaitingApproval, Queued, Running, Success, Failure, Cancelled, Error } // as_str/FromStr | |
| 78 | -pub enum JobState { Blocked, Queued, Running, Success, Failure, Cancelled, Error, Skipped } // as_str/FromStr, is_terminal() | |
| 79 | -pub struct CiRunner { id, name, labels: Vec<String>, created_at, last_seen_at: Option<String>, disabled: bool } | |
| 80 | -pub struct CiRun { id, repo_id, number: i64, trigger: String, git_ref, commit_oid, pull_number: Option<i64>, actor_id, state: RunState, | |
| 81 | - config_error: Option<String>, created_at, finished_at: Option<String> } | |
| 82 | -pub struct CiJob { id, run_id, name, spec_json: String, state: JobState, runner_id: Option<String>, exit_code: Option<i64>, | |
| 83 | - message: Option<String>, queued_at, started_at, finished_at, heartbeat_at: Option<String>, cancel_requested: bool, log_bytes: i64 } | |
| 84 | -pub struct CiArtifact { id, job_id, name, size: i64, sha256, created_at } | |
| 85 | -pub struct NewJob<'a> { pub name: &'a str, pub spec_json: &'a str, pub needs: &'a [String] } | |
| 86 | - | |
| 87 | -// runners | |
| 88 | -create_registration_token(created_by, ttl_hours: u32) -> Result<String /*plaintext, shown once*/> // stores sha256 | |
| 89 | -register_runner(token_plain, name, labels: &[String]) -> Result<(CiRunner, String /*runner secret plaintext*/)> // token single-use, not expired; Invalid otherwise | |
| 90 | -runner_by_secret(secret_plain) -> Result<Option<CiRunner>> // constant-time compare via hash lookup; also updates last_seen_at | |
| 91 | -list_runners() / set_runner_disabled(id, bool) / delete_runner(id) | |
| 92 | -// runs and jobs | |
| 93 | -create_run(repo_id, trigger, git_ref, commit_oid, pull_number: Option<i64>, actor_id, needs_approval: bool, jobs: &[NewJob], config_error: Option<&str>) -> Result<CiRun> | |
| 94 | - // number = max+1 per repo, in one transaction. Jobs with no needs start Queued (or Blocked when needs_approval), others Blocked. | |
| 95 | - // With config_error: no jobs, state Error. | |
| 96 | -approve_run(run_id) -> Result<()> // WaitingApproval -> Queued; needless jobs -> Queued | |
| 97 | -claim_job(runner: &CiRunner) -> Result<Option<(CiJob, CiRun, String /*job token plaintext*/)>> | |
| 98 | - // oldest Queued job whose spec.runs_on ⊆ runner.labels, in a transaction; sets Running, runner_id, started_at, heartbeat_at, token hash; run -> Running | |
| 99 | -job_by_token(job_id, token_plain) -> Result<Option<CiJob>> // only while Running | |
| 100 | -heartbeat(job_id) -> Result<bool /*cancel_requested*/> | |
| 101 | -add_log_bytes(job_id, n: i64) -> Result<()> | |
| 102 | -finish_job(job_id, state: JobState, exit_code: Option<i64>, message: Option<&str>) -> Result<CiRun> | |
| 103 | - // terminal; then unblock dependants (all needs Success -> Queued; any need not Success -> Skipped, cascading); | |
| 104 | - // run state recomputed: any running/queued/blocked -> Running; else Failure if any failure/error, Cancelled if any cancelled, else Success | |
| 105 | -request_cancel(run_id) -> Result<()> // Queued/Blocked jobs -> Cancelled at once; Running jobs get cancel_requested = 1 | |
| 106 | -rerun(run_id, actor_id) -> Result<CiRun> // new run with the same commit, ref, trigger and job specs | |
| 107 | -reap_lost_jobs(older_than: &str /*stamp*/) -> Result<Vec<CiJob>> // Running jobs with heartbeat_at < older_than -> Error "runner lost" | |
| 108 | -run_by_number(repo_id, n) / run_by_id(id) / list_runs(repo_id, page, per_page) -> (Vec<CiRun>, total) / jobs_for_run(run_id) / job_by_id(id) | |
| 109 | -add_artifact(job_id, name, size, sha256) / list_artifacts(job_id) / artifact(job_id, name) | |
| 110 | -purge_old(logs_before: &str, artifacts_before: &str) -> Result<(Vec<String> /*job ids whose logs to delete*/, Vec<String> /*artifact ids*/)> | |
| 111 | -// secrets (sealed with the instance key like mirror secrets) | |
| 112 | -set_ci_secret(repo_id, name, value: &[u8]) / delete_ci_secret(repo_id, name) -> Result<bool> / list_ci_secret_names(repo_id) -> Result<Vec<(String, String /*updated_at*/)>> | |
| 113 | -ci_secret_values(repo_id, names: &[String]) -> Result<BTreeMap<String, String>> // only for claim; missing names omitted | |
| 114 | -``` | |
| 115 | -- `delete_repo` deletes the ci_runs (cascade) and ci_secrets rows. | |
| 116 | -- User deletion reassigns `actor_id` and `created_by` to the ghost user. | |
| 117 | - | |
| 118 | -## 3. forge-web: scheduling (worker W3), `crates/forge-web/src/ci/{mod.rs, schedule.rs, reaper.rs}` | |
| 119 | -- `pub async fn on_ref_update(state, repo, actor_id, refname, new_oid)` is called from `post_update::after_ref_update` for each updated `refs/heads/*` or `refs/tags/*` (not deletions; not pull mirrors pulling? **yes, pull mirrors also trigger**, with the system actor). | |
| 120 | - - It reads `.forge/ci.toml` at `new_oid` (forge-git blob read, capped at 64 KiB), parses it, and checks `triggered_by`. | |
| 121 | - - It creates the run with jobs in `job_order`, then sets a commit status `ci/<job>` = pending for each job, with `target_url` = the job page. | |
| 122 | - - A parse error creates a run in state Error with `config_error` and a status `ci/config` = error. | |
| 123 | - - No file means nothing happens. | |
| 124 | -- `pub async fn on_pull_event(state, repo, pull, actor_id)` runs on PR open, synchronize and reopen (hook into the same places that emit the `pull_request` webhook). | |
| 125 | - - It reads the config at the PR **head** commit, with event `PullRequest`. | |
| 126 | - - **If the head repo is a fork, or the author doesn't have Write on the base repo:** `needs_approval = true`, the state is WaitingApproval, and the statuses are pending with the description "Waiting for approval". | |
| 127 | -- `pub async fn manual_run(state, repo, branch, actor_id) -> Result<CiRun>` runs only when `manual` is true. | |
| 128 | -- **Statuses:** whenever a job changes state, `crate::ci::sync_status(state, run, job)` sets `ci/<job>`: | |
| 129 | - - queued/blocked/running → pending; | |
| 130 | - - success → success; | |
| 131 | - - failure → failure; | |
| 132 | - - error/cancelled → error; | |
| 133 | - - skipped → error, "skipped". | |
| 134 | -- **Reaper task, started at boot:** | |
| 135 | - - every 30s it runs `reap_lost_jobs(now - 120s)` and syncs the statuses; | |
| 136 | - - daily it runs `purge_old` with `[ci] log_retention_days` (default 90) and `artifact_retention_days` (default 30), and deletes the files. | |
| 137 | -- **`AppState.ci_waker: Arc<Notify>`** is notified whenever a job becomes Queued, so long-polling claims wake. | |
| 138 | -- **Config `[ci]`:** | |
| 139 | - - `enabled = true` | |
| 140 | - - `max_log_mb = 16` | |
| 141 | - - `max_artifact_mb = 512` | |
| 142 | - - `max_artifacts_per_job = 50` | |
| 143 | - - `log_retention_days = 90` | |
| 144 | - - `artifact_retention_days = 30` | |
| 145 | - - `registration_token_hours = 24` | |
| 146 | - | |
| 147 | -## 4. forge-web: runner API (worker W4), `crates/forge-web/src/ci/api.rs` | |
| 148 | -All responses are JSON (`forge_ci::proto`). Runner auth is `Authorization: Bearer <runner_secret>`. Job auth is `Authorization: Bearer <job_token>` on `/jobs/{id}/…`, checked with `job_by_token`. | |
| 149 | -- `POST /api/v1/ci/runners/register` takes RegisterRequest and returns RegisterResponse. | |
| 150 | -- `POST /api/v1/ci/runners/claim` takes ClaimRequest. It loops `claim_job`; if nothing is found, it awaits `ci_waker` or 25s, then tries once more. It returns 200 with a JobAssignment, or 204. | |
| 151 | - - The labels in the request are ignored; the stored labels are used. | |
| 152 | - - Disabled runners get 403. | |
| 153 | - - Secrets come from `ci_secret_values(repo, spec.secrets)`, **except for runs whose trigger is pull_request from a fork** (always empty there). | |
| 154 | - - `clone_url` is the repo's HTTPS clone URL. | |
| 155 | -- `POST /api/v1/ci/jobs/{id}/logs`: the body is a raw text chunk (≤ 1 MiB), with header `X-Forge-Log-Offset: <byte offset>`. | |
| 156 | - - It appends to `<data_dir>/ci/logs/<job id>.log` only if the offset equals the current size (idempotent retry: an equal end returns 200 without writing; a mismatch returns 409 with `X-Forge-Log-Size`). | |
| 157 | - - The server **masks again** with `Masker` built from the job's secret values before writing. | |
| 158 | - - The cap is `max_log_mb`. Past it, the server writes "[log truncated]" once and drops the rest. | |
| 159 | - - It calls `add_log_bytes`. | |
| 160 | -- `POST /api/v1/ci/jobs/{id}/heartbeat` returns HeartbeatResponse (`cancel_requested`). | |
| 161 | -- `PUT /api/v1/ci/jobs/{id}/artifacts/{name}` streams the raw body to the BlobStore (`crate::blobs`, key `ci/<artifact id>`), with a sha256 and the `max_artifact_mb` cap. | |
| 162 | - - The name must be `[A-Za-z0-9._+-]{1,128}` and must not start with ".". | |
| 163 | - - At most `max_artifacts_per_job`. | |
| 164 | -- `POST /api/v1/ci/jobs/{id}/finish` takes FinishRequest and calls `finish_job`. It syncs statuses for this job and for every job that became skipped or queued, and notifies `ci_waker`. | |
| 165 | -- **Git read with a job token:** smart-HTTP `authenticate` in `git_http.rs` also accepts Basic auth with username `ci-job` and password `<job_id>:<job_token>`. That gives read-only access to exactly that job's repo while the job is Running. Every write is refused. | |
| 166 | -- Compression is excluded for `/api/v1/ci/` paths (add the path to `compression.rs`). | |
| 167 | - | |
| 168 | -## 5. forge-web UI: runs (worker W5), `crates/forge-web/src/pages/ci_runs.rs`, `ci_job.rs` | |
| 169 | -- **"CI" repo tab.** | |
| 170 | - - `/{o}/{r}/ci` lists runs, newest first: number, state icon, trigger, ref, short commit, actor, and age. | |
| 171 | - - A "Run workflow" form (Write, `manual` allowed) offers a branch select. | |
| 172 | -- **`/{o}/{r}/ci/runs/{n}`** shows the jobs table with state, duration and runner name, plus `config_error` in a `<pre>`. | |
| 173 | - - Buttons, each a POST with CSRF: Cancel (Write), Re-run (Write), and "Approve and run" (Write, only in WaitingApproval). | |
| 174 | -- **`/{o}/{r}/ci/jobs/{job_id}`** shows the job log: the last 512 KiB of the log file in `<pre>`, with a link to the full log. | |
| 175 | - - **No JS:** while the job is not terminal, the page has `<meta http-equiv="refresh" content="3">`. | |
| 176 | - - Artifacts are listed with download links. | |
| 177 | -- **`/{o}/{r}/ci/jobs/{job_id}/log.txt`** serves `text/plain; charset=utf-8`, `X-Content-Type-Options: nosniff`, and **streams live**. It sends the current file, then keeps following it (poll every 1s) until the job is terminal and the file is fully sent. This is the live log; a browser shows it progressively. | |
| 178 | -- **`/{o}/{r}/ci/jobs/{job_id}/artifacts/{name}`** downloads as an attachment, with nosniff. | |
| 179 | -- Read access follows repo Read. Logs of private repos are never on the user-content domain. | |
| 180 | -- The commit list and PR page already show statuses through the status API, so check that the `ci/*` contexts render there. | |
| 181 | - | |
| 182 | -## 6. forge-web UI: secrets and runners (worker W6) | |
| 183 | -- **`/{o}/{r}/settings/secrets`** (Admin): | |
| 184 | - - lists names and when each was updated, never the values; | |
| 185 | - - has an add/update form (name plus value textarea; the value is write-only); | |
| 186 | - - deletes with CSRF; | |
| 187 | - - records audit events `ci_secret_set` and `ci_secret_deleted` (name only). | |
| 188 | -- **`/admin/runners`** (instance admin): | |
| 189 | - - lists runners with labels, last seen and disabled state; | |
| 190 | - - has disable/enable and delete buttons; | |
| 191 | - - has "Create registration token", which shows the token once along with the exact command `forge-runner register --url <base_url> --token <token> --name <name> --labels linux`; | |
| 192 | - - records audit events `ci_runner_registered`, `ci_runner_deleted` and `ci_registration_token_created`. | |
| 193 | - | |
| 194 | -## 7. `forge-runner` (worker W7) | |
| 195 | -- **CLI:** | |
| 196 | - - `forge-runner register --url URL --token T --name N [--labels a,b] [--config PATH]` writes the config (TOML, 0600, default `~/.config/forge-runner/runner.toml`): url, runner_id, runner_secret, labels, `work_dir`, `concurrency = 1`, and the `[limits] memory = "4g"`, `cpus = "2"` and `pids = 4096` defaults. | |
| 197 | - - `forge-runner run [--config PATH]` loops claim → execute → finish, with `concurrency` jobs in parallel. | |
| 198 | -- **Executor trait:** | |
| 199 | -```rust | |
| 200 | -#[async_trait-free: use Pin<Box<dyn Future>> or an enum] | |
| 201 | -trait Executor { async fn run(&self, job: &JobAssignment, workspace: &Path, log: &mut LogSink, cancel: CancellationToken) -> Outcome+exit code } | |
| 202 | -``` | |
| 203 | - - **`PodmanExecutor`** runs `podman run --rm --name forge-job-<job_id> --pull=newer --network=<slirp4netns|none> --memory M --cpus C --pids-limit P --security-opt no-new-privileges --cap-drop ALL -v <ws>:/workspace:Z -w /workspace -e NAME... <image> sh -eu -c <run>`. | |
| 204 | - - Env and secret values are passed through the child's environment with `-e NAME` (name only), **never in argv**. | |
| 205 | - - On cancel or timeout it runs `podman kill forge-job-<id>`, then `podman rm -f`. | |
| 206 | - - The podman argv construction is unit-tested. | |
| 207 | - - **`HostExecutor`** exists only for tests, behind `#[cfg(any(test, feature = "test-host-executor"))]`. It runs `sh -eu -c` in the workspace with the env, and has no isolation. | |
| 208 | -- **Workspace:** a fresh `work_dir/<job_id>`. Run `git init`, then `git fetch --depth=1 <clone_url> <commit>` with `GIT_CONFIG_*` env carrying `http.extraHeader: Authorization: Basic base64(ci-job:<job_id>:<token>)`, never in argv, with `GIT_TERMINAL_PROMPT=0`. Then `git checkout --detach <commit>`. The workspace is deleted afterwards. | |
| 209 | -- **Logs:** | |
| 210 | - - stdout and stderr are merged line-wise and prefixed with nothing. | |
| 211 | - - They pass through `MaskStream` and are batched (≤ 64 KiB or 1s), then POSTed with the offset. On 409 the runner resyncs to `X-Forge-Log-Size`. | |
| 212 | - - The first lines are runner info: runner name, image, and commit. | |
| 213 | -- **Heartbeat** every 10s; `cancel: true` triggers the cancellation token. The timeout is `timeout_minutes`. | |
| 214 | -- **Artifacts:** after a successful run, expand the globs inside the workspace (the canonicalized path must stay inside it, symlinks must not escape, and only regular files count). Upload each file under its relative path flattened with `/` → `_`, respecting the limits. | |
| 215 | -- **Finish:** report `Success` on exit 0, `Failure` on a non-zero exit, `Cancelled`, and `Error` for runner or podman failures, with a message. | |
| 216 | -- **Tests:** a mock forge (a small hyper or tokio TCP server in tests) plus `HostExecutor` cover: | |
| 217 | - - claim → logs → artifacts → finish; | |
| 218 | - - masking; | |
| 219 | - - cancel; | |
| 220 | - - timeout; | |
| 221 | - - 409 resync. | |
| 222 | - - Real podman only in an `#[ignore]` test. | |
| 223 | - | |
| 224 | -## 8. End to end and docs (worker W8, after W1–W7) | |
| 225 | -- **`crates/forge-web/tests/ci_e2e.rs`:** | |
| 226 | - 1. Boot the forge and register a runner through the API. | |
| 227 | - 2. Push a repo with `.forge/ci.toml` (two jobs, with needs, a secret and an artifact). | |
| 228 | - 3. Drive the job with forge-runner's library API and `HostExecutor` (forge-runner exposes `lib.rs`). | |
| 229 | - 4. Assert the commit statuses, the masked secret in the log, the artifact download, the skip on failure, and that a fork PR waits for approval. | |
| 230 | -- **`docs/ci.md`:** | |
| 231 | - - the format reference with examples; | |
| 232 | - - runner install (rootless podman setup: subuid/subgid, `loginctl enable-linger`, a systemd user unit); | |
| 233 | - - the security model (fork PRs, secrets, masking limits); | |
| 234 | - - the runner protocol. |
docs/ci-sandbox-v2.md| @@ -1,122 +0,0 @@ | ||
| 1 | -# CI sandbox v2: subordinate id mapping (contract between workers) | |
| 2 | - | |
| 3 | -**Goal:** jobs get a real range of uids and gids, so package managers that `chown` to other users work (`apt`, `apk add`, `useradd`, …). Container uid 0 maps to the runner's uid, and container ids `1..=N` map to the runner's `/etc/subuid` and `/etc/subgid` range. This uses the setuid helpers `newuidmap` and `newgidmap` from shadow-utils, if present. Without them, the runner falls back to the v1 single-uid mapping. | |
| 4 | - | |
| 5 | -The global rules in `~/.cache/forge-orch/waveb-common.md` apply. | |
| 6 | - | |
| 7 | -The existing sandbox is described in `docs/ci-builtin-executor.md` and `~/.cache/forge-orch/libcontainer-notes.md`. Read both. | |
| 8 | - | |
| 9 | -## Host facts | |
| 10 | -- `/usr/bin/newuidmap` and `/usr/bin/newgidmap` exist. | |
| 11 | -- The subuid range is `tahinli:524288:65536`. | |
| 12 | -- `unshare --map-auto --map-root-user chown 1000:1000 f` works and gives host owner `525287`. That is `start + 999`, because container 0 is the runner and container 1 is `start`. | |
| 13 | -- Tests can use this command to create files with a subordinate owner. Skip the test when it fails. | |
| 14 | - | |
| 15 | -## Mapping | |
| 16 | -- **subuid mode:** container `0` maps to the runner uid (count 1), and container `1` maps to `start` (count `n`), where `n = min(count, 65536)`. The same applies to gids. Container size is `1 + n`. | |
| 17 | -- **single mode:** today's behaviour, `0 → uid`, count 1. | |
| 18 | - | |
| 19 | -## Worker V-A: `crates/forge-runner/src/sandbox/idmap.rs`, plus the hidden-command dispatch | |
| 20 | -```rust | |
| 21 | -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] | |
| 22 | -#[serde(rename_all = "lowercase")] | |
| 23 | -pub enum UsernsMode { #[default] Auto, Single, Subuid } | |
| 24 | - | |
| 25 | -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] | |
| 26 | -pub struct IdMap { pub uid: u32, pub gid: u32, pub sub_uid: Option<(u32, u32)>, pub sub_gid: Option<(u32, u32)> } // (start, n) | |
| 27 | -impl IdMap { | |
| 28 | - pub fn single() -> Self; // current uid/gid, no ranges | |
| 29 | - pub fn resolve(mode: UsernsMode) -> Result<Self, IdMapError>; // see below | |
| 30 | - pub fn is_subuid(&self) -> bool; | |
| 31 | - pub fn size(&self) -> u32; // 1 + n, or 1 | |
| 32 | - pub fn describe(&self) -> String; // "subuid 524288+65536" | "single uid 1000" | |
| 33 | -} | |
| 34 | -impl Default for IdMap // = single() | |
| 35 | -pub fn parse_subid(content: &str, user: &str, uid: u32) -> Vec<(u32, u32)>; // match name OR numeric uid, skip comments/bad lines | |
| 36 | -/// SINGLE-THREADED processes only. unshare(NEWUSER|NEWNS), install the maps, make "/" rec-private. | |
| 37 | -pub fn enter_mapped_userns(map: &IdMap) -> Result<(), IdMapError>; | |
| 38 | -/// `<exe> __sandbox-rm <path>`, IdMap JSON on stdin: enter the userns, give dirs u+rwx, remove_dir_all. Exit 0, or 125 + "forge-sandbox: " line. | |
| 39 | -pub fn rm_helper_main(args: &[OsString]) -> i32; | |
| 40 | -/// Blocking. Single mode: remove_dir_all with a chmod-u+rwx retry, no spawn. Subuid mode: run `<exe> __sandbox-rm <path>` with the map on stdin. | |
| 41 | -/// Missing path = Ok. | |
| 42 | -pub fn remove_tree(map: &IdMap, path: &Path, exe: &Path) -> io::Result<()>; | |
| 43 | -``` | |
| 44 | - | |
| 45 | -**`resolve`** | |
| 46 | -- **Auto:** use subuid when all of these hold: | |
| 47 | - - `newuidmap` and `newgidmap` exist in `/usr/bin`, `/bin`, `/usr/sbin` or `/sbin`; | |
| 48 | - - `/etc/subuid` and `/etc/subgid` each have an entry for the user (by name, from `getpwuid`, or by numeric uid); | |
| 49 | - - that entry has `count >= 65536`. Take the first such range. A smaller range is accepted with a warning only if it is ≥ 1000. | |
| 50 | - | |
| 51 | - Otherwise use single. | |
| 52 | -- **Subuid:** return an error instead of falling back. | |
| 53 | -- **Single:** `single()`. | |
| 54 | - | |
| 55 | -**`enter_mapped_userns`, subuid mode** | |
| 56 | -1. Before unsharing, create a pipe and fork a child. The child blocks reading the pipe. | |
| 57 | -2. The parent runs `unshare(CLONE_NEWUSER | CLONE_NEWNS)`, then writes one byte. | |
| 58 | -3. The child runs these commands: | |
| 59 | - - `newuidmap <parent pid> 0 <uid> 1 1 <start> <n>` | |
| 60 | - - `newgidmap <parent pid> 0 <gid> 1 1 <start> <n>` | |
| 61 | - | |
| 62 | - It runs them via `std::process::Command` (the child is single-threaded, so this is allowed), then `_exit`s with 0 or 1. | |
| 63 | -4. The parent waits with `waitpid` and checks the status. On failure it returns an error including newuidmap's stderr. | |
| 64 | -5. **Do NOT write `setgroups deny`**, because newgidmap needs `allow`. After mapping, call `setgroups(&[])`. Then make the mounts rec-private as today. | |
| 65 | - | |
| 66 | -**Single mode** keeps today's code: `setgroups deny` and direct map writes. Move it here from `helper.rs`; the helper then calls `enter_mapped_userns`. | |
| 67 | - | |
| 68 | -**Dispatch:** | |
| 69 | -- `cli::is_sandbox_invocation()` becomes true for any argv[1] starting with `__sandbox`. | |
| 70 | -- Add `pub fn hidden_main() -> i32` in `cli.rs`, which routes: | |
| 71 | - - `__sandbox` → `sandbox::helper_main()` | |
| 72 | - - `__sandbox-rm` → `idmap::rm_helper_main` | |
| 73 | - - `__sandbox-unpack` → `crate::oci::unpack_helper_main()`. This belongs to V-C; if it doesn't exist yet, add a stub returning 125 in `oci/mod.rs` that V-C replaces. | |
| 74 | -- Use `hidden_main` from `cli::main_with_args`, `forge-runner`'s `main.rs` and forge-web's `main.rs`. | |
| 75 | - | |
| 76 | -**Tests** | |
| 77 | -- `parse_subid` cases. | |
| 78 | -- `size` and `describe`. | |
| 79 | -- `resolve(Auto)` on this host yields subuid. | |
| 80 | -- An integration test in `tests/sandbox.rs` (or a new `tests/idmap.rs`) that runs `forge-runner __sandbox-rm` on a TempDir holding a file chowned to 1000 by `unshare --map-auto --map-root-user`. The dir must be gone afterwards. Skip when `unshare --map-auto` fails. | |
| 81 | - | |
| 82 | -## Worker V-C: images with real ownership, in `crates/forge-runner/src/oci/` | |
| 83 | -- **`unpack_layers` gains a `ownership: Ownership` parameter:** | |
| 84 | - - `pub enum Ownership { Flatten, Preserve { size: u32 } }`. | |
| 85 | - - `Flatten` is today's behaviour. | |
| 86 | - - `Preserve`: | |
| 87 | - - `lchown` every entry, including symlinks, to the tar header uid and gid. | |
| 88 | - - Ids `>= size` become 0 and are counted in `UnpackStats.remapped_ids`. | |
| 89 | - - setuid and setgid are still stripped. | |
| 90 | - - Directories get their exact mode at the end, deepest first, instead of `mode | 0o700`. | |
| 91 | - - Update all callers and tests. | |
| 92 | -- **`ImageStore::with_idmap(map: IdMap, helper_exe: PathBuf)`:** | |
| 93 | - - When the map is subuid, the rootfs dir is `rootfs/<digest>-s<uid start>-<n>`, so a changed range re-unpacks. Single keeps `rootfs/<digest>`. | |
| 94 | - - Unpacking in subuid mode runs `<helper_exe> __sandbox-unpack` from `spawn_blocking`. The JSON `UnpackRequest { map, rootfs, layers, max_bytes }` goes on stdin and `UnpackStats` JSON comes back on stdout. | |
| 95 | - - `pub fn unpack_helper_main() -> i32` (in `oci/mod.rs` or a new `oci/helper.rs`): | |
| 96 | - - reads the request; | |
| 97 | - - calls `idmap::enter_mapped_userns`; | |
| 98 | - - calls `unpack_layers(.., Ownership::Preserve { size: map.size() })`; | |
| 99 | - - prints the stats and exits 0, or 125 with a "forge-sandbox:" line. | |
| 100 | -- **GC and staging cleanup** remove trees with `idmap::remove_tree(&map, path, &helper_exe)`, because subuid-owned files can't be deleted by the runner directly. | |
| 101 | -- **Tests:** | |
| 102 | - - `Preserve` unit tests that only check the remapping logic (a pure function, `fn map_id(id, size) -> (u32, bool)`). | |
| 103 | - - Path naming per mode. | |
| 104 | - - An integration test that unpacks a crafted layer with a file owned by 1000:1000 through `forge-runner __sandbox-unpack`, then checks that the host owner is `start + 999`. Skip when subuid is unavailable. | |
| 105 | - | |
| 106 | -## Worker V-B, after V-A: sandbox, executor, config, check, docs | |
| 107 | -- **`SandboxRequest`** gains `#[serde(default)] pub idmap: IdMap`. The helper calls `idmap::enter_mapped_userns(&req.idmap)`. The container spec's uid and gid mappings become a single line each, `0 0 <size>`, which is relative to the helper's namespace. | |
| 108 | -- **Workspace ownership:** after the job ends, the helper (still root in the userns) walks `req.workspace` without following symlinks and `lchown`s every entry whose uid or gid is not 0 to 0:0. The host runner then owns everything for artifacts and cleanup. | |
| 109 | -- **Executor:** `reap_job` and any job-dir cleanup use `idmap::remove_tree`. `BuiltinExecutor` gets the map. | |
| 110 | -- **Config:** `[sandbox] userns = "auto" | "single" | "subuid"` (default auto). It is resolved once at startup, logs `describe()`, and is passed to `ImageStore::with_idmap` and `BuiltinExecutor`. A failure to resolve in subuid mode is a startup error. | |
| 111 | -- **`forge-runner check`:** prints the mode. In subuid mode it also checks that a probe job can `chown 1000:1000` a file in `/tmp`. | |
| 112 | -- **docs/ci.md:** | |
| 113 | - - replace the single-uid caveat with the v2 behaviour; | |
| 114 | - - document the requirements: shadow-utils `newuidmap`/`newgidmap` and an `/etc/subuid` and `/etc/subgid` entry (`usermod --add-subuids 100000-165535 --add-subgids 100000-165535 <runner user>`); | |
| 115 | - - document the fallback; | |
| 116 | - - explain that cached image files on the host are owned by subordinate ids. | |
| 117 | -- **Tests (`tests/sandbox.rs`, skip when subuid is unavailable):** | |
| 118 | - - a job that does `touch /tmp/f && chown 1000:1000 /tmp/f && stat -c '%u %g' /tmp/f` prints `1000 1000`; | |
| 119 | - - `cat /proc/self/uid_map` has size `1 + n`; | |
| 120 | - - a job that creates `/workspace/a` and chowns it to 1000 leaves `a` owned by the runner uid on the host; | |
| 121 | - - the job dir is gone afterwards; | |
| 122 | - - all existing single-mode tests still pass, with the map forced to single. |
docs/ci.md| @@ -274,6 +274,7 @@ labels = ["linux", "x86_64"] | ||
| 274 | 274 | work_dir = "/home/ci/.cache/forge-runner/work" |
| 275 | 275 | concurrency = 1 |
| 276 | 276 | executor = "builtin" # or "podman" |
| 277 | +auto_update = true # replace the runner binary with the forge's when idle | |
| 277 | 278 | |
| 278 | 279 | [limits] |
| 279 | 280 | memory = "4g" # the job cgroup's memory.max, or podman --memory |
| @@ -290,6 +291,12 @@ username = "bot" | ||
| 290 | 291 | password = "..." # keep private, the file is mode 0600 |
| 291 | 292 | ``` |
| 292 | 293 | |
| 294 | +`auto_update` (default `true`) lets the runner keep its binary in step with the forge. Claim | |
| 295 | +responses tell the runner which binary the forge serves. When it differs from the runner's, and no | |
| 296 | +jobs are running, the runner stops claiming, replaces its binary, and exits with status 75 so systemd | |
| 297 | +restarts it. It is skipped when the forge's binary is for a different OS or architecture. Set | |
| 298 | +`auto_update = false` to pin the binary by hand. | |
| 299 | + | |
| 293 | 300 | `concurrency` is the number of jobs run in parallel. The default is 1. Each job runs with no |
| 294 | 301 | capabilities and `no_new_privileges`. The builtin executor also runs each job under a seccomp |
| 295 | 302 | profile that denies calls such as `mount`, `ptrace`, `bpf` and `unshare`. |
docs/m1-interfaces.md| @@ -1,197 +0,0 @@ | ||
| 1 | -# Milestone 1: shared interfaces (contract between workers) | |
| 2 | - | |
| 3 | -Every worker codes against these names and signatures. If a signature must change, keep the change minimal and say so in your report. | |
| 4 | -The 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 | -``` | |
| 15 | -crates/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) | |
| 29 | -crates/forge-core/migrations/0001_init.sql | |
| 30 | - | |
| 31 | -crates/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 | - | |
| 40 | -crates/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 | - | |
| 46 | -crates/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 | |
| 62 | -pub type Id = String; | |
| 63 | -pub enum Visibility { Public, Private } // stored as "public" / "private" | |
| 64 | -pub enum Role { Read, Triage, Write, Maintain, Admin } // stored lowercase | |
| 65 | -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 } | |
| 66 | -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 } | |
| 67 | -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> } | |
| 68 | -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> } | |
| 69 | -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> } | |
| 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 | |
| 76 | -pub 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 } | |
| 79 | -pub enum Registration { Closed, Invite, Open } | |
| 80 | -impl Config { pub fn load(path: &Path) -> Result<Config> } // writes dev defaults if the file is missing | |
| 81 | - | |
| 82 | -// secret.rs (XChaCha20-Poly1305) | |
| 83 | -pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> // 0600, 32 random bytes | |
| 84 | -pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> String // "v1:" + base64(nonce || ciphertext) | |
| 85 | -pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>> | |
| 86 | - | |
| 87 | -// names.rs | |
| 88 | -pub fn normalize_name(input: &str) -> String // NFKC + lowercase | |
| 89 | -pub fn validate_name(input: &str) -> Result<String> // returns the folded name or Error::InvalidName | |
| 90 | - | |
| 91 | -// password.rs (argon2id) | |
| 92 | -pub fn hash_password(pw: &str) -> Result<String> | |
| 93 | -pub fn verify_password(pw: &str, hash: &str) -> bool | |
| 94 | - | |
| 95 | -// token.rs | |
| 96 | -pub fn generate_token() -> String // "forge_" + base64url(32 random bytes) | |
| 97 | -pub fn hash_token(token: &str) -> String // hex sha256 | |
| 98 | -pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool | |
| 99 | - | |
| 100 | -// session.rs | |
| 101 | -pub fn new_session_token() -> String // base64url(32 random bytes) | |
| 102 | -pub fn new_csrf_token() -> String | |
| 103 | - | |
| 104 | -// authz.rs (pure, no IO) | |
| 105 | -pub enum Action { Read, Write, Admin } | |
| 106 | -pub enum Denied { NotFound, Forbidden } // NotFound when the actor can't even see the repo | |
| 107 | -pub 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 | |
| 115 | -impl 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 | |
| 156 | -pub struct GitEnv { pub git_bin: PathBuf, pub data_dir: PathBuf } // gitconfig at data_dir/gitconfig, hooks at data_dir/hooks | |
| 157 | -pub struct GitCommand { .. } | |
| 158 | -impl 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 | -} | |
| 167 | -pub fn repo_path(data_dir: &Path, repo_id: &str) -> PathBuf; // data_dir/repos/<id[0..2]>/<id[2..4]>/<id>.git (lowercased ULID) | |
| 168 | -pub async fn init_bare(env: &GitEnv, path: &Path, default_branch: &str) -> Result<()>; | |
| 169 | -pub async fn list_branches(env: &GitEnv, path: &Path) -> Result<Vec<String>>; | |
| 170 | -pub async fn git_version(env: &GitEnv) -> Result<(u32, u32, u32)>; | |
| 171 | -pub async fn require_min_version(env: &GitEnv, min: (u32, u32, u32)) -> Result<()>; // min (2,38,0) | |
| 172 | -pub fn write_managed_gitconfig(data_dir: &Path) -> Result<()>; | |
| 173 | -pub fn write_hook_shims(data_dir: &Path, forge_bin: &Path) -> Result<()>; // pre-receive, post-receive; 0755 | |
| 174 | -pub enum GitService { UploadPack, ReceivePack } // "git-upload-pack" / "git-receive-pack" | |
| 175 | -impl GitService { pub fn from_name(s: &str) -> Option<Self>; pub fn name(&self) -> &'static str; } | |
| 176 | -pub fn service_command(env: &GitEnv, svc: GitService, repo: &Path, stateless_rpc: bool, advertise_refs: bool, git_protocol: Option<&str>) -> GitCommand; | |
| 177 | -pub fn pkt_line(data: &str) -> Vec<u8>; pub const FLUSH_PKT: &[u8] = b"0000"; | |
| 178 | -``` | |
| 179 | - | |
| 180 | -## forge-ssh | |
| 181 | -```rust | |
| 182 | -pub struct SshConfig { pub listen: SocketAddr, pub host_key_path: PathBuf, pub auth_timeout: Duration, pub inactivity_timeout: Duration } | |
| 183 | -pub 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 | -} | |
| 190 | -pub fn parse_exec(cmd: &str) -> Result<(forge_git::GitService, String), ParseError>; // "git-upload-pack '/owner/repo.git'" | |
| 191 | -pub 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. |
docs/m2-interfaces.md| @@ -1,101 +0,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. |
docs/m3-interfaces.md| @@ -1,189 +0,0 @@ | ||
| 1 | -# Milestone 3: issues, labels and milestones (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from `docs/m1-interfaces.md` apply: latest dependencies, no unwrap in non-test code, clippy `-D warnings`, fmt, no inline styles, no JS, and every page goes through `load_repo` and `authorize`. Times are shown with `tz::format_in_tz(.., ctx.viewer_tz())` plus `views::relative_time`. Markdown goes through `render_markdown` and is embedded only via `Unescaped::new_unchecked`. | |
| 4 | - | |
| 5 | -## Permissions (forge-core `authz.rs`) | |
| 6 | -Add `Action::Triage`. It's granted to the owner, instance admins, and collaborators with role Triage, Write, Maintain or Admin. Archived repos are read-only for issues too: every issue write action returns Forbidden when `repo.archived` (handled in the pages). | |
| 7 | - | |
| 8 | -| What | Who | | |
| 9 | -|---|---| | |
| 10 | -| View issues, labels, milestones | `Action::Read` (anonymous OK on public repos) | | |
| 11 | -| Open an issue, comment | signed in **and** `Action::Read` | | |
| 12 | -| Edit/delete own comment, edit own issue title/body, close/reopen own issue | the author (signed in, still has Read) | | |
| 13 | -| Edit anyone's issue/comment, close/reopen any, set labels, assignees, milestone | `Action::Triage` | | |
| 14 | -| Create/edit/delete labels and milestones | `Action::Triage` | | |
| 15 | - | |
| 16 | -## Migration `0004_issues.sql` | |
| 17 | -```sql | |
| 18 | -ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1; | |
| 19 | - | |
| 20 | -CREATE TABLE milestones ( | |
| 21 | - id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 22 | - title TEXT NOT NULL, title_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', | |
| 23 | - due_on TEXT, -- 'YYYY-MM-DD' or NULL | |
| 24 | - state TEXT NOT NULL DEFAULT 'open',-- 'open' | 'closed' | |
| 25 | - created_at TEXT NOT NULL, closed_at TEXT, | |
| 26 | - UNIQUE (repo_id, title_folded)); | |
| 27 | - | |
| 28 | -CREATE TABLE issues ( | |
| 29 | - id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 30 | - number INTEGER NOT NULL, | |
| 31 | - kind TEXT NOT NULL DEFAULT 'issue', -- 'issue' now; 'pull' in M4 (shared numbering) | |
| 32 | - title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', | |
| 33 | - state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed' | |
| 34 | - state_reason TEXT, -- NULL | 'completed' | 'not_planned' | |
| 35 | - author_id TEXT NOT NULL REFERENCES users(id), | |
| 36 | - milestone_id TEXT REFERENCES milestones(id) ON DELETE SET NULL, | |
| 37 | - comment_count INTEGER NOT NULL DEFAULT 0, | |
| 38 | - created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, | |
| 39 | - UNIQUE (repo_id, number)); | |
| 40 | -CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number); | |
| 41 | - | |
| 42 | -CREATE TABLE comments ( | |
| 43 | - id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 44 | - author_id TEXT NOT NULL REFERENCES users(id), | |
| 45 | - body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 46 | - edited INTEGER NOT NULL DEFAULT 0); | |
| 47 | -CREATE INDEX comments_issue ON comments(issue_id, created_at); | |
| 48 | - | |
| 49 | --- Edit history: the previous version is saved on every edit. | |
| 50 | -CREATE TABLE edit_history ( | |
| 51 | - id TEXT PRIMARY KEY, target_kind TEXT NOT NULL, -- 'issue' | 'comment' | |
| 52 | - target_id TEXT NOT NULL, old_title TEXT, old_body TEXT NOT NULL, | |
| 53 | - editor_id TEXT NOT NULL REFERENCES users(id), edited_at TEXT NOT NULL); | |
| 54 | -CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at); | |
| 55 | - | |
| 56 | --- Timeline events (everything except comments). | |
| 57 | -CREATE TABLE issue_events ( | |
| 58 | - id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 59 | - actor_id TEXT REFERENCES users(id), -- NULL = system | |
| 60 | - kind TEXT NOT NULL, -- closed reopened labeled unlabeled assigned unassigned milestoned demilestoned renamed referenced | |
| 61 | - data TEXT NOT NULL DEFAULT '{}', -- JSON, e.g. {"label":"bug"} {"from":"a","to":"b"} {"commit":"<oid>"} {"reason":"completed"} | |
| 62 | - created_at TEXT NOT NULL); | |
| 63 | -CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at); | |
| 64 | - | |
| 65 | -CREATE TABLE labels ( | |
| 66 | - id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 67 | - name TEXT NOT NULL, name_folded TEXT NOT NULL, | |
| 68 | - color TEXT NOT NULL, -- '#rrggbb' lowercase | |
| 69 | - description TEXT NOT NULL DEFAULT '', | |
| 70 | - UNIQUE (repo_id, name_folded)); | |
| 71 | - | |
| 72 | -CREATE TABLE issue_labels (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 73 | - label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id)); | |
| 74 | -CREATE TABLE issue_assignees (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 75 | - user_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY (issue_id, user_id)); | |
| 76 | -``` | |
| 77 | -`Store::delete_repo` must delete every row above for the repo in its single transaction, explicitly, child tables first. Don't rely on the engine's FK cascade. `create_repo` seeds no labels. A default label set can come later. | |
| 78 | - | |
| 79 | -**Number allocation:** in one transaction, read `next_issue_number`, insert the issue with it, then increment the counter. Numbers are never reused. | |
| 80 | - | |
| 81 | -## forge-core model (`src/issues.rs`, re-exported) | |
| 82 | -```rust | |
| 83 | -pub enum IssueState { Open, Closed } // as_str / parse: "open" | "closed" | |
| 84 | -pub enum StateReason { Completed, NotPlanned } // "completed" | "not_planned" | |
| 85 | -pub struct Issue { pub id: Id, pub repo_id: Id, pub number: i64, pub title: String, pub body: String, | |
| 86 | - pub state: IssueState, pub state_reason: Option<StateReason>, pub author_id: Id, | |
| 87 | - pub milestone_id: Option<Id>, pub comment_count: i64, | |
| 88 | - pub created_at: String, pub updated_at: String, pub closed_at: Option<String> } | |
| 89 | -pub struct Comment { pub id: Id, pub issue_id: Id, pub author_id: Id, pub body: String, | |
| 90 | - pub created_at: String, pub updated_at: String, pub edited: bool } | |
| 91 | -pub struct IssueEvent { pub id: Id, pub issue_id: Id, pub actor_id: Option<Id>, pub kind: String, | |
| 92 | - pub data: serde_json::Value, pub created_at: String } | |
| 93 | -pub struct Label { pub id: Id, pub repo_id: Id, pub name: String, pub color: String, pub description: String } | |
| 94 | -pub struct Milestone { pub id: Id, pub repo_id: Id, pub title: String, pub description: String, | |
| 95 | - pub due_on: Option<String>, pub state: IssueState, pub created_at: String, pub closed_at: Option<String>, | |
| 96 | - pub open_issues: i64, pub closed_issues: i64 } // counts filled by list/get queries | |
| 97 | -pub struct IssueFilter { pub state: Option<IssueState>, pub label: Option<String> /*name*/, | |
| 98 | - pub milestone: Option<String> /*title*/, pub author: Option<String> /*username*/, | |
| 99 | - pub assignee: Option<String> /*username*/, pub query: Option<String> /*substring of title, case-insensitive*/, | |
| 100 | - pub sort: IssueSort, pub page: u32 /*1-based*/, pub per_page: u32 } | |
| 101 | -pub enum IssueSort { Newest, Oldest, RecentlyUpdated } | |
| 102 | -pub struct IssueListItem { pub issue: Issue, pub author: String /*display username*/, | |
| 103 | - pub labels: Vec<Label>, pub assignees: Vec<String> } | |
| 104 | -/// Validation | |
| 105 | -pub const MAX_TITLE: usize = 256; pub const MAX_BODY: usize = 65536; | |
| 106 | -pub fn validate_title(t: &str) -> Result<String /*trimmed*/>; // non-empty after trim, at most MAX_TITLE chars, no control chars | |
| 107 | -pub fn validate_body(b: &str) -> Result<()>; // at most MAX_BODY bytes | |
| 108 | -pub fn validate_label(name: &str, color: &str) -> Result<(String, String)>; // name 1..=50 chars trimmed, color #rgb/#rrggbb, normalized to lowercase #rrggbb | |
| 109 | -``` | |
| 110 | - | |
| 111 | -## Store (forge-core). New files `store/issues.rs` and `store/labels.rs`, each an `impl Store` block | |
| 112 | -`store/issues.rs`: | |
| 113 | -```rust | |
| 114 | -pub async fn create_issue(&self, repo_id: &str, author_id: &str, title: &str, body: &str) -> Result<Issue>; | |
| 115 | -pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>>; | |
| 116 | -pub async fn list_issues(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, u64 /*total matching*/)>; | |
| 117 | -pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64 /*open*/, u64 /*closed*/)>; | |
| 118 | -pub async fn edit_issue(&self, issue_id: &str, editor_id: &str, title: &str, body: &str) -> Result<()>; // saves history; a title change also records a 'renamed' event | |
| 119 | -pub async fn set_issue_state(&self, issue_id: &str, actor_id: Option<&str>, state: IssueState, reason: Option<StateReason>, data: serde_json::Value) -> Result<bool /*changed*/>; // records a closed/reopened event with data+reason; no-op if already in that state | |
| 120 | -pub async fn add_comment(&self, issue_id: &str, author_id: &str, body: &str) -> Result<Comment>; // bumps comment_count and updated_at | |
| 121 | -pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>>; | |
| 122 | -pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()>; // saves history, edited = 1 | |
| 123 | -pub async fn delete_comment(&self, comment_id: &str) -> Result<()>; // decrements comment_count | |
| 124 | -pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>>; | |
| 125 | -pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>>; | |
| 126 | -pub async fn add_event(&self, issue_id: &str, actor_id: Option<&str>, kind: &str, data: serde_json::Value) -> Result<()>; | |
| 127 | -pub async fn usernames_by_ids(&self, ids: &[String]) -> Result<std::collections::HashMap<String, (String /*display*/, Option<String> /*avatar_hash*/)>>; | |
| 128 | -``` | |
| 129 | -`store/labels.rs`: | |
| 130 | -```rust | |
| 131 | -pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>>; // by name | |
| 132 | -pub async fn create_label(&self, repo_id: &str, name: &str, color: &str, description: &str) -> Result<Label>; // a duplicate (folded) is Error::Conflict, or the existing unique-violation mapping | |
| 133 | -pub async fn update_label(&self, repo_id: &str, label_id: &str, name: &str, color: &str, description: &str) -> Result<()>; | |
| 134 | -pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()>; | |
| 135 | -pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>>; | |
| 136 | -pub async fn set_issue_labels(&self, issue_id: &str, actor_id: &str, label_ids: &[String]) -> Result<()>; // diff, then labeled/unlabeled events; ids must belong to the issue's repo | |
| 137 | -pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(Id, String /*display*/)>>; | |
| 138 | -pub async fn set_issue_assignees(&self, issue_id: &str, actor_id: &str, usernames: &[String]) -> Result<()>; // max 10; each must be a user who can Read the repo (else Error::Invalid); assigned/unassigned events | |
| 139 | -pub async fn list_milestones(&self, repo_id: &str, state: Option<IssueState>) -> Result<Vec<Milestone>>; | |
| 140 | -pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>>; | |
| 141 | -pub async fn create_milestone(&self, repo_id: &str, title: &str, description: &str, due_on: Option<&str>) -> Result<Milestone>; | |
| 142 | -pub async fn update_milestone(&self, repo_id: &str, id: &str, title: &str, description: &str, due_on: Option<&str>, state: IssueState) -> Result<()>; | |
| 143 | -pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()>; // issues keep existing, milestone_id becomes NULL | |
| 144 | -pub async fn set_issue_milestone(&self, issue_id: &str, actor_id: &str, milestone_id: Option<&str>) -> Result<()>; // milestoned/demilestoned events | |
| 145 | -``` | |
| 146 | -Every list query must be a bounded, indexed SQL query. No N+1 queries: list_issues fetches labels and assignees for the whole page in one or two extra queries. | |
| 147 | - | |
| 148 | -## Reference parsing (forge-core `src/issue_refs.rs`) | |
| 149 | -```rust | |
| 150 | -/// "#12" occurrences not preceded by an alphanumeric char, `&` or `/` and not followed by an alphanumeric char; 1..=9 digits. | |
| 151 | -pub fn find_issue_refs(text: &str) -> Vec<i64>; // deduped, in order | |
| 152 | -/// Closing keywords (case-insensitive): close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved, | |
| 153 | -/// followed by optional ':' and whitespace, then "#N". Only same-repo refs. | |
| 154 | -pub fn find_closing_refs(text: &str) -> Vec<i64>; | |
| 155 | -``` | |
| 156 | - | |
| 157 | -## Markdown linking (forge-web `render/markdown.rs`) | |
| 158 | -`LinkContext` gains `pub issues_base: Option<String>` (e.g. `/Tahinli/forge/issues`). When it's set, `#N` in text nodes (not code or links) becomes `<a href="{issues_base}/N" class="issue-ref">#N</a>`, and `@username` (same rules as `validate_username`'s charset, preceded by start or whitespace) becomes `<a href="/username" class="user-mention">@username</a>`. Both are produced as AST link nodes before sanitizing, never by string-pasting HTML. Existing callers pass `issues_base: None`, except the README/blob renderers, which should pass the repo's issues base. | |
| 159 | - | |
| 160 | -## Web routes (forge-web pages) | |
| 161 | -- `GET /{o}/{r}/issues` (`issues_list.rs`): query `?state=open|closed|all` (default open), `label`, `milestone`, `author`, `assignee`, `q`, `sort=newest|oldest|updated`, `page`. | |
| 162 | - - 25 per page, with Open N / Closed N toggles. | |
| 163 | - - Rows: state icon, title link, labels as colored chips (class `label-chip`, color via an inline CSS custom property is NOT allowed by the CSP, so use a generated per-repo stylesheet `GET /{o}/{r}/labels.css` with `.label-<id> { background:#..; color:<contrast> }`; the contrast color is black or white by luminance), `#N opened <time> by <user>`, comment count, assignee avatars, milestone. | |
| 164 | - - Filters are plain links and a GET form. | |
| 165 | - - A "New issue" button shows for signed-in readers. | |
| 166 | -- `GET/POST /{o}/{r}/issues/new` (`issues_list.rs`): title and body. Redirect to the new issue. | |
| 167 | -- `GET /{o}/{r}/issues/{n}` (`issue_view.rs`): | |
| 168 | - - Header: title, `#N`, state badge (Open green, Closed purple/completed or grey/not planned), "<author> opened this <time> · N comments". | |
| 169 | - - **Timeline:** body as the first entry, then comments and events merged and sorted by created_at. Each comment has an avatar, author, time, "edited" marker, and an Edit/Delete form if allowed. Events are one-liners ("Tahinli added the bug label 3 hours ago", "closed this as completed in 9270d63" linking the commit). | |
| 170 | - - **Comment form**, with buttons "Comment" and "Close with comment" / "Reopen with comment" when allowed. A **Close** dropdown isn't possible without JS, so use two submit buttons: "Close as completed" and "Close as not planned". | |
| 171 | - - **Sidebar** (Triage holders get forms; others read-only lists): | |
| 172 | - - Labels: checkbox list of every repo label plus a Save button → `POST /{o}/{r}/issues/{n}/labels`, field `label` repeated. | |
| 173 | - - Assignees: text input with comma-separated usernames → `POST /{o}/{r}/issues/{n}/assignees`, field `assignees`. | |
| 174 | - - Milestone: `<select>` → `POST /{o}/{r}/issues/{n}/milestone`, field `milestone_id` (empty = none). | |
| 175 | -- `POST /{o}/{r}/issues/{n}/comment` (fields `body`, `action` = `comment` | `close_completed` | `close_not_planned` | `reopen`). The comment is added if the body is non-empty, then the state change is applied if permitted. | |
| 176 | -- `GET/POST /{o}/{r}/issues/{n}/edit`: title and body (author or Triage). | |
| 177 | -- `GET/POST /{o}/{r}/issues/{n}/comments/{id}/edit`, and `POST .../comments/{id}/delete`: author or Triage. | |
| 178 | -- The labels, assignees and milestone POST handlers live in `issue_meta.rs`. | |
| 179 | -- `GET /{o}/{r}/labels` and `POST .../labels` (create), `POST .../labels/{id}` (update), `POST .../labels/{id}/delete`, `GET /{o}/{r}/labels.css` (`labels_page.rs`). | |
| 180 | -- `GET /{o}/{r}/milestones?state=` and `POST .../milestones` (create), `GET/POST .../milestones/{id}/edit` (title, description, due_on, state), `POST .../milestones/{id}/delete`. List rows show a progress bar (`<progress>` element, no inline style) and open/closed counts linking to the filtered issue list (`milestones_page.rs`). | |
| 181 | -- Every POST has CSRF. Every page and handler 404s when the repo is hidden or the issue is missing, and returns 403 when visible but not permitted. | |
| 182 | -- Repo header tabs gain **Issues** with the open count, between Code and Commits. That's in `views/mod.rs` or `pages/repo.rs` (wherever the tabs are); the list-page worker owns this change. | |
| 183 | - | |
| 184 | -## Closing via commits (forge-web `internal.rs` post-receive, in background) | |
| 185 | -After a push that updates the **default branch** (old→new, new is not zero): | |
| 186 | -- List the new commits with `git rev-list --max-count=500 <new> ^<old>` (only `^<old>` if old isn't zero; for a new branch, use `--max-count=50`), and read their messages via `history::log`-style CLI calls with full OIDs only. | |
| 187 | -- For each `find_closing_refs` hit, call `set_issue_state(Closed, Completed, actor = pusher, data {"commit": oid})` if the issue exists, is open and is kind 'issue'. | |
| 188 | -- For every other `find_issue_refs` hit, add a `referenced` event with `{"commit": oid}` (deduped per issue and commit). | |
| 189 | -- Errors are logged and never fail the push. Runs in the same `tokio::spawn` background path as maintenance (but not coalesced away: every push's ranges must be processed). |
docs/m4-interfaces.md| @@ -1,120 +0,0 @@ | ||
| 1 | -# Milestone 4a: forks and pull requests (contract between workers) | |
| 2 | - | |
| 3 | -Reviews with line comments come in M4b, not here. The global rules from `docs/m1-interfaces.md` and `docs/m3-interfaces.md` apply: | |
| 4 | -- The git CLI only gets **full hex OIDs** and validated ref names, placed after `--end-of-options` where git supports it. | |
| 5 | -- No shell, no unwrap in non-test code. Clippy `-D warnings` and fmt must pass. | |
| 6 | -- No inline styles and no JS. | |
| 7 | -- Every page goes through `load_repo` and `authorize`, and times use `viewer_tz`. | |
| 8 | -- **Never put cargo target dirs or big files in /tmp**, and tests use `tempfile::TempDir`. | |
| 9 | - | |
| 10 | -## Concepts | |
| 11 | -- A PR is an `issues` row with `kind = 'pull'`, so it shares the per-repo numbering with issues. That gives it comments, labels, assignees, milestones, events and edit history for free. Issue pages already filter `kind = 'issue'`; PR pages filter `kind = 'pull'`. `#N` links go to `/issues/N`, and the issue page redirects (303) to `/pulls/N` when N is a PR. | |
| 12 | -- The **head** is `(head_repo_id, head_branch)`, either the same repo or a fork of the base. The **base** is `(base repo = issue.repo_id, base_branch)`. | |
| 13 | -- The base repo keeps `refs/pull/<N>/head` pointing at the head commit. It's updated whenever the head branch moves, so the base repo always has the objects. For a fork, that means a local `git fetch` from the fork's path. | |
| 14 | - | |
| 15 | -## Forks | |
| 16 | -- `repos` gains `fork_of TEXT NULL REFERENCES repos(id)` (migration 0007). | |
| 17 | -- Creating a fork: `POST /{o}/{r}/fork` (signed in, Read on the source, CSRF). The new repo is named like the source (a `-1`, `-2` suffix on conflict), owned by the viewer. Its visibility matches the source; a private source forks to private. | |
| 18 | -- **On disk:** `git clone --bare --local --no-checkout <src_path> <new_path>` via GitCommand. `--local` hardlinks objects, which is cheap and safe because objects are immutable. Copy only branches and tags (`refs/heads/*`, `refs/tags/*`), not `refs/pull/*`. Then apply the managed config the same way `init_bare` does (hooks path and so on). Use alternates? **No**: deleting or trashing the parent would break the fork. | |
| 19 | -- The fork page header shows "forked from owner/name", linked to the source if the viewer can see it. | |
| 20 | -- Deleting a parent repo leaves forks working, with `fork_of` set to NULL in the delete transaction. | |
| 21 | - | |
| 22 | -## Schema (migration `0007_pulls.sql`) | |
| 23 | -```sql | |
| 24 | -ALTER TABLE repos ADD COLUMN fork_of TEXT REFERENCES repos(id); | |
| 25 | -CREATE TABLE pull_requests ( | |
| 26 | - issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE, | |
| 27 | - base_branch TEXT NOT NULL, | |
| 28 | - head_repo_id TEXT REFERENCES repos(id), -- NULL if the head repo was deleted | |
| 29 | - head_branch TEXT NOT NULL, | |
| 30 | - head_oid TEXT NOT NULL, -- last known head commit (also refs/pull/N/head) | |
| 31 | - base_oid TEXT NOT NULL, -- base branch tip when mergeability was last computed | |
| 32 | - merge_base TEXT, | |
| 33 | - mergeable TEXT NOT NULL DEFAULT 'unknown', -- 'unknown' | 'clean' | 'conflict' | 'merged' | 'up_to_date' | |
| 34 | - conflict_files TEXT NOT NULL DEFAULT '[]', -- JSON array of paths | |
| 35 | - merged_at TEXT, merged_by TEXT REFERENCES users(id), merge_commit TEXT, | |
| 36 | - allow_maintainer_edits INTEGER NOT NULL DEFAULT 1); | |
| 37 | -CREATE INDEX pull_requests_head ON pull_requests(head_repo_id, head_branch); | |
| 38 | -``` | |
| 39 | -`Store::delete_repo` (base) must delete pull_requests rows of the repo's issues. Deleting a **head** repo sets `head_repo_id = NULL`; such a PR can't be merged any more, but its stored refs and diff still show. | |
| 40 | - | |
| 41 | -## forge-core (`src/pulls.rs` model plus `store/pulls.rs`, an `impl Store` block) | |
| 42 | -```rust | |
| 43 | -pub enum Mergeable { Unknown, Clean, Conflict, Merged, UpToDate } // as_str/parse | |
| 44 | -pub struct PullRequest { pub issue: Issue, pub base_branch: String, pub head_repo_id: Option<Id>, | |
| 45 | - pub head_branch: String, pub head_oid: String, pub base_oid: String, pub merge_base: Option<String>, | |
| 46 | - pub mergeable: Mergeable, pub conflict_files: Vec<String>, pub merged_at: Option<String>, | |
| 47 | - pub merged_by: Option<Id>, pub merge_commit: Option<String> } | |
| 48 | -pub enum MergeStyle { Merge, Squash, FastForward } // "merge" | "squash" | "ff" | |
| 49 | - | |
| 50 | -// store/pulls.rs | |
| 51 | -pub async fn create_pull(&self, repo_id: &str, author_id: &str, title: &str, body: &str, | |
| 52 | - base_branch: &str, head_repo_id: &str, head_branch: &str, head_oid: &str, base_oid: &str) -> Result<PullRequest>; // allocates a number like create_issue (shared counter), kind='pull' | |
| 53 | -pub async fn pull_by_number(&self, repo_id: &str, number: i64) -> Result<Option<PullRequest>>; | |
| 54 | -pub async fn list_pulls(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, u64)>; // same filters as issues; state 'open'|'closed' (merged PRs are closed) | |
| 55 | -pub async fn pull_counts(&self, repo_id: &str) -> Result<(u64, u64)>; | |
| 56 | -pub async fn open_pulls_for_head(&self, head_repo_id: &str, head_branch: &str) -> Result<Vec<(Id /*base repo*/, i64 /*number*/, Id /*issue id*/)>>; | |
| 57 | -pub async fn open_pulls_for_base(&self, repo_id: &str, base_branch: &str) -> Result<Vec<(i64, Id)>>; | |
| 58 | -pub async fn update_pull_head(&self, issue_id: &str, head_oid: &str) -> Result<()>; // resets mergeable to 'unknown' | |
| 59 | -pub async fn set_mergeability(&self, issue_id: &str, base_oid: &str, merge_base: Option<&str>, m: Mergeable, conflicts: &[String]) -> Result<()>; | |
| 60 | -pub async fn mark_merged(&self, issue_id: &str, actor_id: &str, merge_commit: &str) -> Result<()>; // closes the issue (state_reason completed) + 'merged' event {"commit"} | |
| 61 | -pub async fn find_open_pull(&self, base_repo: &str, base_branch: &str, head_repo: &str, head_branch: &str) -> Result<Option<i64>>; // prevents duplicates | |
| 62 | -// forks | |
| 63 | -pub async fn create_fork(&self, source: &Repo, owner_id: &str, name: &str) -> Result<Repo>; // sets fork_of | |
| 64 | -pub async fn list_forks(&self, repo_id: &str) -> Result<Vec<Repo>>; | |
| 65 | -``` | |
| 66 | -`add_event` must accept the new kinds: `merged`, `head_updated` (data `{"from","to"}`), `base_changed`, and `head_deleted`. | |
| 67 | - | |
| 68 | -## forge-git (`src/pulls.rs`) | |
| 69 | -```rust | |
| 70 | -pub async fn fetch_into_pull_ref(env: &GitEnv, base_repo: &Path, head_repo: &Path, head_oid: &str, number: i64) -> Result<()>; | |
| 71 | - // same repo: `update-ref refs/pull/N/head <oid>`; fork: `git fetch --no-tags <head_repo_path> <oid>` then update-ref (uploadpack.allowAnySHA1InWant isn't needed for a local path fetch of a full oid; if it is, fetch the branch ref and verify the oid) | |
| 72 | -pub async fn merge_base(env, repo, a: &str, b: &str) -> Result<Option<String>>; | |
| 73 | -pub enum MergeCheck { Clean { tree: String }, Conflict { files: Vec<String> }, UpToDate } | |
| 74 | -pub async fn check_merge(env, repo, base_oid: &str, head_oid: &str) -> Result<MergeCheck>; | |
| 75 | - // `git merge-tree --write-tree --name-only --no-messages <base> <head>`; exit 1 = conflict (parse the file list); UpToDate if head is an ancestor of base | |
| 76 | -pub async fn commit_tree(env, repo, tree: &str, parents: &[&str], message: &str, author: &Signature, committer: &Signature) -> Result<String>; | |
| 77 | - // `git commit-tree`, with GIT_AUTHOR_*/GIT_COMMITTER_* env set from the signatures; the message goes in via stdin (never argv) | |
| 78 | -pub async fn update_ref_cas(env, repo, refname: &str, new: &str, old: &str) -> Result<()>; // `update-ref <ref> <new> <old>`, an atomic compare-and-swap | |
| 79 | -pub async fn range_diff_files(env, repo, base_oid: &str, head_oid: &str, limits: DiffLimits) -> Result<CommitDiff>; // three-dot: merge-base..head, same parsing as commit_diff | |
| 80 | -pub async fn range_log(env, repo, base_oid: &str, head_oid: &str, max: usize) -> Result<Vec<CommitInfo>>; // `log <head> ^<base>`, oldest first | |
| 81 | -pub struct Signature { pub name: String, pub email: String, pub time_unix: i64 } | |
| 82 | -``` | |
| 83 | -**Merging (forge-web, `pulls_merge.rs`):** load the PR; require Write on the base repo, not archived, the PR open, and the head present. Recompute `check_merge` against the CURRENT base tip. | |
| 84 | -- **Merge:** `commit_tree(tree, [base, head], "Merge pull request #N from owner/branch\n\n<title>")`. | |
| 85 | -- **Squash:** `commit_tree(tree, [base], "<title> (#N)\n\n<body or commit list>")`, with the PR author as author. | |
| 86 | -- **FastForward:** only if base is an ancestor of head; new = head. | |
| 87 | -- Then `update_ref_cas(refs/heads/<base>, new, base)`. On a CAS failure, show "base moved, try again". | |
| 88 | -- **Branch protection:** a merge is never a force push. It runs as Write, so allowed. | |
| 89 | -- After the merge, call `mark_merged`, then run the same post-push side effects the hook would: closing keywords from the merged commits, the maintenance trigger, and recomputing mergeability of other PRs on that base. Factor the post-receive logic in `internal.rs` into a reusable `fn after_ref_update(state, repo, actor, refname, old, new)`, then call it from both places. | |
| 90 | - | |
| 91 | -## Post-receive integration (forge-web `internal.rs`) | |
| 92 | -For every updated `refs/heads/<b>` of repo R (both paths, via `after_ref_update`): | |
| 93 | -- For each open PR whose head is (R, b): `fetch_into_pull_ref`, `update_pull_head`, a `head_updated` event, then recompute mergeability in the background. If the branch was deleted: a `head_deleted` event, and keep the last head. | |
| 94 | -- For each open PR whose base is (R, b): recompute mergeability in the background. | |
| 95 | -- If a push to the base branch makes a PR's head an ancestor of base (someone merged manually on the CLI), call `mark_merged` with the merge commit unknown. Use the base tip, and record the `merged` event. | |
| 96 | - | |
| 97 | -## Web routes | |
| 98 | -- `GET /{o}/{r}/pulls`: list, same filters as issues; state open/closed, where closed also shows merged with a purple badge. `pulls_list.rs`. | |
| 99 | -- `GET /{o}/{r}/compare/{base}...{head}`, where head is `branch` or `owner:branch` for forks; also `GET /{o}/{r}/compare` with a base/head picker form (plain GET form). `compare.rs`. | |
| 100 | - - Shows the commits, the diff stat and the file diffs (reuse the commit page's diff rendering: extract it into a shared component in `views/diff.rs`). | |
| 101 | - - Shows an "Open pull request" form (title prefilled from the first commit summary, body) if the viewer can Read the base and there are commits. | |
| 102 | - - `POST /{o}/{r}/pulls` creates the PR (CSRF; a duplicate open PR for the same head and base redirects to the existing one). | |
| 103 | -- `GET /{o}/{r}/pulls/{n}` (Conversation tab), `/pulls/{n}/commits`, `/pulls/{n}/files`. `pull_view.rs`. | |
| 104 | - - Header: title, state badge (Open / Merged / Closed), "author wants to merge N commits into base from head". | |
| 105 | - - The Conversation tab reuses the issue timeline, sidebar and comment form. Factor the shared parts of `issue_view.rs` into reusable components; the issue page must keep working and its tests must keep passing. | |
| 106 | - - **Merge box:** mergeability status ("This branch has conflicts that must be resolved", listing the files; "Ready to merge"; "Checking…" when unknown), a style radio (merge, squash, ff when possible) and a Merge button for Write holders. `POST /{o}/{r}/pulls/{n}/merge`. | |
| 107 | - - Close and reopen work like issues. | |
| 108 | -- Repo tabs gain **Pull requests (N)** after Issues. | |
| 109 | -- Repo pages for a forkable repo show a **Fork** button in the header (`POST /fork`). The fork header shows "forked from". | |
| 110 | -- A banner on the repo home after a recent push to a non-default branch ("feature/x had recent pushes — Compare & pull request"): optional, skip if complex. | |
| 111 | - | |
| 112 | -## Tests (each worker covers its part; the e2e test file `tests/pulls_e2e.rs` uses real git) | |
| 113 | -- Same-repo PR: push `feature`, open the PR via compare, see its commits and files, merge-commit merge, then main contains the feature commit, the PR is merged, and a "Fixes #1" in the PR commits closes issue 1. | |
| 114 | -- Fork PR: bob forks, pushes to his fork, opens a PR to alice's repo. Alice merges with squash, giving a single new commit on main with bob as author. | |
| 115 | -- A conflict is detected and lists the file; the Merge button is disabled and the POST is rejected. | |
| 116 | -- Pushing to the head branch updates the PR (a new commit shows, a `head_updated` event). | |
| 117 | -- A base that moved concurrently gives the CAS failure message (simulate by updating the base between check and update; a unit test in forge-git is fine). | |
| 118 | -- FF-only when possible; otherwise the FF option is hidden or rejected. | |
| 119 | -- Permissions: a Read user can open a PR from their fork but can't merge (403). Private base: anonymous gets 404. | |
| 120 | -- `#N` on the issue page redirects to the PR. |
docs/m4b-interfaces.md| @@ -1,119 +0,0 @@ | ||
| 1 | -# Milestone 4b: pull request reviews and line comments (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from `docs/m1-interfaces.md`, `docs/m3-interfaces.md` and `docs/m4-interfaces.md` apply: | |
| 4 | -- No JS and no inline styles. | |
| 5 | -- Full hex OIDs only, validated paths, and user values after `--end-of-options`. | |
| 6 | -- No unwrap in non-test code. | |
| 7 | -- Tests use `tempfile::TempDir`. | |
| 8 | -- Never put cargo target dirs or big files in /tmp. | |
| 9 | - | |
| 10 | -## Concepts | |
| 11 | -- A **review** belongs to a PR (`issue_id`) and has an author. Its state is one of: | |
| 12 | - - `pending`: a draft, visible only to its author, with at most one per author per PR. | |
| 13 | - - `commented`, `approved` or `changes_requested`: submitted. | |
| 14 | - - `dismissed`: a Write holder dismissed an approval or a change request, giving a reason. | |
| 15 | -- A **review comment** is a comment on one line of the PR diff, identified by: | |
| 16 | - - `path`: the new path; for a deleted file, the old path; | |
| 17 | - - `side`: `new` for added and context lines, `old` for deleted lines; | |
| 18 | - - `line`: the line number on that side; | |
| 19 | - - `commit_oid`: the head OID when it was written. | |
| 20 | - It belongs to a review. A comment whose `reply_to` is set is a reply in the root comment's thread; replies are submitted immediately, through an auto-created `commented` review without a body. A root carries `resolved`. | |
| 21 | -- **Outdated:** after the head moves, a root comment is outdated when the blob at `path` differs between its `commit_oid` and the new head (this is coarse on purpose; line remapping comes later). Outdated threads still show in the conversation, with an "Outdated" badge, and no longer show on the Files tab. | |
| 22 | -- `diff_hunk` stores the last up to 4 diff lines ending at the commented line, as plain text with a `+`, `-` or space prefix, captured at creation. The conversation shows it as the thread's context without running git again. | |
| 23 | -- PR authors can't approve or request changes on their own PR; they can only comment. Anyone who can Read the base repo and is signed in can review. A review by a Read user counts in the summary, but merge rules don't enforce anything yet. | |
| 24 | - | |
| 25 | -## Schema (migration `0008_reviews.sql`) | |
| 26 | -```sql | |
| 27 | -CREATE TABLE pull_reviews ( | |
| 28 | - id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 29 | - author_id TEXT NOT NULL REFERENCES users(id), | |
| 30 | - state TEXT NOT NULL, -- pending | commented | approved | changes_requested | dismissed | |
| 31 | - body TEXT NOT NULL DEFAULT '', | |
| 32 | - commit_oid TEXT NOT NULL, -- head at submit (or at creation while pending) | |
| 33 | - dismissed_reason TEXT, | |
| 34 | - created_at TEXT NOT NULL, submitted_at TEXT); | |
| 35 | -CREATE INDEX pull_reviews_issue ON pull_reviews(issue_id, submitted_at); | |
| 36 | -CREATE TABLE review_comments ( | |
| 37 | - id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 38 | - review_id TEXT NOT NULL REFERENCES pull_reviews(id) ON DELETE CASCADE, | |
| 39 | - author_id TEXT NOT NULL REFERENCES users(id), | |
| 40 | - path TEXT NOT NULL, side TEXT NOT NULL, line INTEGER NOT NULL, | |
| 41 | - commit_oid TEXT NOT NULL, diff_hunk TEXT NOT NULL DEFAULT '', | |
| 42 | - reply_to TEXT REFERENCES review_comments(id) ON DELETE CASCADE, | |
| 43 | - body TEXT NOT NULL, outdated INTEGER NOT NULL DEFAULT 0, resolved INTEGER NOT NULL DEFAULT 0, | |
| 44 | - resolved_by TEXT REFERENCES users(id), | |
| 45 | - created_at TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 46 | -CREATE INDEX review_comments_issue ON review_comments(issue_id, path, created_at); | |
| 47 | -``` | |
| 48 | -Issue events gain the kinds `review_dismissed` (data `{"review","reason"}`), `thread_resolved` and `thread_unresolved`. | |
| 49 | - | |
| 50 | -## forge-core (`src/reviews.rs` model plus `store/reviews.rs`, an `impl Store` block) | |
| 51 | -```rust | |
| 52 | -pub enum ReviewState { Pending, Commented, Approved, ChangesRequested, Dismissed } // as_str / FromStr | |
| 53 | -pub enum Side { Old, New } // "old" | "new" | |
| 54 | -pub struct Review { pub id: Id, pub issue_id: Id, pub author_id: Id, pub author_name: String, pub state: ReviewState, | |
| 55 | - pub body: String, pub commit_oid: String, pub dismissed_reason: Option<String>, pub created_at: String, pub submitted_at: Option<String> } | |
| 56 | -pub struct ReviewComment { pub id: Id, pub review_id: Id, pub author_id: Id, pub author_name: String, pub path: String, pub side: Side, | |
| 57 | - pub line: u32, pub commit_oid: String, pub diff_hunk: String, pub reply_to: Option<Id>, pub body: String, | |
| 58 | - pub outdated: bool, pub resolved: bool, pub created_at: String, pub updated_at: String, pub pending: bool /* review is pending */ } | |
| 59 | -pub struct Thread { pub root: ReviewComment, pub replies: Vec<ReviewComment> } | |
| 60 | - | |
| 61 | -// store/reviews.rs | |
| 62 | -pub async fn pending_review(&self, issue_id: &str, author_id: &str) -> Result<Option<Review>>; | |
| 63 | -pub async fn add_review_comment(&self, issue_id: &str, author_id: &str, pending: bool, path: &str, side: Side, line: u32, | |
| 64 | - commit_oid: &str, diff_hunk: &str, body: &str) -> Result<ReviewComment>; | |
| 65 | - // pending=true: put it in the author's pending review, creating that review if needed; | |
| 66 | - // pending=false: create a submitted `commented` review holding just this comment | |
| 67 | -pub async fn reply_review_comment(&self, issue_id: &str, author_id: &str, root_id: &str, body: &str) -> Result<ReviewComment>; | |
| 68 | - // root must be a submitted root comment of this PR; copies path/side/line/commit_oid | |
| 69 | -pub async fn submit_review(&self, issue_id: &str, author_id: &str, state: ReviewState /* not Pending/Dismissed */, | |
| 70 | - body: &str, head_oid: &str) -> Result<Review>; | |
| 71 | - // turns the pending review into `state` (or creates one with no comments); Commented with an empty body | |
| 72 | - // and no comments is Invalid; the PR author's Approved or ChangesRequested is Forbidden | |
| 73 | -pub async fn discard_pending_review(&self, issue_id: &str, author_id: &str) -> Result<()>; | |
| 74 | -pub async fn dismiss_review(&self, review_id: &str, actor_id: &str, reason: &str) -> Result<()>; // approved or changes_requested only; adds the event | |
| 75 | -pub async fn list_reviews(&self, issue_id: &str) -> Result<Vec<Review>>; // submitted ones, oldest first | |
| 76 | -pub async fn review_threads(&self, issue_id: &str, viewer_id: Option<&str>) -> Result<Vec<Thread>>; | |
| 77 | - // submitted comments plus the viewer's own pending ones; replies oldest first | |
| 78 | -pub async fn review_comment(&self, id: &str) -> Result<Option<ReviewComment>>; | |
| 79 | -pub async fn edit_review_comment(&self, id: &str, body: &str) -> Result<()>; // records edit_history target_kind 'review_comment' | |
| 80 | -pub async fn delete_review_comment(&self, id: &str) -> Result<()>; // deleting a root deletes its replies | |
| 81 | -pub async fn set_thread_resolved(&self, root_id: &str, actor_id: &str, resolved: bool) -> Result<()>; // adds an event | |
| 82 | -pub async fn mark_outdated(&self, issue_id: &str, ids: &[Id]) -> Result<()>; | |
| 83 | -pub async fn review_summary(&self, issue_id: &str) -> Result<ReviewSummary>; | |
| 84 | - // per reviewer, their latest non-commented, non-dismissed submitted state | |
| 85 | -pub struct ReviewSummary { pub approved_by: Vec<String>, pub changes_requested_by: Vec<String> } | |
| 86 | -``` | |
| 87 | -Bodies use the same limits as issue comments. `author_name` comes from a join on `users`. | |
| 88 | - | |
| 89 | -## forge-git (added to `src/pulls.rs`) | |
| 90 | -```rust | |
| 91 | -pub async fn blob_at(env: &GitEnv, repo: &Path, commit: &str, path: &str) -> Result<Option<String>>; // `rev-parse <commit>:<path>` gives the blob oid; None if absent. Validate path: no NUL, not absolute, no `..` segments | |
| 92 | -``` | |
| 93 | - | |
| 94 | -## Outdated marking (forge-web `pulls_sync.rs`, extending the head_updated path) | |
| 95 | -After `update_pull_head`, for each submitted root comment that isn't outdated, compare `blob_at(old commit_oid, path)` with `blob_at(new_head, path)`. Mark it outdated if they differ (cache by `(commit_oid, path)`). | |
| 96 | - | |
| 97 | -## Web | |
| 98 | -- **Files tab** `/pulls/{n}/files`: | |
| 99 | - - **Line anchors.** Each diff line gets an id `L-<file_index>-<side>-<line>` and a small "+" link to `?comment=<file_index>:<side>:<line>#L-…`. Use `file_index` (the file's position in the diff), not the path, to keep URLs short. The server maps it back to the path and re-validates it against the current diff. | |
| 100 | - - **Inline comment form.** When `comment=` matches a line, render a form right under that line. It POSTs `/pulls/{n}/review_comments` with csrf, `file_index`, `side`, `line`, `head_oid` (rejected if stale: "the pull request changed, reload"), body, and two buttons: `action=single` ("Add single comment") or `action=review` ("Start a review" / "Add review comment" if one is pending). | |
| 101 | - - **Threads.** Existing, non-outdated threads on the current diff render under their line: comments (markdown-rendered like issue comments), a reply form, a Resolve/Unresolve button (PR author or Write), and edit/delete for the author. | |
| 102 | - - **Review bar** at the top for signed-in viewers: "Finish your review" with a pending comment count, a body textarea, radio comment/approve/request_changes (approve and request changes hidden for the PR author), Submit and Discard. Posts to `/pulls/{n}/reviews` or `/pulls/{n}/reviews/discard`. | |
| 103 | - - The `diff_section` component gains an optional slot. Implement it as an optional `&HashMap<(usize, Side, u32), Vec<...>>` of rendered thread data plus an optional open-form target; keep `commit.rs` and `compare.rs` calls working, passing none. | |
| 104 | -- **Conversation timeline:** submitted reviews appear in created order: | |
| 105 | - - Avatar/name with the verb ("approved these changes" / "requested changes" / "reviewed"), the body, then its threads, each showing the `diff_hunk` as a small code block, the path:line, an "Outdated" badge, comments and replies, and a "Resolved" collapsed marker using `<details>`. | |
| 106 | - - Dismissed reviews show struck-through with the reason. | |
| 107 | -- **Merge box:** a summary line: "Approved by a, b", "Changes requested by c". Write holders see a "Dismiss" form for each approve or request-changes review, with a reason. | |
| 108 | -- **Routes** (all POST with CSRF, all redirecting 303 back to the referring tab with an anchor): | |
| 109 | - - `/pulls/{n}/review_comments` | |
| 110 | - - `/pulls/{n}/review_comments/{id}/reply` | |
| 111 | - - `/pulls/{n}/review_comments/{id}/edit` (GET form + POST) | |
| 112 | - - `/pulls/{n}/review_comments/{id}/delete` | |
| 113 | - - `/pulls/{n}/review_comments/{id}/resolve` (with `resolved=1|0`) | |
| 114 | - - `/pulls/{n}/reviews`, `/pulls/{n}/reviews/discard`, `/pulls/{n}/reviews/{id}/dismiss` | |
| 115 | -- **Permissions:** | |
| 116 | - - Read and signed in to comment or review. | |
| 117 | - - Author of the comment to edit or delete; Triage+ may also delete. | |
| 118 | - - PR author or Write to resolve; Write to dismiss. | |
| 119 | - - Archived repo: no writes. Merged or closed PR: comments and replies still allowed, new reviews not. |
docs/mirrors-interfaces.md| @@ -1,110 +0,0 @@ | ||
| 1 | -# Push and pull mirrors (contract between workers) | |
| 2 | - | |
| 3 | -The global rules apply: | |
| 4 | -- No JS and no inline styles. | |
| 5 | -- No unwrap in non-test code. | |
| 6 | -- Tests use `tempfile::TempDir`. | |
| 7 | -- Never put cargo target dirs or big files in /tmp. | |
| 8 | -- New crates are added at their latest version with `cargo add`. | |
| 9 | - | |
| 10 | -## Model | |
| 11 | -- **Pull mirror:** a repo whose refs come from a remote URL. The forge fetches on an interval and on "Sync now". | |
| 12 | - - Pushes to it are rejected in pre-receive with "this repository is a pull mirror". | |
| 13 | - - Issues and PRs still work. | |
| 14 | - - At most one pull mirror per repo. It is created through the new-repo form ("Mirror an existing repository") or added in settings while the repo is empty (no refs at all). Settings refuses the add on a repo with refs, and the form is not offered when a pull mirror already exists. | |
| 15 | -- **Push mirror:** a remote that receives our refs after every push (debounced 10s per mirror) and on its interval. A repo can have up to 10. | |
| 16 | -- **Wikis are not mirrored.** Pull and push mirrors only sync the code repo. A repo's wiki (`<repo>.wiki.git`) stays on this forge and is not sent to push-mirror remotes or fetched from pull-mirror remotes. Wiki pushes are not refused by the pull-mirror rule. | |
| 17 | -- **Refs synced (both directions):** `refs/heads/*` and `refs/tags/*` only, with prune. Pull: `+refs/heads/*:refs/heads/*` and `+refs/tags/*:refs/tags/*`. Push: the same refspecs plus `--prune --force`. **Never** `refs/pull/*`. | |
| 18 | -- **URLs:** | |
| 19 | - - Validate with the webhook policy: `webhooks::client::validate_url` and `is_forbidden_ip` on every resolved address before each run, so private and loopback targets are rejected unless `[webhooks] allowed_hosts` allows them. | |
| 20 | - - **DNS pinning:** the worker resolves the host once per run (`check_remote`), checks every address (one forbidden address refuses the host), and pins the first one into git with `-c http.curloptResolve=<host>:<port>:<ip>` (IPv6 in brackets). Git then connects to the checked address and never resolves the name again, which closes the DNS rebinding window. No pin applies to an address literal or an `allowed_hosts` host. | |
| 21 | - - `https` only, unless `[webhooks] allow_http` is on. | |
| 22 | - - No userinfo in the URL. | |
| 23 | - - Never `file://`, `ssh://` or `ext::`. | |
| 24 | -- **Credentials:** an optional username and password/token. The secret is sealed with the instance key, the same way webhook secrets are. | |
| 25 | - - They are passed to git **only through environment config**: `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=http.extraHeader`, `GIT_CONFIG_VALUE_0=Authorization: Basic <b64>`. | |
| 26 | - - **Never** put them in argv, the stored URL, logs, errors or audit entries. Redact the URL to host and path in the UI and in errors. | |
| 27 | -- **git safety for remote operations:** | |
| 28 | - - `-c protocol.allow=never -c protocol.https.allow=always` (plus `protocol.http.allow=always` only when `allow_http` is on); | |
| 29 | - - `-c http.followRedirects=false -c credential.helper=`; | |
| 30 | - - `GIT_TERMINAL_PROMPT=0`; | |
| 31 | - - a timeout (default 10 min, killing the process). | |
| 32 | - - Use the existing `GitCommand` builder (managed gitconfig, cleared env) and `--end-of-options` before the URL. | |
| 33 | -- **Intervals:** default 8 hours, minimum 10 minutes, `0` = manual only. | |
| 34 | - | |
| 35 | -## Schema (migration `0017_mirrors.sql`) | |
| 36 | -```sql | |
| 37 | -CREATE TABLE repo_mirrors ( | |
| 38 | - id TEXT PRIMARY KEY, | |
| 39 | - repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 40 | - direction TEXT NOT NULL, -- 'pull' | 'push' | |
| 41 | - url TEXT NOT NULL, | |
| 42 | - username TEXT NOT NULL DEFAULT '', | |
| 43 | - secret_sealed BLOB, -- NULL = no password | |
| 44 | - interval_minutes INTEGER NOT NULL DEFAULT 480, | |
| 45 | - enabled INTEGER NOT NULL DEFAULT 1, | |
| 46 | - next_sync_at TEXT NOT NULL, | |
| 47 | - last_sync_at TEXT, last_ok INTEGER, last_error TEXT, | |
| 48 | - created_at TEXT NOT NULL); | |
| 49 | -CREATE INDEX repo_mirrors_due ON repo_mirrors(enabled, next_sync_at); | |
| 50 | -CREATE UNIQUE INDEX repo_mirrors_one_pull ON repo_mirrors(repo_id) WHERE direction = 'pull'; | |
| 51 | -``` | |
| 52 | -- `Store::delete_repo` (and the repo trash purge) must delete the repo's mirror rows. | |
| 53 | - | |
| 54 | -## forge-core (`src/mirrors.rs` model plus `store/mirrors.rs`), worker C1 | |
| 55 | -```rust | |
| 56 | -pub enum MirrorDirection { Pull, Push } // as_str / FromStr | |
| 57 | -pub struct Mirror { pub id: Id, pub repo_id: Id, pub direction: MirrorDirection, pub url: String, pub username: String, | |
| 58 | - pub has_secret: bool, pub interval_minutes: u32, pub enabled: bool, pub next_sync_at: String, | |
| 59 | - pub last_sync_at: Option<String>, pub last_ok: Option<bool>, pub last_error: Option<String>, pub created_at: String } | |
| 60 | -pub struct NewMirror<'a> { pub repo_id: &'a str, pub direction: MirrorDirection, pub url: &'a str, pub username: &'a str, | |
| 61 | - pub secret: Option<&'a [u8]> /* plaintext; the store seals it */, pub interval_minutes: u32 } | |
| 62 | - | |
| 63 | -create_mirror(&NewMirror) -> Result<Mirror> // Conflict if a second pull mirror; Invalid if over 10 push mirrors | |
| 64 | -list_mirrors(repo_id) -> Result<Vec<Mirror>> | |
| 65 | -mirror(id) -> Result<Option<Mirror>> | |
| 66 | -pull_mirror(repo_id) -> Result<Option<Mirror>> // used by pre-receive and the UI | |
| 67 | -update_mirror(id, url, username, secret: SecretUpdate /* Keep | Clear | Set(&[u8]) */, interval_minutes, enabled) -> Result<()> | |
| 68 | -delete_mirror(id) -> Result<()> | |
| 69 | -mirror_secret(id) -> Result<Option<Vec<u8>>> // unsealed, only for the sync worker | |
| 70 | -due_mirrors(now, limit) -> Result<Vec<Mirror>> | |
| 71 | -schedule_mirror(id, next_sync_at) -> Result<()> // "sync now" sets it to now | |
| 72 | -record_mirror_result(id, ok: bool, error: Option<&str>, next_sync_at) -> Result<()> | |
| 73 | -``` | |
| 74 | - | |
| 75 | -## forge-git (`src/mirror.rs`), worker C1 | |
| 76 | -```rust | |
| 77 | -pub struct RemoteAuth { pub username: String, pub secret: Vec<u8> } // Debug impl redacts | |
| 78 | -pub struct PinnedHost { pub host: String, pub port: u16, pub ip: IpAddr } // config_pair() -> "http.curloptResolve=host:port:ip" | |
| 79 | -pub async fn mirror_fetch(env: &GitEnv, repo: &Path, url: &str, auth: Option<&RemoteAuth>, allow_http: bool, pin: Option<&PinnedHost>, timeout: Duration) -> Result<()>; | |
| 80 | -pub async fn mirror_push(env: &GitEnv, repo: &Path, url: &str, auth: Option<&RemoteAuth>, allow_http: bool, pin: Option<&PinnedHost>, timeout: Duration) -> Result<()>; | |
| 81 | -``` | |
| 82 | -- `pin` is the address the worker checked (see DNS pinning above). It is passed as a `-c` pair built by `PinnedHost::config_pair`, which rejects a host that is not a plain DNS name. Its port must match the URL's port (443 or 80 by scheme). | |
| 83 | -- Errors carry git's stderr (truncated to 2 KiB) with the URL's userinfo and any `Authorization` text scrubbed. | |
| 84 | -- After a pull fetch, set HEAD to the remote's default branch if ours doesn't exist. `git ls-remote --symref <url> HEAD` is part of the same fetch call. | |
| 85 | - | |
| 86 | -## forge-web, worker C1 | |
| 87 | -- `crate::mirrors::worker`: a tokio task started at boot, polling every 60s and woken by a `Notify` on "sync now" or a push. It claims due mirrors, with at most 2 running concurrently and never two on the same repo. | |
| 88 | - - **Pull:** snapshot the refs (`git for-each-ref`) before and after, then call `post_update::after_ref_update` for the changed refs with the ghost or system actor (sender null). Webhooks and PR sync then work as for a push. | |
| 89 | - - **Push:** runs `mirror_push`. | |
| 90 | - - Each run records the result, sets `next_sync_at = now + interval` (or far future when the interval is 0), and is audited only on failure transitions (ok → failed). | |
| 91 | -- `crate::mirrors::enqueue_push_mirrors(state, repo_id)` is called from `after_ref_update`. It sets every enabled push mirror's `next_sync_at` to now+10s (debounce) and notifies. | |
| 92 | -- `check_remote(url, policy) -> Result<Option<PinnedHost>>` does the URL and address checks and returns the pin; `pin_checked(host, port, resolved, policy)` is the pure address check that picks the first address. | |
| 93 | -- **Pre-receive:** reject pushes to a repo with a pull mirror. | |
| 94 | - | |
| 95 | -## forge-web UI, worker C2 (against the API above; while C1 is unfinished, stub nothing and wait or retry the compile) | |
| 96 | -- **Add pull mirror to an empty repo** (repo settings → Mirrors, Admin): shown only when the repo has no pull mirror and `load_refs` returns no refs. Its form (URL, username, password/token, interval) posts to the same `/settings/mirrors` route with a hidden `direction=pull`. The handler refuses a repo with refs (redirect with `notice=pull-not-empty`), validates the URL with `check_form`, calls `store.create_mirror` with `Pull`, schedules it now and notifies `mirror_waker` (`notice=pull-created`). It is audited as `mirror_created`, like the push form. Non-admins get 404; archived repos get 403. | |
| 97 | -- **New repo form:** a "Mirror an existing repository" section with URL, username, password/token, and interval. On submit, create the repo (empty), create the pull mirror, and schedule it now. The repo page shows a "Mirror of <redacted url>, last synced …" line under the header (all repo pages, via `RepoHead`). | |
| 98 | -- **Repo settings → "Mirrors" page** (`/{o}/{r}/settings/mirrors`, Admin): | |
| 99 | - - List the mirrors with direction, redacted URL, interval, enabled flag, last sync, ok/error (error in `<details>`), and "Sync now" (POST, CSRF). | |
| 100 | - - An add-push-mirror form; edit form (the secret is write-only: blank keeps it, plus a "clear password" checkbox); delete. | |
| 101 | - - Converting a pull mirror into a normal repo is "Delete" on the pull mirror row. Confirm text: "Stop mirroring; the repository becomes a normal repository". | |
| 102 | -- **Audit variants:** `mirror_created`, `mirror_updated`, `mirror_deleted`, `mirror_sync_failed` (with the redacted URL). | |
| 103 | -- **Tests:** | |
| 104 | - - page permissions (including the add-pull POST as non-admin → 404); | |
| 105 | - - add pull mirror allowed on an empty repo, refused on a repo with refs, form shown only when allowed; | |
| 106 | - - pin: config pair for IPv4 and IPv6, the first checked address is pinned, a forbidden address refuses the host, and a fetch to a pinned name that never resolves fails at connect, not at resolve; | |
| 107 | - - a secret never rendered; | |
| 108 | - - SSRF rejection of a loopback URL in the form; | |
| 109 | - - pull mirror end to end with a `file`-less local HTTP smart server, or else allow-list `127.0.0.1` in the test config and use the forge's own HTTP git server as the remote; | |
| 110 | - - the push to a pull mirror rejected. |
docs/notifications-interfaces.md| @@ -1,86 +0,0 @@ | ||
| 1 | -# Notifications (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from the earlier interface docs apply: | |
| 4 | -- No JS and no inline styles; no unwrap in non-test code. | |
| 5 | -- Tests use `tempfile::TempDir`. | |
| 6 | -- Never put cargo target dirs or big files in /tmp. | |
| 7 | - | |
| 8 | -## Model | |
| 9 | -- **One notification thread per (user, issue-or-PR).** New activity on a thread upserts its row: | |
| 10 | - - `unread = 1`, `updated_at = now`, `reason` = the strongest current reason, `last_actor_id`. | |
| 11 | - - Reason strength, highest first: `mention` > `assign` > `review_requested` > `author` > `comment` > `state_change` > `subscribed` > `watching`. | |
| 12 | -- **Who gets notified** about activity on issue I in repo R (never the actor themself; only users who can Read R *now*, checked with `authorize` using their collaborator role): | |
| 13 | - 1. users subscribed to I (`issue_subscriptions.state = 'subscribed'`), with their stored reason; | |
| 14 | - 2. users watching R with level `watching`, unless they have an `ignored` subscription on I; | |
| 15 | - 3. users @mentioned in the new text who can read R. This auto-subscribes them with reason `mention`. | |
| 16 | -- **Auto-subscribe** (state subscribed, if no row exists yet): | |
| 17 | - - the author on create; | |
| 18 | - - a commenter on comment; | |
| 19 | - - an assignee on assign; | |
| 20 | - - a reviewer on review submit; | |
| 21 | - - anyone mentioned. | |
| 22 | - An explicit `ignored` row is never overwritten by auto-subscribe. | |
| 23 | -- **Repo watch levels:** | |
| 24 | - - `watching`: all activity; | |
| 25 | - - `participating`: the default when no row exists; subscriptions only; | |
| 26 | - - `ignoring`: no notifications from this repo at all, even when subscribed. **Mentions still notify.** | |
| 27 | -- **Activities:** | |
| 28 | - - `opened`, `comment`, `closed`, `reopened`, `merged`; | |
| 29 | - - `review` (with `state`), `assigned` (to that user: reason `assign`); | |
| 30 | - - `mentioned`, `pushed` (synchronize on a PR: subscribers only, not watchers). | |
| 31 | - | |
| 32 | -## Schema (migration `0012_notifications.sql`) | |
| 33 | -```sql | |
| 34 | -CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 35 | - level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id)); | |
| 36 | -CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id), | |
| 37 | - state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id)); | |
| 38 | -CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), | |
| 39 | - repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id), | |
| 40 | - reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id), | |
| 41 | - unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id)); | |
| 42 | -CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at); | |
| 43 | -``` | |
| 44 | -- `Store::delete_repo` must delete these rows for the repo and its issues. | |
| 45 | -- `delete_user` (store/user_delete.rs) must delete the user's rows. In `notifications.last_actor_id` it reassigns the user to ghost. | |
| 46 | - | |
| 47 | -## forge-core (`src/notifications.rs` model plus `store/notifications.rs`) | |
| 48 | -```rust | |
| 49 | -pub enum WatchLevel { Watching, Participating, Ignoring } | |
| 50 | -pub enum SubState { Subscribed, Ignored } | |
| 51 | -pub enum Reason { Mention, Assign, ReviewRequested, Author, Comment, StateChange, Subscribed, Watching } // as_str/parse, rank() | |
| 52 | -pub struct Notification { pub id: Id, pub repo_id: Id, pub repo_owner: String, pub repo_name: String, pub issue_id: Id, | |
| 53 | - pub number: i64, pub is_pull: bool, pub title: String, pub issue_state: String, pub reason: Reason, | |
| 54 | - pub last_activity: String, pub last_actor_name: Option<String>, pub unread: bool, pub updated_at: String } | |
| 55 | - | |
| 56 | -set_watch(user, repo, WatchLevel); watch_level(user, repo) -> WatchLevel; watchers(repo) -> Vec<Id> // level watching | |
| 57 | -subscribe(user, issue, SubState, Reason); auto_subscribe(user, issue, Reason) // no-op if a row exists | |
| 58 | -subscription(user, issue) -> Option<(SubState, Reason)>; subscribers(issue) -> Vec<(Id, Reason)> // state subscribed | |
| 59 | -upsert_notification(user, repo, issue, reason, activity, actor) // keeps the stronger reason if one exists and the row is still unread | |
| 60 | -list_notifications(user, unread_only: bool, before: Option<&str>, limit) -> Vec<Notification> | |
| 61 | -unread_count(user) -> u64 | |
| 62 | -mark_read(user, notification_id); mark_all_read(user, repo: Option<&str>); mark_issue_read(user, issue) // the last one is called when the user views the issue or PR | |
| 63 | -``` | |
| 64 | - | |
| 65 | -## forge-web | |
| 66 | -- `crate::notify::dispatch(state, repo, issue, actor_id, activity, text_for_mentions: Option<&str>)`: | |
| 67 | - - computes recipients per the rules above, then auto-subscribes and upserts; | |
| 68 | - - never fails the caller (it logs errors); | |
| 69 | - - runs **inline** (DB only, cheap). | |
| 70 | -- **Mentions:** extract `@username` with the same rules as the markdown `@user` linking (reuse that code: grep `render/markdown.rs` for the mention handling and factor a `pub fn mentioned_usernames(text) -> Vec<String>`). Skip code spans and code blocks, and dedupe. | |
| 71 | -- **Call sites**, the same places as the webhook `emit_*` calls: | |
| 72 | - - issue/PR open: activity `opened`, with the body checked for mentions; | |
| 73 | - - comment create: `comment`, with mentions; | |
| 74 | - - close and reopen (including closing keywords and merges): `closed`/`reopened`/`merged`; | |
| 75 | - - assign: `assigned`, notifying the assignee with reason `assign` even when they don't watch; | |
| 76 | - - review submit: `review`, with the body and the review comment bodies checked for mentions; | |
| 77 | - - PR head update: `pushed`; | |
| 78 | - - edit of a body or comment: mentions newly added by the edit (old vs new text) only. | |
| 79 | -- **Views:** | |
| 80 | - - `GET /notifications`: tabs Unread and All, grouped by repo; each row shows an icon (issue or PR, by state), `owner/name #N title`, reason, last activity, relative time and a "Mark read" button. Bulk actions: "Mark all read" per repo and globally. Cursor pagination, 50 per page. | |
| 81 | - - Clicking a row goes to `/notifications/{id}/open`, which marks it read and redirects 303 to the issue or PR URL. | |
| 82 | - - Rows for repos the user can no longer read are hidden, and marked read lazily. | |
| 83 | - - A header bell link with the unread count, for signed-in users (the `shell` in `views/mod.rs`); one cheap COUNT query. | |
| 84 | - - Issue and PR sidebar: a "Notifications" box with Subscribe/Unsubscribe (POST, CSRF) and the reason ("You're receiving notifications because you were mentioned"). | |
| 85 | - - Repo header: a Watch form with a `<select>` (Participating / Watching / Ignoring) and a submit button (POST, CSRF); shows the watcher count. | |
| 86 | - - Viewing an issue or PR page calls `mark_issue_read` for the viewer. |
docs/profile-interfaces.md| @@ -1,66 +0,0 @@ | ||
| 1 | -# Profile: avatar, about and timezone (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from `docs/m1-interfaces.md` apply: latest dependencies in root `[workspace.dependencies]`, no unwrap in non-test code, clippy `-D warnings` clean, no inline styles and no JS. | |
| 4 | - | |
| 5 | -## Storage: migration `crates/forge-core/migrations/0002_profile.sql` | |
| 6 | -```sql | |
| 7 | -ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 8 | -ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'; | |
| 9 | -CREATE TABLE avatars ( | |
| 10 | - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 11 | - data BLOB NOT NULL, -- re-encoded PNG, 256x256 | |
| 12 | - hash TEXT NOT NULL, -- hex sha256 of data (first 16 chars), used as ETag and as ?v= | |
| 13 | - updated_at TEXT NOT NULL | |
| 14 | -); | |
| 15 | -``` | |
| 16 | -Avatars live in the DB, not on disk, so the standby replica and the backups carry them automatically. Each one is about 30–80 KB. | |
| 17 | - | |
| 18 | -## forge-core | |
| 19 | -```rust | |
| 20 | -// model.rs: User gains | |
| 21 | -pub about: String, // raw markdown, at most 2000 chars | |
| 22 | -pub timezone: String, // IANA name, e.g. "Europe/Istanbul"; "UTC" by default | |
| 23 | - | |
| 24 | -// store | |
| 25 | -pub async fn set_profile(&self, user_id: &str, about: &str, timezone: &str) -> Result<()>; | |
| 26 | -pub async fn set_avatar(&self, user_id: &str, png: &[u8], hash: &str) -> Result<()>; // upsert | |
| 27 | -pub async fn delete_avatar(&self, user_id: &str) -> Result<()>; | |
| 28 | -pub async fn avatar_hash(&self, user_id: &str) -> Result<Option<String>>; | |
| 29 | -pub async fn avatar(&self, user_id: &str) -> Result<Option<(Vec<u8>, String /*hash*/)>>; | |
| 30 | - | |
| 31 | -// tz.rs (jiff, latest; bundled tzdb so it doesn't depend on the host) | |
| 32 | -pub fn validate_timezone(name: &str) -> Result<()>; // must be a known IANA name | |
| 33 | -pub fn all_timezones() -> Vec<String>; // sorted, for the <select> | |
| 34 | -pub fn format_in_tz(unix: i64, tz: &str) -> String; // "2026-10-15 14:05 +03"; falls back to UTC on error | |
| 35 | -pub fn local_time_now(tz: &str, now_unix: i64) -> String; // "14:05 (UTC+03:00)" | |
| 36 | - | |
| 37 | -// avatar.rs (image, latest, only the png/jpeg/webp/gif decoders) | |
| 38 | -pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024; | |
| 39 | -pub struct ProcessedAvatar { pub png: Vec<u8>, pub hash: String } | |
| 40 | -/// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256 | |
| 41 | -/// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown | |
| 42 | -/// formats, too-large input or a decode failure give Error::InvalidInput. | |
| 43 | -pub fn process_avatar(bytes: &[u8]) -> Result<ProcessedAvatar>; | |
| 44 | -``` | |
| 45 | -`about` is limited to 2000 chars. Validation is in the store or the page (either is fine); the page shows the error. | |
| 46 | - | |
| 47 | -## forge-web | |
| 48 | -- `GET /avatars/{username}` (route, cacheable): | |
| 49 | - - Serves the PNG with `Content-Type: image/png`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'`, `ETag: "<hash>"`, and `Cache-Control: public, max-age=31536000, immutable` when `?v=<hash>` matches; otherwise `max-age=300`. `If-None-Match` gives a 304. | |
| 50 | - - **No avatar:** generated SVG with the first letter of the username on a color derived from the user id. It's built by our own code, the letter is XML-escaped, and it's still served with nosniff and the CSP above. | |
| 51 | - - Unknown user: 404. | |
| 52 | -- Helper `views::avatar_url(username, hash: Option<&str>) -> String` gives `/avatars/<username>?v=<hash or "default">`. It's used in the nav, the profile page and the settings page. | |
| 53 | -- `/settings/profile`: the existing username-case form, plus: | |
| 54 | - - avatar upload (`multipart/form-data`) and remove | |
| 55 | - - an "about" textarea | |
| 56 | - - a timezone `<select>` that lists every timezone | |
| 57 | - - every POST has CSRF | |
| 58 | -- `GET /{username}`: the public profile page, showing: | |
| 59 | - - avatar | |
| 60 | - - display username | |
| 61 | - - about, through `render_markdown(about, None)` | |
| 62 | - - "Local time: 14:05 (UTC+03:00)" | |
| 63 | - - repos visible to the viewer | |
| 64 | - - 404 for an unknown user | |
| 65 | - - Fixed routes (`/new`, `/login`, `/setup`, `/settings/*`, `/admin/*`, `/assets/*`, `/avatars/*`) must keep winning; reserved names guarantee no user owns those paths. | |
| 66 | -- **Absolute times** (the `title`/absolute text on commit, commits, tree and refs pages) use the logged-in viewer's timezone via `tz::format_in_tz`, or UTC for anonymous viewers. Relative times stay as they are. |
docs/research.md| @@ -1,249 +0,0 @@ | ||
| 1 | -# Git forge research notes | |
| 2 | - | |
| 3 | -Compiled 2026-10-15 from 10 parallel research passes. Sources are linked inline. | |
| 4 | -Items marked *(unverified)* came from secondary sources or aggregators. Many 2026 CVE IDs below came from aggregator sites, so check them against GHSA/NVD before citing them. | |
| 5 | - | |
| 6 | ---- | |
| 7 | - | |
| 8 | -## 1. Smart HTTP | |
| 9 | - | |
| 10 | -- **Endpoints:** | |
| 11 | - - `GET /info/refs?service=git-{upload,receive}-pack` | |
| 12 | - - `POST /git-upload-pack` | |
| 13 | - - `POST /git-receive-pack` | |
| 14 | -- **Content types:** | |
| 15 | - - advertisement: `application/x-git-<svc>-advertisement` | |
| 16 | - - request: `application/x-git-<svc>-request` | |
| 17 | - - result: `application/x-git-<svc>-result` | |
| 18 | - - Always send `Cache-Control: no-cache`. | |
| 19 | - - <https://git-scm.com/docs/gitprotocol-http> | |
| 20 | -- **Advertisement body:** a pkt-line `# service=git-upload-pack`, then a flush `0000`, then the output of `git upload-pack --stateless-rpc --advertise-refs`. | |
| 21 | -- **Protocol v2:** pass the `Git-Protocol` request header through to the `GIT_PROTOCOL` env var. With v2, clients go straight to POST. <https://git-scm.com/docs/gitprotocol-v2> | |
| 22 | -- **Gzip:** request bodies may arrive with `Content-Encoding: gzip`. Inflate them before piping to git. | |
| 23 | -- **Streaming:** stream request and response bodies to and from the git child process. Never buffer a whole push. | |
| 24 | - - Clients use chunked POSTs above `http.postBuffer` (1 MiB). | |
| 25 | - - GitLab moved git traffic out of Rails into Workhorse because the app server's 60s timeout killed long requests. **Git routes must have no request timeout and no body-size middleware.** <https://docs.gitlab.com/development/workhorse/handlers/> | |
| 26 | -- **Keepalive:** `uploadpack.keepAlive` defaults to 5s, so proxies must not apply idle timeouts to git routes. | |
| 27 | -- **Auth:** answer 401 with `WWW-Authenticate: Basic`. The username is ignored or must match, and the password is a personal access token. Anonymous read is allowed only for public repos. | |
| 28 | -- **Dumb protocol:** skip it, and set `http.getanyfile=false`. | |
| 29 | -- **Partial clone:** enable `uploadpack.allowFilter` and `uploadpack.allowRefInWant`. | |
| 30 | - | |
| 31 | -## 2. SSH | |
| 32 | - | |
| 33 | -- The client runs `git-upload-pack '/path'`, `git-receive-pack '/path'` or `git-upload-archive '/path'`. | |
| 34 | - - **Allow-list these exact command names.** | |
| 35 | - - Parse the single-quoted argument strictly: reject quotes and newlines inside it. | |
| 36 | - - Never use a shell. | |
| 37 | - - <https://git-scm.com/docs/gitprotocol-pack> | |
| 38 | -- **Environment:** accept only `GIT_PROTOCOL` from env requests. Gogs CVE-2024-39930 was an RCE through env handling in its built-in SSH server. <https://github.com/gogs/gogs/security/advisories/GHSA-vm62-9jw3-c8w3> | |
| 39 | -- **Key lookup** must be an exact, case-sensitive match on the key blob. Gitea had an auth bypass from a case-insensitive SQLite `LIKE`. <https://github.com/go-gitea/gitea/security/advisories/GHSA-8pwg-hcgm-54cp> | |
| 40 | -- **Key types:** disable `ssh-rsa` with SHA-1 signatures (OpenSSH 8.8 default). russh still lists it as supported, so turn it off explicitly. | |
| 41 | -- **Timeouts:** set handshake and auth timeouts. Gogs had a DoS from stalled SSH handshakes. | |
| 42 | -- **russh:** version 0.64.x. `server::Handler` provides `auth_publickey`, `env_request`, `exec_request` and `data`; `Session` provides `data`, `eof`, `exit_status_request` and `close`. | |
| 43 | -- **Alternative:** OpenSSH with `AuthorizedKeysCommand` plus `AcceptEnv GIT_PROTOCOL`. | |
| 44 | - | |
| 45 | -## 3. Push handling and hooks | |
| 46 | - | |
| 47 | -- **pre-receive:** | |
| 48 | - - Runs once per push and gets `<old> <new> <ref>` lines on stdin. | |
| 49 | - - A non-zero exit rejects the **whole** push. **All policy goes here.** | |
| 50 | - - New objects sit in a **quarantine** directory (`GIT_QUARANTINE_PATH`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`). They aren't visible to other processes until the hook accepts, so any git command the hook runs must inherit those env vars. | |
| 51 | -- **update:** runs per ref and can reject a single ref. | |
| 52 | -- **post-receive:** runs after the push and cannot reject. Use it to enqueue events: webhooks, PR updates, cache invalidation, mirror pushes. | |
| 53 | -- **reference-transaction** (git 2.28+) can veto any ref update, including ones that don't come from a push. | |
| 54 | -- **Hook design (Gitea pattern):** a tiny hook binary calls back into the app's internal API. | |
| 55 | - - Use a unix socket plus a shared secret. | |
| 56 | - - Pass user ID, repo ID and protocol through env vars set when spawning receive-pack. | |
| 57 | - - <https://github.com/go-gitea/gitea/blob/main/cmd/hook.go> | |
| 58 | -- **Force-push detection:** `git rev-list --max-count=1 <old> ^<new>`; any output means a force push. | |
| 59 | -- **Deletion:** `new == 0000…`. | |
| 60 | -- **Server config:** | |
| 61 | - - `receive.fsckObjects=true` | |
| 62 | - - `receive.maxInputSize` | |
| 63 | - - `receive.advertisePushOptions=true` | |
| 64 | - - `core.hooksPath` pointing to our own read-only directory. Users never write hooks or config. | |
| 65 | -- **Gitea gap to avoid:** its branch protection with required checks only applies to PR merges, so direct pushes bypass it. | |
| 66 | -- **AGit flow:** `git push origin HEAD:refs/for/main -o title=…` creates a PR without a fork. It goes through the `proc-receive` hook with `receive.procReceiveRefs`. | |
| 67 | - | |
| 68 | -## 4. Security checklist | |
| 69 | - | |
| 70 | -- **Argument injection:** | |
| 71 | - - Always put `--end-of-options` before user-supplied revisions and `--` before paths (git 2.24+). | |
| 72 | - - Reject refs and names starting with `-`. | |
| 73 | - - Build every git command through one builder that enforces this. | |
| 74 | - - Examples: Gitea CVE-2022-42968; go-git CVE-2025-21613. | |
| 75 | -- **Symlinks and paths:** check **every** path component for symlinks when rendering, archiving or writing, not just the last one. Gogs CVE-2025-8110 is in the CISA KEV catalog of actively exploited bugs. | |
| 76 | -- **Untrusted repos (imports and mirrors):** | |
| 77 | - - Never recurse into submodules. | |
| 78 | - - Never trust repo-local config. | |
| 79 | - - Keep `protocol.file.allow=user`. | |
| 80 | - - Run as a dedicated service user. | |
| 81 | - - Keep the git binary patched: CVE-2024-32002 and CVE-2025-48384 are hook-execution bugs via submodules. | |
| 82 | -- **SSRF** (migrations, mirrors, webhooks): resolve DNS, pin the resulting IP, block private and link-local ranges, and **re-check after every redirect**. Block `file://`. Gitea and Gogs had several 2026 CVEs here *(unverified IDs)*. This matters most for us, because mirrors are a core feature. | |
| 83 | -- **XSS:** | |
| 84 | - - Serve raw and user-uploaded content from a **separate origin** (e.g. `raw.<domain>`) with `X-Content-Type-Options: nosniff` and a strict CSP. | |
| 85 | - - Use `Content-Disposition: attachment` for HTML and SVG. | |
| 86 | - - Sanitize rendered markdown (ammonia) and allow only http(s) and mailto links. | |
| 87 | -- **DoS:** | |
| 88 | - - Cap request bodies, diff sizes, blame and log time, and archive sizes. | |
| 89 | - - Use only linear-time regex (Rust's `regex` crate is fine). Gitea had a ReDoS in CODEOWNERS matching. | |
| 90 | -- **Private repos:** return **404 for both missing and forbidden**, everywhere, including any go-get style endpoints. | |
| 91 | -- **Tokens:** store hashes and compare in constant time. Never put tokens in URLs or logs. | |
| 92 | -- **Names:** | |
| 93 | - - NFKC-normalize and case-fold, with a unique index on the folded form. | |
| 94 | - - Keep a reserved-names list. | |
| 95 | - - Don't allow names to end in `.git` or `.atom`. | |
| 96 | - | |
| 97 | -## 5. Storage and maintenance | |
| 98 | - | |
| 99 | -- **Layout by ID**, e.g. `repos/ab/cd/<id>.git` (GitLab hashed storage). Renames are then DB-only, with a redirect table for old web and git URLs. GitHub redirects git remotes after a rename. | |
| 100 | -- **Maintenance:** | |
| 101 | - - Schedule `git maintenance` (incremental-repack plus loose-objects), or `git repack --geometric=2 --write-midx`. | |
| 102 | - - Write a commit-graph with changed-path Bloom filters; these make `git log -- path` fast. | |
| 103 | - - Write bitmaps for clone speed. | |
| 104 | -- **Hidden refs:** set `uploadpack.hideRefs` / `transfer.hideRefs` for internal refs. | |
| 105 | -- **Atomic ref updates:** use `git update-ref --stdin`, which updates all refs in one transaction. | |
| 106 | -- **Tree view "last commit per file":** this is expensive and needs a cache keyed by (commit, path). GitLab measured a ~22× gain from caching it. | |
| 107 | -- **Archives:** cache them, but don't promise stable checksums. Bytes change across git versions. | |
| 108 | -- **Forks:** dedupe with alternates or object pools, but pools must never be pruned. Leaking objects between private forks is a risk. **Defer dedup and do full copies at first.** | |
| 109 | -- **Backups:** snapshot the refs first, then the objects, or briefly pause writes. Back up the **instance secret key** separately; Gitea loses 2FA and mirror credentials without it. | |
| 110 | - | |
| 111 | -## 6. Pull requests | |
| 112 | - | |
| 113 | -- **PR refs:** store the head as `refs/pull/N/head` in the base repo and update it on every push to the head branch. | |
| 114 | -- **Mergeability:** | |
| 115 | - - Check with `git merge-tree --write-tree base head`, which needs no worktree and reports conflicts. | |
| 116 | - - Recompute whenever the base or head moves. | |
| 117 | - - A test-merge ref `refs/pull/N/merge` is optional. | |
| 118 | -- **Diffs:** show **three-dot** diffs (from the merge base). | |
| 119 | -- **Merge strategies:** | |
| 120 | - - merge commit, built with `commit-tree` from the merge-tree result | |
| 121 | - - squash | |
| 122 | - - rebase | |
| 123 | - - fast-forward only | |
| 124 | -- **Review comments:** store the commit SHA, path, line, side and the diff hunk context. When the anchored code changes, mark the comment **outdated**; don't delete it. | |
| 125 | -- **Large diffs:** collapse or lazy-load big files, detect binary files, and use rename detection (`-M`). | |
| 126 | -- **Range-diff:** `git range-diff` gives "what changed since the last review". | |
| 127 | -- **Gerrit/AGit:** an alternative workflow that pushes to `refs/for/<branch>`. | |
| 128 | - | |
| 129 | -## 7. Git library split | |
| 130 | - | |
| 131 | -- gix 0.89 *(unverified version)*: | |
| 132 | - - **Use it in-process for:** | |
| 133 | - - object, tree and blob reads | |
| 134 | - - rev-parse and rev-walk | |
| 135 | - - tree and blob diff | |
| 136 | - - rename detection | |
| 137 | - - tar and zip archives | |
| 138 | - - pack encode and decode | |
| 139 | - - basic ref transactions | |
| 140 | - - **Missing in gix:** | |
| 141 | - - the upload-pack/receive-pack server side | |
| 142 | - - signature verification | |
| 143 | - - commit-graph writing and bitmaps | |
| 144 | - - **Partial in gix:** | |
| 145 | - - blame (no rename tracking; "not competitive with git") | |
| 146 | - - merge workflow | |
| 147 | -- **Shell out to `git` for:** serving fetch and push, merges and conflicts, blame, `verify-commit`/`verify-tag`, and maintenance. | |
| 148 | -- **Hot-path fallback:** a pool of long-running `git cat-file --batch-command` processes, one per repo. Gitea and Gitaly both use this. | |
| 149 | -- **Skip git2**, since it adds a C dependency. | |
| 150 | -- **Minimum git version:** 2.38+ for `merge-tree --write-tree`. In practice, track the latest release for security fixes. | |
| 151 | - | |
| 152 | -## 8. Features and crates | |
| 153 | - | |
| 154 | -- **Markdown:** comrak (GFM, alerts, footnotes, math) followed by ammonia for sanitization. Rewrite relative links and images to repo paths. | |
| 155 | -- **Syntax highlighting:** syntect (mature), or arborium/tree-sitter. Tree-sitter grammars must match the core version. | |
| 156 | -- **Language stats:** tokei (not equivalent to Linguist). | |
| 157 | -- **Code search:** a trigram index (zoekt-style) or tantivy. Defer it. | |
| 158 | -- **Commit signatures:** `git verify-commit` with `gpg.format=ssh` and a generated `allowedSignersFile` built from users' SSH signing keys. | |
| 159 | -- **Webhooks:** | |
| 160 | - - Sign with an `X-Hub-Signature-256`-style HMAC-SHA256 over the raw body. | |
| 161 | - - Send a delivery ID header and use a 10s timeout. | |
| 162 | - - Keep a retry queue and a delivery log. | |
| 163 | -- **LFS:** the batch API (`POST /objects/batch`) plus the locks API. Storage can go to `in` or `object_store`. | |
| 164 | -- **CODEOWNERS:** the last matching pattern wins. | |
| 165 | -- **Mailmap:** apply `.mailmap` in display. | |
| 166 | -- **Avatars:** Gravatar uses SHA-256 of the email. | |
| 167 | -- **Feeds:** atom_syndication. | |
| 168 | - | |
| 169 | -## 9. Ecosystem and portability | |
| 170 | - | |
| 171 | -- **Woodpecker CI** supports Gitea and Forgejo forges, and so do Renovate and `tea`. **A Gitea-compatible API subset would make them all work for us almost for free.** | |
| 172 | -- **Forgejo Actions:** its runner is a fork of `act` with a Connect/JSON runner protocol *(unverified)*. It's explicitly not identical to GitHub Actions. | |
| 173 | -- **Minimum CI surface:** webhooks plus a commit-status API. | |
| 174 | -- **F3 (Friendly Forge Format):** JSON plus attachments for issues, PRs, labels and milestones, with a Forgejo driver. A good export target. <https://f3.forgefriends.org/> | |
| 175 | -- **Radicle and git-bug** store issues and patches **inside git**, so they travel with every clone and mirror. | |
| 176 | -- **Software Heritage** has a Gitea/Forgejo lister ("Add Forge Now"). Being Gitea-API-compatible would also let it archive us automatically. | |
| 177 | -- **Forgejo federation:** ForgeFed; v16 (2026-07) shipped federated stars as a first step. | |
| 178 | - | |
| 179 | -## 10. Operations and product | |
| 180 | - | |
| 181 | -- **Permission model:** Read, Triage, Write, Maintain and Admin, plus collaborators. Org teams come later. | |
| 182 | -- **One authorization function** must serve web, API, smart HTTP, SSH and hooks. | |
| 183 | -- **Ghost user:** on account deletion, reassign issues and comments to a ghost user; never hard-delete them. | |
| 184 | -- **Personal access tokens:** expiry (default 30 days), scopes, stored hashed. | |
| 185 | -- **Deploy keys:** read-only by default, with write as an opt-in. | |
| 186 | -- **Archived repos:** fully read-only. | |
| 187 | -- **Mirrors:** | |
| 188 | - - Pull mirrors run on an interval, with a minimum interval. | |
| 189 | - - Push mirrors keep encrypted credentials. | |
| 190 | - - Run them after each push with a backoff. | |
| 191 | -- **Rate limiting:** per user and per IP, with writes weighted heavier. | |
| 192 | -- **Open registration** (if ever): require email verification and rate-limit new accounts. Codeberg sees bot accounts opening hundreds of issues per hour. | |
| 193 | -- **Takedowns:** only relevant if the instance becomes public. | |
| 194 | - | |
| 195 | ---- | |
| 196 | - | |
| 197 | -# Part 2: Data safety and reliability | |
| 198 | - | |
| 199 | -## 11. Lessons from real incidents | |
| 200 | - | |
| 201 | -- **SourceHut (Jan 2024):** a DDoS plus upstream null-routing took down the primary datacenter. They migrated to Amsterdam over about a week. The standby DB was under 30s behind, but **storage backups were up to 12h old and object storage had no off-site copy**. <https://sourcehut.org/blog/2024-01-19-outage-post-mortem/> | |
| 202 | -- **OVH Strasbourg fire (2021):** backups kept **at the same site** burned with the primary. Courts awarded customers damages. | |
| 203 | -- **Provider account risk:** Hetzner can terminate with 30 days' notice, and customers have reported notices that never arrived. OVH has reports of suspensions without warning *(unverified reviews)*. The protection is diversity: different provider, **payment method, contact email** and jurisdiction. | |
| 204 | -- **Git corruption** happens on power loss or storage hiccups, typically as zero-length loose objects. GitLab's runbook says to run `git fsck` after any unclean shutdown. | |
| 205 | - | |
| 206 | -## 12. How big hosts replicate, and what fits us | |
| 207 | - | |
| 208 | -- **GitHub Spokes:** 3 replicas, with each write committed by a majority under a lock. Repos re-replicate after a failure. This needs consensus and is overkill for us. | |
| 209 | -- **GitLab Gitaly Cluster (Praefect):** has documented known issues, such as snapshot restores that can't be reconciled and hook races. It's a cautionary example of complexity. | |
| 210 | -- **Raft (openraft):** pre-1.0. Overkill here. | |
| 211 | -- **Split-brain** is the main danger of more than one writer. **Fence the old primary before promoting the standby.** DNS and health checks alone don't enforce a single writer. | |
| 212 | -- **Never rsync or `cp` a live repo or a live DB.** | |
| 213 | - | |
| 214 | -## 13. Git replication approach (single writer) | |
| 215 | - | |
| 216 | -1. After post-receive, a replication job takes a **per-repo lock** and runs `git push --mirror` to each replica over SSH. It records failures and retries with backoff. | |
| 217 | -2. **Hourly reconcile:** compare a ref checksum (sha256 of sorted `git for-each-ref --format='%(objectname) %(refname)'`) between primary and replica. On a mismatch, re-mirror and run `git fsck`. | |
| 218 | -3. **Daily `git bundle create --all`** for each repo to off-provider storage, then `git bundle verify`. | |
| 219 | -4. Replicas get a weekly `git fsck --full` from cron. `git maintenance` has no fsck task. | |
| 220 | - | |
| 221 | -## 14. Metadata (Turso) safety | |
| 222 | - | |
| 223 | -- Turso supports only `synchronous` OFF or FULL. **Use WAL with FULL.** | |
| 224 | -- **Litestream is not verified on Turso files.** Turso says mixed SQLite/Turso access across processes is unsupported. Run a spike before relying on it. | |
| 225 | -- **Turso Sync/CDC** is documented only against Turso Cloud, and self-hosted sync is unconfirmed. Don't depend on it. | |
| 226 | -- **Verified path:** `VACUUM INTO` (supported by Turso) produces consistent snapshots on a schedule, which are then shipped to the replica and to backups. | |
| 227 | -- **Integrity checks:** `quick_check` daily, `integrity_check` plus `foreign_key_check` weekly. Alert on any result other than `ok`. | |
| 228 | -- **Instance secret key** (it encrypts mirror credentials, 2FA secrets and similar): back it up separately and escrow it offline. | |
| 229 | - | |
| 230 | -## 15. Backups (3-2-1-1-0) | |
| 231 | - | |
| 232 | -- **3-2-1-1-0:** 3 copies, 2 media, 1 offsite, 1 **immutable**, 0 verify errors. | |
| 233 | -- **Immutable storage:** Backblaze B2 Object Lock in **compliance mode** (nobody can shorten it), Cloudflare R2 bucket locks, or Wasabi compliance mode. | |
| 234 | -- **Survive a compromised server:** the server holds **no delete credentials**. Either a separate machine pulls the backups, or the server pushes with append-only keys (rest-server `--append-only`, or BorgBase append-only). | |
| 235 | -- **Tools:** | |
| 236 | - - restic: mature, with `check --read-data-subset` for verification. | |
| 237 | - - rustic: Rust and restic-compatible, but newer. | |
| 238 | - - borg: 1.4 is stable. | |
| 239 | -- **Encryption keys:** add a second restic key and store the keys offline. A lost key means lost data. | |
| 240 | -- **Monitoring:** a dead-man switch via healthchecks.io pings with `/start` and `/fail`. | |
| 241 | -- **Restore drill:** monthly, automated, restoring to a fresh VPS. Time it; that time is the real recovery time (RTO). | |
| 242 | - | |
| 243 | -## 16. Storage-level integrity | |
| 244 | - | |
| 245 | -- **Filesystem:** | |
| 246 | - - btrfs (or ZFS with ≥4GB RAM) gives checksums, scrub, snapshots and `send`/`receive`. | |
| 247 | - - On tiny VPSes, ext4/XFS with tested backups is fine. | |
| 248 | -- **Git fsync:** set `core.fsync` explicitly to cover loose objects and refs, plus a `core.fsyncMethod`. The default `committed,-loose-object` "risks losing recent work". <https://git-scm.com/docs/git-config> | |
| 249 | -- **App files:** write a temp file, fsync it, rename it, then fsync the directory. |
docs/topcoat-0.10-notes.md| @@ -1,499 +0,0 @@ | ||
| 1 | -# Topcoat 0.10 API cheat-sheet (git forge web layer) | |
| 2 | - | |
| 3 | -`REG` = `/home/tahinli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. Sub-crates: `REG/topcoat-<name>-0.10.0/`. **UNVERIFIED** = not confirmed in source. Snippets checked against signatures and docs, not compiled. | |
| 4 | - | |
| 5 | -Key files: facade `REG/topcoat-0.10.0/src/{lib,serve}.rs`; router `REG/topcoat-router-0.10.0/src/{router,builder,request,response,error,body,body_limit,compression,origin,proxy,listener}.rs`, `.../src/content/{json,form}.rs`; cookies `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`; context `REG/topcoat-core-0.10.0/src/context/app_context.rs`; views `REG/topcoat-view-0.10.0/src/`, `REG/topcoat-view-macro-0.10.0/docs/view.md`; guides `REG/topcoat-0.10.0/docs/*.md`, `REG/topcoat-router-0.10.0/docs/*.md`. | |
| 6 | - | |
| 7 | ---- | |
| 8 | - | |
| 9 | -## 1. Feature flags | |
| 10 | - | |
| 11 | -Default: `asset, compression, cookie, font, icon, router, runtime, serve, session, view, discover`. | |
| 12 | - | |
| 13 | -| Feature | Enables | | |
| 14 | -|---|---| | |
| 15 | -| `router` | `topcoat::router` (routes, pages, layouts, layers, `Router`); pulls `view` | | |
| 16 | -| `serve` | `topcoat::start/serve/serve_until`. Without it use `Router::handle` only | | |
| 17 | -| `discover` | Link-time collection: `module_router!()`, `.discover()` | | |
| 18 | -| `view` | `view!`, `#[component]`, `class!`, `suspense`, `error_boundary` | | |
| 19 | -| `cookie` | `topcoat::cookie`. Still call `.cookies()` on the builder | | |
| 20 | -| `asset` | `asset!`, `AssetBundle` | | |
| 21 | -| `compression` | Response compression (on by default, see 7e) | | |
| 22 | -| `session` | `topcoat::session` (**UNVERIFIED**, not covered) | | |
| 23 | -| `fs` | `public_dir`, `serve_dir` (not default) | | |
| 24 | -| `multipart` | `content::Multipart` (not default) | | |
| 25 | -| `websocket`, `sse`, `sitemap`, `tower`, `tailwind`, `datastar`, `htmx`, `alpine-ajax`, `mail`, `ui`, `font-fontsource`, `icon-iconify`, `anyhow`, `full` | Optional extras (not default) | | |
| 26 | -Minimal deps: `topcoat = "0.10.0"`, `tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util"] }`. For streaming (4b) also `http-body = "1"`, `http-body-util = "0.1"`, `tokio-util = { version = "0.7", features = ["io"] }`, `futures-util = "0.3"`. | |
| 27 | - | |
| 28 | ---- | |
| 29 | - | |
| 30 | -## 2. Routes | |
| 31 | - | |
| 32 | -### 2a. Module routing (recommended) | |
| 33 | - | |
| 34 | -```rust | |
| 35 | -// src/app.rs (module maps to "/") | |
| 36 | -use topcoat::router::{Router, module_router}; | |
| 37 | -pub fn router() -> Router { module_router!().discover().build() } | |
| 38 | -``` | |
| 39 | - | |
| 40 | -- `module_router!()` returns a `RouterBuilder`. `.discover()` (trait `topcoat::router::RouterBuilderDiscoverExt`) also collects explicit-path items. | |
| 41 | -- `app::about` -> `/about`; `app::blog_posts` -> `/blog-posts`. Handler names do not affect the URL. | |
| 42 | -- Without a path string, `#[page]`, `#[route(GET)]`, `#[layout]`, `#[layer]` take the module path. `#[route(GET "./health")]` appends to it. Source: `REG/topcoat-router-0.10.0/docs/module.md`. | |
| 43 | - | |
| 44 | -### 2b. Explicit paths | |
| 45 | - | |
| 46 | -```rust | |
| 47 | -use topcoat::{Result, router::{Router, page, route}, view::{View, view}}; | |
| 48 | -#[page("/")] | |
| 49 | -async fn home() -> Result<impl View> { Ok(view! { <h1>"Home"</h1> }) } | |
| 50 | -#[route(GET "/api/health")] | |
| 51 | -async fn health() -> Result<&'static str> { Ok("ok") } | |
| 52 | -pub fn router() -> Router { Router::builder().page(home).route(health).build() } | |
| 53 | -``` | |
| 54 | - | |
| 55 | -Builder methods: `.page(f)`, `.route(f)`, `.layout(f)`, `.layer(f)`, `.discover()`, `.build()`. Source: `REG/topcoat-router-0.10.0/src/builder.rs`. | |
| 56 | - | |
| 57 | -### 2c. Attributes | |
| 58 | - | |
| 59 | -```rust | |
| 60 | -#[route(GET "/x")] // one method | |
| 61 | -#[route([GET, POST] "/x")] // several | |
| 62 | -#[route(* "/x")] // any method; specific-method routes win over `*` | |
| 63 | -#[page] // GET, module path | |
| 64 | -#[page("/x")] // GET, explicit | |
| 65 | -#[page(POST "/x")] // POST page | |
| 66 | -``` | |
| 67 | - | |
| 68 | -Route handlers return `Result<T>` where `T: AsyncIntoResponse`. Pages return `Result<impl View>`. Parameters: optional `cx: &Cx` and at most one body extractor, in either order. Overlapping methods at the same path fail at `.build()`. | |
| 69 | - | |
| 70 | -### 2d. Path params: `/{owner}/{repo}/info/refs` | |
| 71 | - | |
| 72 | -```rust | |
| 73 | -use topcoat::{Result, context::Cx, router::{path_param, route}, view::{View, view}}; | |
| 74 | -path_param!(owner); // unparsed: path_param::<Owner>(cx) -> &str (percent-decoded) | |
| 75 | -path_param!(repo); | |
| 76 | -path_param!(id: u64); // parsed with FromStr: path_param::<Id>(cx) -> Result<&u64, _> | |
| 77 | -path_param!(id2: u64, error = not_found); // error = not_found|bad_request|unauthorized|forbidden|redirect("/x") | |
| 78 | -path_param!(*rest); // catch-all: "{*rest}", path_param::<Rest>(cx) -> CatchAllSegments | |
| 79 | - | |
| 80 | -#[route(GET "/{owner}/{repo}/info/refs")] | |
| 81 | -async fn info_refs(cx: &Cx) -> Result<&'static str> { | |
| 82 | - let owner: &str = path_param::<Owner>(cx); | |
| 83 | - let repo: &str = path_param::<Repo>(cx); | |
| 84 | - let _ = (owner, repo); | |
| 85 | - Ok("ok") | |
| 86 | -} | |
| 87 | -``` | |
| 88 | - | |
| 89 | -- `path_param!(name)` generates `Name` (Pascal-cased). Name the parameter in the path as `{name}`. Reading a parameter the route did not capture panics. | |
| 90 | -- Module form: `module_param!(post_id: u64, error = bad_request);` in `src/app/posts/post_id.rs` serves `/posts/{post_id}` (the module adds one segment). `module_param!(*path)` for catch-all. | |
| 91 | -- Source: `REG/topcoat-router-macro-0.10.0/docs/path_param.md`, `.../docs/module_param.md`. | |
| 92 | - | |
| 93 | -### 2e. Query params | |
| 94 | - | |
| 95 | -```rust | |
| 96 | -use topcoat::{Result, context::Cx, router::{page, query_params}, view::{View, view}}; | |
| 97 | -#[query_params(error = bad_request)] | |
| 98 | -struct InfoRefsQuery { service: Option<String> } | |
| 99 | - | |
| 100 | -#[page] | |
| 101 | -async fn page(cx: &Cx) -> Result<impl View> { | |
| 102 | - let q = query_params::<InfoRefsQuery>(cx)?; // &InfoRefsQuery, memoized | |
| 103 | - Ok(view! { <p>(q.service.as_deref().unwrap_or(""))</p> }) | |
| 104 | -} | |
| 105 | -``` | |
| 106 | - | |
| 107 | -Derives `serde::Deserialize`. Works from any handler with `cx`. Source: `REG/topcoat-router-macro-0.10.0/docs/query_params.md`. | |
| 108 | - | |
| 109 | -### 2f. Registration summary | |
| 110 | - | |
| 111 | -- `module_router!()` (module-derived), `Router::builder()` (manual), `.discover()` (all explicit-path items), `.public_dir("./public")` / `.serve_dir("/p/{*f}", "./dir")` (feature `fs`, static files). | |
| 112 | - | |
| 113 | ---- | |
| 114 | - | |
| 115 | -## 3. Request access | |
| 116 | - | |
| 117 | -Import helpers from `topcoat::router::request`. All take `cx: &Cx`. | |
| 118 | - | |
| 119 | -| Need | Call | Returns | | |
| 120 | -|---|---|---| | |
| 121 | -| method | `method(cx)` | `&http::Method` | | |
| 122 | -| uri / query | `uri(cx)` | `&http::Uri` (`uri(cx).query()`) | | |
| 123 | -| headers | `headers(cx)` | `&HeaderMap` | | |
| 124 | -| content type | `content_type(cx)` | `Option<&str>` | | |
| 125 | -| all parts | `parts(cx)` | `&http::request::Parts` | | |
| 126 | -| client IP | `client_ip(cx)` | `Option<IpAddr>` (proxy-aware) | | |
| 127 | -| peer addr | `remote_addr(cx)` | `Option<SocketAddr>` (None on Unix sockets) | | |
| 128 | -| pre-rewrite | `original_uri(cx)`, `original_headers(cx)`, ... | same types | | |
| 129 | - | |
| 130 | -`client_ip` returns the direct peer unless `.trusted_proxies(TrustedProxies::new()...)` is set on the builder. `ForwardedHeader` variant names: **UNVERIFIED** (`REG/topcoat-router-0.10.0/src/proxy.rs`). | |
| 131 | - | |
| 132 | -Body extractors (one per handler): | |
| 133 | - | |
| 134 | -| Type | Behavior | | |
| 135 | -|---|---| | |
| 136 | -| `Body` (`topcoat::router::Body`) | Streaming. Not limited. `body.into_data_stream()` yields `Result<Bytes, BoxError>` | | |
| 137 | -| `Bytes` (`topcoat::router::request::Bytes`) | Buffered, body limit applies (413) | | |
| 138 | -| `String` | Buffered UTF-8, body limit applies | | |
| 139 | -| `Json<T>` (`topcoat::router::content::Json`) | Requires `Content-Type: application/json` (400 otherwise); malformed = 400 | | |
| 140 | -| `Form<T>` (`topcoat::router::content::Form`) | `x-www-form-urlencoded`. On GET/HEAD reads the query string | | |
| 141 | -| `RawForm` | Raw form bytes | | |
| 142 | -| `Multipart` (feature `multipart`) | **UNVERIFIED** field API (`REG/topcoat-router-0.10.0/docs/content/multipart.md`) | | |
| 143 | -| `Option<T>` | Wraps any extractor to accept an absent body | | |
| 144 | - | |
| 145 | -```rust | |
| 146 | -use topcoat::{Result, context::Cx, router::{Body, route, content::Form}}; | |
| 147 | - | |
| 148 | -#[route(POST "/{owner}/{repo}/git-receive-pack")] | |
| 149 | -async fn receive(cx: &Cx, body: Body) -> Result<&'static str> { let _ = (cx, body); Ok("") } | |
| 150 | - | |
| 151 | -#[derive(serde::Deserialize)] struct NewRepo { name: String } | |
| 152 | -#[route(POST "/repos")] | |
| 153 | -async fn create(Form(input): Form<NewRepo>) -> Result<&'static str> { let _ = input.name; Ok("created") } | |
| 154 | -``` | |
| 155 | - | |
| 156 | -Custom extractor: `impl FromRequest { async fn from_request(cx: &Cx, body: Body) -> Result<Self> }`. Delegate buffering to `Bytes` or `to_bytes(body, body_limit(cx))`. | |
| 157 | - | |
| 158 | ---- | |
| 159 | - | |
| 160 | -## 4. Responses and errors | |
| 161 | - | |
| 162 | -### 4a. Return types | |
| 163 | - | |
| 164 | -- `&'static str`, `String`, `Vec<u8>`, `Bytes`, `()` -> 200 with body. `StatusCode` -> empty body. `Body` -> as-is. `Json<T>` -> JSON. `Response` (`http::Response<Body>`) -> full control. | |
| 165 | -- Tuples: a leading `StatusCode`, `Parts`, or `Response<()>` sets the status; middle items are `IntoResponseParts` (`HeaderMap`, `[(K, V); N]`, `Extensions`, `Option<T>`); the last item is the body. Source: `REG/topcoat-router-0.10.0/src/response.rs`. | |
| 166 | - | |
| 167 | -```rust | |
| 168 | -use topcoat::{Result, router::{Body, HeaderMap, StatusCode, header, route}}; | |
| 169 | -#[route(GET "/{owner}/{repo}/info/refs")] | |
| 170 | -async fn info_refs() -> Result<(StatusCode, HeaderMap, Body)> { | |
| 171 | - let mut h = HeaderMap::new(); | |
| 172 | - h.insert(header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement".parse().unwrap()); | |
| 173 | - Ok((StatusCode::OK, h, Body::from("001e# service=git-upload-pack\n0000"))) | |
| 174 | -} | |
| 175 | -// or: Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], Body::from("ok"))) | |
| 176 | -``` | |
| 177 | - | |
| 178 | -### 4b. Streaming from a child process | |
| 179 | - | |
| 180 | -Pattern from `REG/topcoat-router-0.10.0/src/route/directory.rs` (`fn stream`): `Body::new(StreamBody::new(stream_of_frames))`. | |
| 181 | - | |
| 182 | -```rust | |
| 183 | -use std::process::Stdio; | |
| 184 | -use futures_util::TryStreamExt; | |
| 185 | -use http_body::Frame; | |
| 186 | -use http_body_util::StreamBody; | |
| 187 | -use tokio::process::Command; | |
| 188 | -use tokio_util::io::ReaderStream; | |
| 189 | -use topcoat::{Result, router::{Body, HeaderMap, StatusCode}}; | |
| 190 | - | |
| 191 | -async fn stream_child(mut cmd: Command) -> Result<(StatusCode, HeaderMap, Body)> { | |
| 192 | - let mut child = cmd.stdout(Stdio::piped()).spawn()?; // io::Error -> 500 | |
| 193 | - let stdout = child.stdout.take().expect("piped"); | |
| 194 | - let chunks = ReaderStream::new(stdout).map_ok(Frame::<bytes::Bytes>::data); | |
| 195 | - Ok((StatusCode::OK, HeaderMap::new(), Body::new(StreamBody::new(chunks)))) | |
| 196 | -} | |
| 197 | -``` | |
| 198 | - | |
| 199 | -- `bytes` is re-exported as `topcoat::router::request::Bytes`. | |
| 200 | -- Caveat: `child` drops when this function returns. With `kill_on_drop(true)` the process would die immediately, so either omit it or move the `Child` into the stream (a struct owning `child` and the stream). Without `kill_on_drop`, the child is not killed on client disconnect; it exits on SIGPIPE. **UNVERIFIED** that the router does anything here. | |
| 201 | -- Request body to child stdin: `tokio_util::io::StreamReader::new(body.into_data_stream().map_err(std::io::Error::other))` then `tokio::io::copy`. | |
| 202 | - | |
| 203 | -### 4c. Errors -> status codes | |
| 204 | - | |
| 205 | -`topcoat::Result<T>` = `Result<T, topcoat_core::error::Error>`. Any `std::error::Error` converts with `?`. Non-router errors become **500** (body `internal server error`). Handler panics also become 500 (`REG/topcoat-router-0.10.0/src/router.rs`, `handle`). | |
| 206 | - | |
| 207 | -| Constructor (`topcoat::router::error`) | Status | Ext on `Option`/`Result` | | |
| 208 | -|---|---|---| | |
| 209 | -| `not_found()` | 404 | `ok_or_not_found()` | | |
| 210 | -| `bad_request("msg")` | 400 | `ok_or_bad_request(..)` | | |
| 211 | -| `unauthorized()` | 401 | `ok_or_unauthorized()` | | |
| 212 | -| `forbidden()` | 403 | `ok_or_forbidden()` | | |
| 213 | -| `method_not_allowed(methods)` | 405 (+ `Allow`) | - | | |
| 214 | -| `content_too_large()` | 413 | - | | |
| 215 | -| `too_many_requests(secs)` | 429 | - | | |
| 216 | -| `service_unavailable(secs)` | 503 | - | | |
| 217 | -| `redirect(uri)` | 307 | `ok_or_redirect(uri)` | | |
| 218 | -| `redirect_permanent(uri)` | 308 | `ok_or_redirect_permanent(uri)` | | |
| 219 | -| `see_other(uri)` | 303 (`SeeOther`, usable as `Ok(..)`) | - | | |
| 220 | -| `internal_server_error(e)` | 500 | - | | |
| 221 | - | |
| 222 | -```rust | |
| 223 | -use topcoat::{Result, context::Cx, router::{error::{RouterErrorExt, forbidden, see_other, SeeOther}, route}}; | |
| 224 | -#[route(POST "/repos")] | |
| 225 | -async fn create(cx: &Cx) -> Result<SeeOther> { let _ = cx; Ok(see_other("/repos")) } | |
| 226 | -fn lookup() -> Result<&'static str> { Ok(None::<&str>.ok_or_not_found()?) } // 404 | |
| 227 | -fn gate() -> Result<()> { Err(forbidden().into()) } // 403 | |
| 228 | -``` | |
| 229 | - | |
| 230 | -Error boundary: a layout can wrap `slot` in `error_boundary(fallback: |error| ..., (slot))` and `error.downcast_ref::<NotFoundError>()` for a branded 404 (`REG/topcoat-0.10.0/docs/router.md`). | |
| 231 | - | |
| 232 | -Headers that must appear on error responses: `topcoat::router::response::response_headers(cx).append(name, value)` in a layer (`REG/topcoat-router-0.10.0/src/response/headers.rs`). | |
| 233 | - | |
| 234 | ---- | |
| 235 | - | |
| 236 | -## 5. Views, components, layouts, escaping, forms | |
| 237 | - | |
| 238 | -```rust | |
| 239 | -use topcoat::{Result, router::{Slot, layout}, view::{View, component, view, class}}; | |
| 240 | - | |
| 241 | -#[component] | |
| 242 | -async fn badge(label: &str, ok: bool) -> Result<impl View> { | |
| 243 | - Ok(view! { <span class=(class!("badge", "badge-ok" if ok))>(label)</span> }) | |
| 244 | -} | |
| 245 | - | |
| 246 | -#[layout] // or #[layout("/settings")] | |
| 247 | -async fn root(slot: Slot<'_>) -> Result<impl View> { | |
| 248 | - Ok(view! { | |
| 249 | - <!DOCTYPE html> | |
| 250 | - <html> | |
| 251 | - <body> | |
| 252 | - <h1>"Repo"</h1> | |
| 253 | - badge(label: "New", ok: true) // component call, named args | |
| 254 | - (slot) // inner page | |
| 255 | - </body> | |
| 256 | - </html> | |
| 257 | - }) | |
| 258 | -} | |
| 259 | - | |
| 260 | -#[component] | |
| 261 | -async fn list(items: Vec<String>) -> Result<impl View> { | |
| 262 | - Ok(view! { | |
| 263 | - <ul> | |
| 264 | - for item in items.iter() { <li>(item)</li> } | |
| 265 | - </ul> | |
| 266 | - }) | |
| 267 | -} | |
| 268 | -``` | |
| 269 | - | |
| 270 | -- Syntax: HTML tags with real names; void tags without closing; text in quotes; Rust expressions in parentheses (child and attribute position); `if`, `for`, `match`, `let` in markup; dynamic names `<(tag) (attr)="x">`; attribute names may contain `-`, `:`, `.`. Source: `REG/topcoat-view-macro-0.10.0/docs/view.md`, `.../docs/component.md`. | |
| 271 | -- Components take typed params; `Slot` for layouts; `cx: &Cx` optional, any order. | |
| 272 | -- **Escaping: yes, auto-escaped.** Text and `(expr)` interpolations escape `<`, `&`, etc., and attribute values are escaped too (tests in `REG/topcoat-view-0.10.0/src/buffer/handle.rs`, `.../src/html/attribute.rs`). Opt-out is `push_*_unescaped` and an `Unescaped<T>` wrapper (`REG/topcoat-view-0.10.0/src/string.rs`); facade path **UNVERIFIED**. Do not wrap commit messages, file names, or diffs in it. | |
| 273 | -- Links and forms: `href!(handler_fn)` builds URLs from the handler's registered path; `href!(mod::handler, mod::Param(1))` passes params. `<form method="post" action=(href!(publish))>` from source docs. HTML forms only send GET/POST. Source: `REG/topcoat-router-0.10.0/docs/href.md`. | |
| 274 | -- Active nav: `href.is_current(cx)` plus `class!`. | |
| 275 | -- A `#[page]` sets status or headers with a `StatusCode` or `HeaderMap` element inside `view!` (per `REG/topcoat-0.10.0/docs/router.md`; not run). For git endpoints use `#[route]` returning `Response` or a tuple. | |
| 276 | - | |
| 277 | ---- | |
| 278 | - | |
| 279 | -## 6. Cookies | |
| 280 | - | |
| 281 | -```rust | |
| 282 | -use topcoat::{cookie::RouterBuilderCookieExt, router::{Router, RouterBuilderDiscoverExt, module_router}}; | |
| 283 | -pub fn router() -> Router { module_router!().discover().cookies().build() } // required | |
| 284 | - | |
| 285 | -use topcoat::{Result, context::Cx, cookie::{Cookie, Cookies, cookies}, router::route}; | |
| 286 | -#[route(POST "/theme")] | |
| 287 | -async fn set_theme(cx: &Cx) -> Result<&'static str> { | |
| 288 | - let jar = cookies(cx); // &CookieJar; `Cookies` trait in scope | |
| 289 | - let _current = jar.get("theme"); // includes pending writes | |
| 290 | - jar.add(Cookie::build(("theme", "dark")).path("/").http_only(true).secure(true).build()); | |
| 291 | - jar.remove(Cookie::build(("theme", "")).path("/")); // same Path/Domain as when set | |
| 292 | - Ok("ok") | |
| 293 | -} | |
| 294 | -``` | |
| 295 | - | |
| 296 | -- `add`/`remove` accept `Into<Cookie>`, so `jar.add(("k", "v"))` works. | |
| 297 | -- Attributes: `Path`, `Domain`, `HttpOnly`, `Secure`, `SameSite`, `MaxAge`, set via builder or `cookie!("s" = "v"; Path = "/"; Secure; HttpOnly; SameSite = Lax)` (`topcoat::cookie::cookie`). | |
| 298 | -- Defaults/overrides on the jar: `cookies(cx).default_secure(true).default_http_only(true).default_same_site(SameSite::Lax).default_path("/")`. The `override_*` form forces a value. Wrap this in one helper `fn cookies(cx) -> impl Cookies`. | |
| 299 | -- `__Host-`: works. `jar.override_prefix_host()` stores `__Host-name`, forces `Secure`, `Path=/`, no `Domain`; reads strip the prefix. `override_prefix_secure()` gives `__Secure-`. Browsers reject `Secure` cookies over plain `http://` except on localhost (**UNVERIFIED** for your deployment). | |
| 300 | -- Signed/private: `signed_cookies(cx)`, `private_cookies(cx)` need a `Key` in app context: `.app_context(Key::generate())`. Calling them without a key panics. Persist the key across restarts. `cookies(cx).private(&key)` takes it explicitly. | |
| 301 | -- Typed store: `cookie_store::<T, _>(private_cookies(cx), "cart").parse_or_default().update(|t| ..).commit()?`. Nothing is written until `commit()`. | |
| 302 | -- Timing: writes must happen before the handler returns. After the jar is sealed (e.g. inside a streaming body) writes **panic**. Reads still work. | |
| 303 | -- Source: `REG/topcoat-0.10.0/docs/cookie.md`, `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`. | |
| 304 | - | |
| 305 | ---- | |
| 306 | - | |
| 307 | -## 7. Layers, body limit, timeouts, compression, origin | |
| 308 | - | |
| 309 | -### 7a. Layers | |
| 310 | - | |
| 311 | -```rust | |
| 312 | -use topcoat::{Result, context::Cx, router::{Body, Next, layer, response::Response}}; | |
| 313 | -#[layer("/api")] // no path = wraps every request | |
| 314 | -async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> { | |
| 315 | - next.run(cx, body).await | |
| 316 | -} | |
| 317 | -// register: Router::builder().layer(timing) | |
| 318 | -``` | |
| 319 | - | |
| 320 | -- Matching is by path prefix; outer = least specific. Same-path layers from `module_router!` are rejected; use explicit `.layer()` for ordering. A path-less layer also sees 404/405 as `Err` from `next.run`. Per-request values: `let cx = cx.with(value)`. | |
| 321 | -- Source: `REG/topcoat-router-0.10.0/docs/router.md`, `.../docs/layer.md`. | |
| 322 | - | |
| 323 | -### 7b. Tower (feature `tower`) | |
| 324 | - | |
| 325 | -`TowerLayer::new(mw).at("/api")`, `TowerRoute::any("/legacy/{*rest}", svc)`. Example: `TowerLayer::new(tower::timeout::TimeoutLayer::new(Duration::from_secs(5))).at("/api")`. Source: `REG/topcoat-router-0.10.0/docs/tower.md`. | |
| 326 | - | |
| 327 | -### 7c. Body limit | |
| 328 | - | |
| 329 | -- Default **2 MiB** (`REG/topcoat-router-0.10.0/src/body_limit.rs`), enforced by `Bytes`, `String`, `Json`, `Form`. Over it: **413**. `Body` is not limited. | |
| 330 | -- Override per path: | |
| 331 | - | |
| 332 | -```rust | |
| 333 | -use topcoat::router::{BodyLimit, module_router}; | |
| 334 | -let router = module_router!() | |
| 335 | - .layer(BodyLimit::max(64 << 20).at("/upload")) | |
| 336 | - .layer(BodyLimit::disable().at("/{owner}/{repo}/git-receive-pack")) // `{param}` in .at(): UNVERIFIED | |
| 337 | - .build(); | |
| 338 | -``` | |
| 339 | - | |
| 340 | -- `body_limit(cx)` returns the effective limit for custom extractors. | |
| 341 | - | |
| 342 | -### 7d. Timeouts | |
| 343 | - | |
| 344 | -- **No request, header, or idle timeout** found in router or serve code (grepped `REG/topcoat-router-0.10.0/src` and `REG/topcoat-0.10.0/src/serve.rs`). | |
| 345 | -- Only timeout: graceful shutdown, `RouterService::new(router).shutdown_timeout(d)`, default **30 s**. Request timeout, if wanted: TowerLayer with `TimeoutLayer` (7b). | |
| 346 | - | |
| 347 | -### 7e. Compression (on by default; matters for git) | |
| 348 | - | |
| 349 | -- Negotiated from `Accept-Encoding` after all layers (`REG/topcoat-router-0.10.0/src/compression.rs`). | |
| 350 | -- Streaming bodies of unknown size are always compressed. Excluded types: gRPC, images, SSE. `application/x-git-*-result` is not excluded, so pack responses would be compressed. | |
| 351 | -- Fix (applied): `Router::builder().compression(Compression::off())` on the public router, plus `crates/forge-web/src/compression.rs` (`ResponseCompression`, a tower layer). Topcoat has no per-route or content-type switch, so the policy is in that layer. See architecture.md 5.6. | |
| 352 | - | |
| 353 | -### 7f. Origin policy (CSRF guard, default on) | |
| 354 | - | |
| 355 | -- Before layers run: state-changing cross-origin browser requests and cross-origin WebSocket handshakes get **403**. Requests with no Origin or Fetch-Metadata headers pass, as do same-origin and direct navigations. | |
| 356 | -- Configure: `.origin_policy(OriginPolicy::new().trust_origins([...]))` (`REG/topcoat-router-0.10.0/src/origin.rs`). | |
| 357 | -- Git clients: expected to pass (no browser headers). **UNVERIFIED** per client. | |
| 358 | - | |
| 359 | ---- | |
| 360 | - | |
| 361 | -## 8. Shared application state (answer to the critical question) | |
| 362 | - | |
| 363 | -Shared state uses **app context**: values keyed by Rust type, registered once on the builder. Not an extension, and not a field on `Cx`. | |
| 364 | - | |
| 365 | -```rust | |
| 366 | -// src/state.rs | |
| 367 | -pub struct AppState { pub repos_root: std::path::PathBuf, pub db: std::sync::Arc<Db> } | |
| 368 | - | |
| 369 | -// router setup | |
| 370 | -pub fn router(state: AppState) -> topcoat::router::Router { | |
| 371 | - topcoat::router::module_router!().discover().app_context(state).build() | |
| 372 | -} | |
| 373 | - | |
| 374 | -// any handler/layout/component with cx | |
| 375 | -use topcoat::context::{Cx, app_context, try_app_context}; | |
| 376 | -pub fn state(cx: &Cx) -> &AppState { app_context::<AppState>(cx) } // panics if missing | |
| 377 | -pub fn maybe_state(cx: &Cx) -> Option<&AppState> { try_app_context(cx) } | |
| 378 | -``` | |
| 379 | - | |
| 380 | -- Bounds: `T: Any + Send + Sync + 'static`. Duplicate type panics at registration. | |
| 381 | -- Use `Arc` inside for mutable resources (pools, locks). | |
| 382 | -- Per-request values: `request_context::<T>(cx)` / `try_request_context`, added by a layer with `cx.with(value)`. | |
| 383 | -- Background work: `let cx = cx.clone()` and move it into `tokio::spawn`. A cloned `Cx` still reads app context, but cannot set response headers. | |
| 384 | -- Source: `REG/topcoat-0.10.0/docs/context.md`, `REG/topcoat-router-0.10.0/src/builder.rs` (`app_context`), `REG/topcoat-core-0.10.0/src/context/app_context.rs`. | |
| 385 | - | |
| 386 | ---- | |
| 387 | - | |
| 388 | -## 9. Serving | |
| 389 | - | |
| 390 | -```rust | |
| 391 | -topcoat::start(service: impl Into<RouterService>) -> io::Result<()> // HOST (127.0.0.1) / PORT (3000) | |
| 392 | -topcoat::serve(listener: impl Listener, service) -> io::Result<()> // Ctrl+C / SIGTERM | |
| 393 | -topcoat::serve_until(listener, service, signal: impl Future<Output = ()>) -> io::Result<()> | |
| 394 | -``` | |
| 395 | - | |
| 396 | -Source: `REG/topcoat-0.10.0/src/serve.rs`. `Listener` is implemented for `tokio::net::TcpListener` and `tokio::net::UnixListener` (`REG/topcoat-router-0.10.0/src/listener.rs`). `Router` converts to `RouterService` via `From`. | |
| 397 | - | |
| 398 | -Two routers (public TCP, internal Unix socket) with one shared shutdown: | |
| 399 | - | |
| 400 | -```rust | |
| 401 | -use tokio::net::{TcpListener, UnixListener}; | |
| 402 | -use topcoat::router::RouterService; | |
| 403 | - | |
| 404 | -#[tokio::main] | |
| 405 | -async fn main() -> std::io::Result<()> { | |
| 406 | - let _ = std::fs::remove_file("/run/forge-internal.sock"); // stale socket is not removed for you | |
| 407 | - let tcp = TcpListener::bind("0.0.0.0:8080").await?; | |
| 408 | - let unix = UnixListener::bind("/run/forge-internal.sock")?; | |
| 409 | - let (stop, _) = tokio::sync::watch::channel(()); | |
| 410 | - let (mut a, mut b) = (stop.subscribe(), stop.subscribe()); | |
| 411 | - tokio::try_join!( | |
| 412 | - topcoat::serve_until(tcp, RouterService::new(public::router(state())), async move { let _ = a.changed().await; }), | |
| 413 | - topcoat::serve_until(unix, RouterService::new(internal::router(state())), async move { let _ = b.changed().await; }), | |
| 414 | - async { tokio::signal::ctrl_c().await?; stop.send_replace(()); Ok::<(), std::io::Error>(()) }, | |
| 415 | - )?; | |
| 416 | - Ok(()) | |
| 417 | -} | |
| 418 | -``` | |
| 419 | - | |
| 420 | -- `serve` and `start` each install their own signal handlers, so use `serve_until` for several listeners. Unix listener: `remote_addr` and `client_ip` return `None`. Placeholders (`public::router`, `state()`) are not compiled. | |
| 421 | - | |
| 422 | ---- | |
| 423 | - | |
| 424 | -## 10. Assets and CSS | |
| 425 | - | |
| 426 | -```rust | |
| 427 | -use topcoat::{Result, asset::{Asset, asset}, router::{Slot, layout}, view::{View, view}}; | |
| 428 | -const APP_CSS: Asset = asset!("assets/app.css"); // relative to CARGO_MANIFEST_DIR; "./x" is relative to the source file | |
| 429 | -#[layout] | |
| 430 | -async fn root(slot: Slot<'_>) -> Result<impl View> { | |
| 431 | - Ok(view! { | |
| 432 | - <!DOCTYPE html> | |
| 433 | - <html><head><link rel="stylesheet" href=(APP_CSS)></head><body>(slot)</body></html> | |
| 434 | - }) | |
| 435 | -} | |
| 436 | -``` | |
| 437 | - | |
| 438 | -- Renders as `/_topcoat/assets/app-<hash>.css` (cache-safe). Build the bundle with `topcoat asset bundle` (or `topcoat dev`); output is `<target>/<profile>/assets`. | |
| 439 | -- Load before `.build()`: `module_router!().discover().assets(AssetBundle::load().unwrap()).build()`; use `AssetBundle::load_dir("dist/assets")` for a custom directory. Rendering a missing asset panics. | |
| 440 | -- Tailwind (feature `tailwind`): `build.rs` calls `topcoat::tailwind::BuildConfig::new().render().unwrap()`, then `tailwind::stylesheet!()` (`REG/topcoat-0.10.0/docs/tailwind.md`). Fixed-URL files: feature `fs`, `public_dir("./public")`. | |
| 441 | - | |
| 442 | ---- | |
| 443 | - | |
| 444 | -## 11. Testing without a socket | |
| 445 | - | |
| 446 | -```rust | |
| 447 | -use topcoat::router::{Body, Router, request::Request, to_bytes}; | |
| 448 | - | |
| 449 | -#[tokio::test] | |
| 450 | -async fn info_refs_ok() { | |
| 451 | - let router: Router = my_app::router(test_state()); | |
| 452 | - let req = Request::builder().method("GET") | |
| 453 | - .uri("/alice/repo/info/refs?service=git-upload-pack") | |
| 454 | - .body(Body::empty()).unwrap(); | |
| 455 | - let res = router.handle(req).await; // http::Response<Body> | |
| 456 | - assert_eq!(res.status(), 200); | |
| 457 | - let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); | |
| 458 | - assert!(!bytes.is_empty()); | |
| 459 | -} | |
| 460 | -``` | |
| 461 | - | |
| 462 | -- `Router::handle(&self, Request) -> Response`. Panics become 500. Needs no `serve` feature. | |
| 463 | -- `handle_with(request, values)` injects request-context values. `to_bytes` is exported from `topcoat::router`. | |
| 464 | - | |
| 465 | ---- | |
| 466 | - | |
| 467 | -## 12. Differences from 0.9 | |
| 468 | - | |
| 469 | -- `module_param!` is new in 0.10 (CHANGELOG: "Added: module_param! macro", PR #460). Grep found no `module_param` in `REG/topcoat-router-macro-0.9.0/src`. | |
| 470 | -- 0.10 changelog recommends the module router as the default example. | |
| 471 | -- Same names exist in 0.9 (existence checked only): `serve`, `serve_until`, `start`, `Router::handle`, `app_context`, `request_context`, `cookies`, `signed_cookies`, `cookie_store`. | |
| 472 | -- Not compared: `Cookies` trait, `Compression`, `OriginPolicy`, `TrustedProxies`, `session`. Use 0.10 source. Other 0.10 changelog items (CLI, runtime) are not relevant here. | |
| 473 | - | |
| 474 | ---- | |
| 475 | - | |
| 476 | -## 13. Forge notes | |
| 477 | - | |
| 478 | -1. Smart HTTP routes: `GET /{o}/{r}/info/refs?service=..` (query struct), `POST /{o}/{r}/git-upload-pack`, `POST /{o}/{r}/git-receive-pack`. Use explicit `#[route]` paths with `path_param!`. | |
| 479 | -2. Pack bodies: take `Body` (streaming). The 2 MiB default would reject pushes otherwise. Use `BodyLimit::disable()` or a large `max` on receive-pack. | |
| 480 | -3. Compression off on pack routes (7e). Serve pack output via `Body` from child stdout (4b). No request timeout by default (7d). | |
| 481 | -4. Repos root, config, and DB pool go in one `AppState` (section 8). | |
| 482 | -5. Web sessions: `private_cookies` with a persisted `Key`, `override_prefix_host()`, `HttpOnly`, `SameSite=Lax`. | |
| 483 | -6. Escape all user text (commit messages, paths). Never `Unescaped` for user data. | |
| 484 | -7. Internal API on a Unix socket via `serve_until` (section 9). `client_ip` is None there. | |
| 485 | - | |
| 486 | ---- | |
| 487 | - | |
| 488 | -## 14. UNVERIFIED | |
| 489 | - | |
| 490 | -- Facade path for `Unescaped<T>`. | |
| 491 | -- Per-route compression disable (only `Compression::off()` on the builder is confirmed). | |
| 492 | -- `BodyLimit::at` accepting `{param}` segments. | |
| 493 | -- `ForwardedHeader` / `TrustedProxies` variant names. | |
| 494 | -- Multipart field API. `session` API. | |
| 495 | -- Whether the router kills child processes on client disconnect (not tested). | |
| 496 | -- Whether a 401 from `unauthorized()` sends `WWW-Authenticate`. | |
| 497 | -- Exact Origin/Fetch-Metadata headers sent by git clients. | |
| 498 | -- `HeaderMap` inside `view!` for `#[page]` headers (documented, not run). | |
| 499 | -- Sections 8, 9, 11 snippets: derived from signatures, not compiled or run. |
docs/wave-b-interfaces.md| @@ -1,121 +0,0 @@ | ||
| 1 | -# Wave B: releases, LFS, signed commits, standby replication (contracts between workers) | |
| 2 | - | |
| 3 | -The global rules apply: | |
| 4 | -- No JS and no inline styles. | |
| 5 | -- No unwrap in non-test code. | |
| 6 | -- Tests use `tempfile::TempDir`. | |
| 7 | -- Never put cargo target dirs or big files in /tmp. | |
| 8 | -- New crates go in at their latest version via `cargo add`, recorded in the root `[workspace.dependencies]`. | |
| 9 | -- User values come after `--end-of-options`. | |
| 10 | - | |
| 11 | -Migration numbers are fixed: `0020_releases`, `0021_lfs`, `0022_signatures`, `0023_replication`. Keep `store/migrate.rs` sorted. | |
| 12 | - | |
| 13 | -## 1. Releases (migration 0020) | |
| 14 | -```sql | |
| 15 | -CREATE TABLE releases (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id), tag TEXT NOT NULL, | |
| 16 | - target_oid TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', draft INTEGER NOT NULL DEFAULT 0, | |
| 17 | - prerelease INTEGER NOT NULL DEFAULT 0, author_id TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, | |
| 18 | - published_at TEXT, UNIQUE (repo_id, tag)); | |
| 19 | -CREATE TABLE release_assets (id TEXT PRIMARY KEY, release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | |
| 20 | - name TEXT NOT NULL, size INTEGER NOT NULL, sha256 TEXT NOT NULL, content_type TEXT NOT NULL, | |
| 21 | - download_count INTEGER NOT NULL DEFAULT 0, uploader_id TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, | |
| 22 | - UNIQUE (release_id, name)); | |
| 23 | -``` | |
| 24 | -- **Storage:** | |
| 25 | - - Use the `object_store` crate (latest) with `LocalFileSystem` rooted at `<data_dir>/blobs`, behind a small `BlobStore` wrapper in forge-web (`put_stream`, `get_stream`, `delete`), so S3 can be added later. | |
| 26 | - - Keys are `assets/<asset id>`. | |
| 27 | - - Uploads stream to a temp object while computing sha256, capped at `[releases] max_asset_mb = 512`. | |
| 28 | -- **Tags:** | |
| 29 | - - Creating a release for a tag that doesn't exist creates it as a lightweight tag at the chosen branch's head, through the existing ref update path (`after_ref_update`). | |
| 30 | - - Deleting a release keeps the tag. | |
| 31 | -- **Pages** (Write to create, edit or delete; Read to view, and drafts are visible only to Write): | |
| 32 | - - `/{o}/{r}/releases`: list, newest first, with a "Latest" badge. | |
| 33 | - - `/releases/new`, `/releases/tag/{tag}`, `/releases/{id}/edit`, `/releases/{id}/delete`. | |
| 34 | - - Asset upload is a `multipart/form-data` POST, streamed. Check what Topcoat 0.10 offers for multipart, or use the `multer` crate on the raw body. | |
| 35 | - - Downloads: `/{o}/{r}/releases/download/{tag}/{name}`. Stream with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`, and increment the counter. | |
| 36 | -- **Other surfaces:** | |
| 37 | - - Source archives: link to the existing archive route if there is one. | |
| 38 | - - Webhook event `release` (`published`, `updated`, `deleted`). | |
| 39 | - - Repo home sidebar: "Latest release". | |
| 40 | -- **Cleanup:** | |
| 41 | - - `Store::delete_repo` deletes the release rows. | |
| 42 | - - The web layer deletes the blobs; the repo purge path must call it. | |
| 43 | - - Backups: the blobs dir is included in a backup set as `blobs/` (extend `backup/create.rs` and restore) **only if** it is simple. Otherwise document it in `docs/backup.md` as an rsync-safe directory (blobs are immutable; write temp, fsync, rename). | |
| 44 | - | |
| 45 | -## 2. Git LFS (migration 0021) | |
| 46 | -```sql | |
| 47 | -CREATE TABLE lfs_objects (repo_id TEXT NOT NULL REFERENCES repos(id), oid TEXT NOT NULL, size INTEGER NOT NULL, | |
| 48 | - created_at TEXT NOT NULL, PRIMARY KEY (repo_id, oid)); | |
| 49 | -``` | |
| 50 | -- **Storage:** content-addressed at `<data_dir>/lfs/<oid[0..2]>/<oid[2..4]>/<oid>`, shared across repos. Access is per repo through `lfs_objects`. Write temp, verify sha256 and size, fsync, rename. | |
| 51 | -- **Batch API:** `POST /{o}/{r}.git/info/lfs/objects/batch`, plus the path without `.git`. | |
| 52 | - - Content type `application/vnd.git-lfs+json`. | |
| 53 | - - Basic transfer only. Operation `download` needs Read; `upload` needs Write, and the repo must not be archived or a pull mirror. | |
| 54 | - - **Auth:** HTTP Basic with a personal access token, exactly like smart-HTTP (reuse its auth code), or anonymous for public reads. 401 sends `LFS-Authenticate: Basic realm="forge"`. | |
| 55 | - - **Response actions:** `href` points at `/{o}/{r}.git/info/lfs/objects/{oid}` (GET and PUT) with header `Authorization: RemoteAuth <token>`. | |
| 56 | - - The token is a short-lived (1h) HMAC derived from the instance key over (repo id, oid, op, expiry), so action requests don't need the PAT again. | |
| 57 | - - Uploads also get a `verify` action. | |
| 58 | - - **Validation:** oid is 64 lowercase hex; size is at most `[lfs] max_object_mb = 2048`. | |
| 59 | -- **Transfers and verify:** | |
| 60 | - - GET streams the file. | |
| 61 | - - PUT streams to temp with a hash check, then inserts the `lfs_objects` row. | |
| 62 | - - `POST .../objects/verify` checks that the row exists and the size matches. | |
| 63 | -- **Locks API:** not implemented. Return 404 for `/locks*`, which the client handles as "locking unsupported". | |
| 64 | -- **SSH:** the `git-lfs-authenticate <path> <download|upload>` exec in forge-ssh returns JSON `{href, header: {Authorization: "RemoteAuth <token>"}, expires_in}` with a repo-scoped token. The token is the same HMAC with op `any`, valid for both ops subject to the role check, so add a `lfs_token` helper in forge-core. | |
| 65 | -- **UI:** blob view detects LFS pointer files (`version https://git-lfs.github.com/spec/v1`) and shows "Stored with Git LFS (size)" with a download link to the media route `/{o}/{r}/media/{ref}/{path}`, which serves the LFS object, on the user-content domain if the raw route is served there. | |
| 66 | -- **Cleanup:** `delete_repo` removes rows. A GC of unreferenced files is an admin CLI, `admin lfs-gc`: a file with no row in any repo is deleted if it is older than 1 day. | |
| 67 | -- **Test:** the e2e uses the real `git lfs` binary only if it is installed; otherwise skip with a message. Unit and route tests cover the batch, PUT, GET and verify flows directly. | |
| 68 | - | |
| 69 | -## 3. Signed commit badges (migration 0022) | |
| 70 | -```sql | |
| 71 | -CREATE TABLE gpg_keys (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), fingerprint TEXT NOT NULL UNIQUE, | |
| 72 | - armored TEXT NOT NULL, emails TEXT NOT NULL /* json array from the key's uids */, created_at TEXT NOT NULL); | |
| 73 | -CREATE TABLE commit_signatures (repo_id TEXT NOT NULL, oid TEXT NOT NULL, status TEXT NOT NULL /* verified|unverified|unsigned|unknown_key|bad */, | |
| 74 | - kind TEXT /* ssh|gpg */, signer_user_id TEXT, key_fingerprint TEXT, checked_at TEXT NOT NULL, PRIMARY KEY (repo_id, oid)); | |
| 75 | -``` | |
| 76 | -- **SSH signatures:** | |
| 77 | - - Build an allowed-signers file under `<data_dir>/run/allowed_signers`, rewritten atomically whenever any SSH key is added or removed. Each line is `<user email> namespaces="git" <key type> <base64>`, for every SSH key of every user. Users' emails are on the users table. | |
| 78 | - - Verify with `git -c gpg.ssh.allowedSignersFile=<file> verify-commit --raw <oid>`. Its output gives the principal and the fingerprint; map the fingerprint back to `ssh_keys`. | |
| 79 | - - **Verified** means a good signature, the key belongs to user U, and the committer email equals U's email. | |
| 80 | -- **GPG signatures:** | |
| 81 | - - Use a forge-managed `GNUPGHOME=<data_dir>/gnupg` (0700). Import every `gpg_keys.armored` on add and remove the key on delete, via the `gpg` binary. | |
| 82 | - - If `gpg` is missing, the GPG status is `unknown_key` and the settings page says GPG verification is unavailable. | |
| 83 | - - Run `verify-commit` with `gpg.program` defaulted. | |
| 84 | - - **Verified** means a good signature by a key whose fingerprint is in `gpg_keys` for a user whose key emails include the committer email. | |
| 85 | -- **Cache:** results go in `commit_signatures`. When a user's SSH or GPG keys change, delete that user's cache rows (`signer_user_id`) **and** every row with status `unknown_key`. | |
| 86 | -- **UI:** | |
| 87 | - - Settings → "GPG keys" page: add (paste armored), list with fingerprint and emails, delete. | |
| 88 | - - Commit list and commit page show a badge: "Verified" (green, title shows signer and key) or "Unverified" (signed but unmatched or bad). Unsigned commits show nothing. | |
| 89 | - - Verification is per page, for at most the 50 commits shown, using the cache. Uncached commits are verified inline with a 5s total budget; anything left unchecked shows no badge and gets checked next time. | |
| 90 | - | |
| 91 | -## 4. Standby replication (migration 0023) | |
| 92 | -The standby is a **passive SSH host**: a dedicated unix account on another provider that holds bare repos and DB snapshots. It is not a running forge. At failover, `admin restore` is run from its data (see `docs/backup.md` and architecture §8.5). | |
| 93 | - | |
| 94 | -- **Config:** | |
| 95 | -```toml | |
| 96 | -[replication] | |
| 97 | -target = "" # "user@host:/srv/forge-standby" ; empty = disabled | |
| 98 | -ssh_port = 22 | |
| 99 | -identity_file = "" # private key for the standby account (0600) | |
| 100 | -known_hosts_file = "" # pinned host key; StrictHostKeyChecking=yes always | |
| 101 | -db_snapshot_minutes = 15 | |
| 102 | -reconcile_minutes = 60 | |
| 103 | -``` | |
| 104 | -- **Transport:** | |
| 105 | - - Use the system `ssh` binary with `-i identity -o IdentitiesOnly=yes -o UserKnownHostsFile=<file> -o StrictHostKeyChecking=yes -o BatchMode=yes -p port`. Git gets it through `GIT_SSH_COMMAND` built the same way. | |
| 106 | - - The remote base path is validated against `^/[A-Za-z0-9._/-]+$` with no `..`. | |
| 107 | - - Remote shell commands are built only from validated paths and fixed words; still quote each arg with single quotes. | |
| 108 | -- **Remote layout:** `<base>/repos/<repo id>.git`, `<base>/db/forge-<UTC ts>.db` (keep the newest 24), `<base>/db/latest -> forge-<ts>.db` (symlink, updated atomically with `ln -sfn` and `mv -T`), and `<base>/keys/` (never written by forge; the operator escrows the key). | |
| 109 | -- **Repo replication:** | |
| 110 | - - After every `after_ref_update` (debounced 5s per repo), and for every repo at each reconcile: | |
| 111 | - 1. `ssh … "git init --bare --quiet '<base>/repos/<id>.git'"` (idempotent); | |
| 112 | - 2. `git push --mirror --force ssh://user@host:port<base>/repos/<id>.git`; | |
| 113 | - 3. compute `refs_sha256` locally (`forge_git::backup::refs_checksum`) and remotely (`ssh … "git -C '<path>' for-each-ref --format='%(objectname) %(refname)'"`, then hash it the same way locally); a mismatch triggers a re-push, and a second mismatch records an error. | |
| 114 | - - Deleted repos: delete the remote repo dir at reconcile (`rm -rf` of exactly `<base>/repos/<id>.git` after validation). | |
| 115 | -- **DB:** every `db_snapshot_minutes`, `Store::snapshot_to` into `<data_dir>/run/replica-snap.db`, `verify_snapshot`, then `scp`-equivalent via `ssh … "cat > '<base>/db/.tmp-<ts>' && mv '<base>/db/.tmp-<ts>' '<base>/db/forge-<ts>.db'"`, streaming the file to stdin. Then update `latest` and prune to 24. | |
| 116 | -- **Status:** | |
| 117 | - - Table `replication_status(subject TEXT PRIMARY KEY /* 'db' or repo id */, last_ok_at TEXT, last_error TEXT, last_attempt_at TEXT, refs_sha256 TEXT)`. | |
| 118 | - - `/admin/replication` (admin): target, last DB snapshot shipped, a repos table (ok, lagging, error), a "Reconcile now" button (POST, CSRF), and audit `replication_reconcile_started`. | |
| 119 | - - The healthcheck reuses `[backup] healthcheck_url`? No: add an optional `[replication] healthcheck_url` pinged after each reconcile. | |
| 120 | -- **Test:** no real SSH in unit tests. Abstract the transport behind a trait `StandbyTransport { run(cmd) -> output, push_mirror(repo_path, remote) , upload(local, remote_path) }`, with a `LocalTransport` used in tests that maps the remote base to a local temp dir (it runs the same commands without ssh). The real `SshTransport` is unit-tested only for argument construction. | |
| 121 | -- **Docs:** a new section in `docs/backup.md`, "Standby replication", covering setting up the standby account (restricted key with `restrict` in authorized_keys), the known_hosts pin, and a failover runbook pointing at architecture §8.5. |
docs/wave-c-interfaces.md| @@ -1,248 +0,0 @@ | ||
| 1 | -# Wave C: orgs, email, wiki and polish (contract between workers) | |
| 2 | - | |
| 3 | -The global rules in `~/.cache/forge-orch/waveb-common.md` apply. Background research: | |
| 4 | -- `~/.cache/forge-orch/orgs-study.md`: every place where the owner is assumed to be a user. | |
| 5 | -- `~/.cache/forge-orch/email-study.md`: the im and iz mail designs. | |
| 6 | -- `~/.cache/forge-orch/polish-audit.md`: what exists already. | |
| 7 | - | |
| 8 | -## Shared-file rules (many workers in parallel) | |
| 9 | -- **Migrations:** each worker owns exactly one number. Add one line to the array in `crates/forge-core/src/store/migrate.rs`, re-reading the file first. | |
| 10 | - | |
| 11 | - | Migration | Owner | | |
| 12 | - |---|---| | |
| 13 | - | 0025_orgs | O1 | | |
| 14 | - | 0026_mail | M1 | | |
| 15 | - | 0027_wiki | W1 | | |
| 16 | - | 0028_discussions | D1 | | |
| 17 | - | 0029_snippets | S1 | | |
| 18 | - | 0030_ui_prefs | T1 | | |
| 19 | - | 0031_advisories | A1 | | |
| 20 | - | 0032_repo_transfers | P1 | | |
| 21 | -- **Store code:** put it in a new `crates/forge-core/src/store/<feature>.rs`. Don't grow `store/mod.rs`, apart from the `mod` line and changes your section explicitly requires. | |
| 22 | -- **Shared files** (`pages/mod.rs` route lists, `views/mod.rs` (shell and repo tabs), `names.rs` reserved words, `lib.rs`, `style/main.scss`): small hunks, and re-read the file right before every edit. | |
| 23 | -- **SCSS:** each feature appends its own clearly commented block at the end of `style/main.scss`, or a partial if the file already uses partials. | |
| 24 | -- **Audit:** every state-changing action writes an audit entry, as the existing features do. Use new action names, and grep the tests that assert audit counts. One of them asserts a total (48); if you add actions, update it. | |
| 25 | -- **No JS anywhere.** Use forms and links only. | |
| 26 | -- **Tests:** unit tests in the store module, plus one e2e file per feature in `crates/forge-web/tests/<feature>_e2e.rs` using `tests/common/mod.rs`. | |
| 27 | - | |
| 28 | -## O1: organizations core (wave 1) | |
| 29 | -**Model:** an org is a row in `users` with `kind = 'org'`. That keeps one namespace (`/{name}` works for both), keeps `repos.owner_id → users(id)`, and leaves every `owner_name` join unchanged. | |
| 30 | - | |
| 31 | -**Migration `0025_orgs.sql`:** | |
| 32 | -```sql | |
| 33 | -ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','org')); | |
| 34 | -ALTER TABLE users ADD COLUMN description TEXT; -- only if no such column exists (check 0002_profile) | |
| 35 | -CREATE TABLE org_settings (org_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 36 | - default_repo_role TEXT NOT NULL DEFAULT 'read' CHECK (default_repo_role IN ('none','read','write')), | |
| 37 | - members_can_create_repos INTEGER NOT NULL DEFAULT 1); | |
| 38 | -CREATE TABLE org_members (org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 39 | - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 40 | - role TEXT NOT NULL CHECK (role IN ('owner','member')), created_at TEXT NOT NULL, | |
| 41 | - PRIMARY KEY (org_id, user_id)); | |
| 42 | -CREATE TABLE teams (id TEXT PRIMARY KEY, org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 43 | - name TEXT NOT NULL, name_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', | |
| 44 | - role TEXT NOT NULL CHECK (role IN ('read','triage','write','maintain','admin')), | |
| 45 | - all_repos INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (org_id, name_folded)); | |
| 46 | -CREATE TABLE team_members (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 47 | - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (team_id, user_id)); | |
| 48 | -CREATE TABLE team_repos (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 49 | - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, PRIMARY KEY (team_id, repo_id)); | |
| 50 | -``` | |
| 51 | -**Org rows:** | |
| 52 | -- They get an unusable `password_hash` (`"!org"`). | |
| 53 | -- **Hard rule:** an org can never authenticate. Every authentication path must reject `kind = 'org'` explicitly: | |
| 54 | - - password login, sessions, tokens, SSH keys (including the add-key paths), LFS tokens, TOTP; | |
| 55 | - - admin `create-user`/`reset-password`/`set-username` (refuse orgs); | |
| 56 | - - the CI actor. | |
| 57 | - Add a test for each. | |
| 58 | -- The `User` struct gets `kind: AccountKind { User, Org }`, with a `is_org()` helper. | |
| 59 | - | |
| 60 | -**`store/orgs.rs`:** | |
| 61 | -- `create_org(name, creator_id)`: the creator becomes owner, and the org gets the same name validation as a username, plus `org_settings` defaults. | |
| 62 | -- `org_members`, `add_org_member`, `set_org_member_role`, and `remove_org_member`, which refuses to remove the last owner. | |
| 63 | -- Teams CRUD, team members and team repos. | |
| 64 | -- `orgs_for_user`. | |
| 65 | -- `delete_org`, which refuses while the org owns repos. | |
| 66 | -- `org_repo_role(org_id, user_id, repo_id) -> Option<Role>`: owner gives admin. Otherwise the max of `default_repo_role` (for members) and the roles of the user's teams that include the repo, or that have `all_repos`. | |
| 67 | - | |
| 68 | -**authz:** extend the existing authorization input (see `crates/forge-core/src/authz*` and its callers in `access.rs`) with `org_role: Option<Role>`, and load it in the same place collaborators are loaded. The effective role is the max of the collaborator role and the org role. An owner check (`owner_id == actor.id`) never matches an org. | |
| 69 | - | |
| 70 | -**Audit every existing site that compares `owner_id == user.id`** (orgs-study lists them: `internal.rs:526/820`, `repo.rs:282`, `admin_users.rs`, `notify.rs`) and route it through the effective role instead. | |
| 71 | - | |
| 72 | -**Notifications:** wherever "the repo owner" is a recipient, an org owner expands to the org's owners. | |
| 73 | - | |
| 74 | -**User deletion:** refuse to delete a user who is the last owner of any org. Show the message on the admin page and the CLI. | |
| 75 | - | |
| 76 | -**Tests:** | |
| 77 | -- the role math; | |
| 78 | -- auth refusal for orgs; | |
| 79 | -- the last-owner rules; | |
| 80 | -- a store-level `repo_by_owner_and_name` for an org-owned repo. | |
| 81 | - | |
| 82 | -## O2: organizations UI (wave 2, after O1) | |
| 83 | -**Routes:** the prefix `org` is already reserved. | |
| 84 | -- `GET/POST /org/new`. Allowed for everyone, unless the config `[orgs] creation = "admin"`; the default is `"all"`. | |
| 85 | -- `/org/{name}/members`: add by username (owners only), change role, remove, leave. | |
| 86 | -- `/org/{name}/teams`, `/org/{name}/teams/{team}`: members, repos, role. | |
| 87 | -- `/org/{name}/settings`: description, default role, members-can-create, delete. | |
| 88 | - | |
| 89 | -**Other pages:** | |
| 90 | -- `/{name}` (`profile.rs`): when the target is an org, show the repos visible to the viewer, the members list and the teams count. | |
| 91 | -- New repo form: an owner `<select>` listing self plus the orgs where the viewer may create. | |
| 92 | -- Transfer to an org is handled by P1, not here. O2 does not edit `repo_rename.rs`. | |
| 93 | -- `explore`: orgs are listed separately or marked. | |
| 94 | -- Admin users page: shows the kind. | |
| 95 | - | |
| 96 | -**Webhooks:** the payload `repository.owner` stays the name string. Add `"owner_kind": "user"|"org"`. | |
| 97 | - | |
| 98 | -**E2e:** create an org, add a member, create a team with write on a repo, then: | |
| 99 | -- that member can push over HTTPS; | |
| 100 | -- a non-member can't see a private org repo (404); | |
| 101 | -- the org can't log in. | |
| 102 | - | |
| 103 | -## M1: mail core (wave 1). Follow iz's outbox and im's token design (see the study). | |
| 104 | -- **Crate:** `lettre` (latest) with `smtp-transport, builder, tokio1-rustls, ring, webpki-roots, pool`, and `default-features = false`. | |
| 105 | -- **`0026_mail.sql`:** | |
| 106 | - - `mail_settings`, a singleton row: `host, port, security ('starttls'|'tls'|'none'), username, password_sealed BLOB, from_addr, from_name, enabled`; | |
| 107 | - - `mail_outbox (id, idem_key TEXT UNIQUE, to_addr, subject, body_text, created_at, attempts, next_attempt_at, lease_until, sent_at, last_error)`; | |
| 108 | - - `users.email_verified_at TEXT`. | |
| 109 | -- **Sealing:** the password is sealed with the existing instance key (forge.key, XChaCha20-Poly1305). Find the existing secret-sealing helper (used by repo secrets, CI and mirrors) and reuse it. Debug impls redact the password. | |
| 110 | -- **Transport:** a `Mailer` trait (`async fn send(&self, msg) -> Result`). The implementations are: | |
| 111 | - - `SmtpMailer` (lettre, 30 s timeout); | |
| 112 | - - `LogMailer`, which writes to `<data_dir>/mail-log/` and is enabled by `[mail] transport = "log"` in forge.toml, for dev and tests; | |
| 113 | - - a test `FakeMailer` that collects messages. | |
| 114 | -- **`enqueue(idem_key, to, subject, body)`:** idempotent. The background worker leases rows and sends them, retrying up to 5 times with exponential backoff (1m, 5m, 30m, 2h, 12h), then gives up. Wire it into boot the same way the mirror worker is. | |
| 115 | -- **Admin page `/admin/mail`:** the settings form, plus a "send test mail" button, and a list of the last 50 outbox rows (status and error, never the body). | |
| 116 | -- **Templates:** plain text, built in one module `crates/forge-web/src/mail/templates.rs` as functions returning `(subject, body)`. i18n will hook in later through that module only. | |
| 117 | -- **Tests:** outbox idempotency, the retry schedule, the lease, sealing round-trip, and Debug redaction. | |
| 118 | - | |
| 119 | -## M2: mail flows (wave 2, after M1) | |
| 120 | -- **Password reset** (`/forgot`, `/reset/{token}`): | |
| 121 | - - Tokens are 32 random bytes, stored as sha256, with a 60-minute TTL. | |
| 122 | - - Single use, enforced by an atomic `UPDATE … SET used_at=? WHERE token_hash=? AND used_at IS NULL AND expires_at>? RETURNING user_id`. | |
| 123 | - - Mail goes only to **verified** addresses. | |
| 124 | - - `/forgot` always shows the same response. The lookup and enqueue happen in a spawned task, so response timing doesn't reveal whether the account exists. | |
| 125 | - - A successful reset revokes all sessions and is audited. | |
| 126 | - - Rate-limit with the existing `throttle.rs`. | |
| 127 | -- **Email verification:** setting or changing the email (profile and setup) sends a verify link (24 h TTL, single use) and clears `email_verified_at`. The settings page shows the status and a "resend" button. | |
| 128 | -- **Invites:** `registration = "invite"`. Admins invite by email from `/admin/users`, with a 7-day single-use token. `/register?invite=` creates the account with that email pre-verified. | |
| 129 | -- **Notification emails:** | |
| 130 | - - A per-user setting, on by default, that applies only to verified addresses. | |
| 131 | - - Send for the existing notification kinds. Read `notify.rs` for the list. | |
| 132 | - - Each mail has a link and the threading headers `Message-ID`, `In-Reply-To` and `References`, keyed per issue or PR. | |
| 133 | - - The idempotency key is the notification id. | |
| 134 | -- **E2e** with `transport = "log"` or `FakeMailer`: the reset flow end to end, token reuse refused, an expired token refused, an unverified address gets no mail, and the invite flow. | |
| 135 | - | |
| 136 | -## W1: wiki (wave 1) | |
| 137 | -- **Storage:** a bare repo at `<repo path minus .git>.wiki.git`, created lazily on the first page save. Add a helper next to `repo_path` in `forge-git/src/repo.rs`. | |
| 138 | -- **`0027_wiki.sql`:** `repos.wiki_enabled INTEGER NOT NULL DEFAULT 1`. | |
| 139 | -- **Pages:** | |
| 140 | - - `/{o}/{r}/wiki` shows `Home`, or "create the first page"; | |
| 141 | - - `/{o}/{r}/wiki/{page}`; | |
| 142 | - - `/{o}/{r}/wiki/{page}/edit`; | |
| 143 | - - `/{o}/{r}/wiki/-/new`; | |
| 144 | - - `/{o}/{r}/wiki/-/pages`; | |
| 145 | - - `/{o}/{r}/wiki/{page}/history`. | |
| 146 | - Pages are `<Title-With-Dashes>.md` files on `main`. Markdown is rendered with the existing renderer, and wiki links `[[Page]]` resolve. | |
| 147 | -- **Web edits:** commit via plumbing with the forge-git builder (`hash-object -w --stdin`, `mktree` or `read-tree`/`update-index` on a temp index, `commit-tree`, `update-ref` with the old value). The author is the user's name and email. A conflicting concurrent edit gets an error page, not a lost write. | |
| 148 | -- **Permissions:** read needs repo Read. Edit and push need repo Write. Hide the tab when the wiki is disabled. | |
| 149 | -- **Git access:** | |
| 150 | - - Clone and push over HTTPS at `/{o}/{r}.wiki.git` (and `/{o}/{r}.wiki`), and over SSH at `o/r.wiki.git`. | |
| 151 | - - Extend `forge-ssh/src/parse.rs` and `git_http.rs` resolution. | |
| 152 | - - Repo names must not end in `.wiki`; add that to `names.rs`. | |
| 153 | - - Pre-receive applies to wiki pushes, but skips the default-branch protections. | |
| 154 | -- **Coverage:** | |
| 155 | - - repo delete and trash move the wiki too; | |
| 156 | - - backup bundles and verify include the wiki when present (`verify.rs` hard-codes `repos/{id}.bundle`, so add `repos/{id}.wiki.bundle`); | |
| 157 | - - restore and `admin fsck` handle it too; | |
| 158 | - - replication carries it, so read `replication/` and extend it the same way as the main repo; | |
| 159 | - - mirrors don't mirror wikis (document this). | |
| 160 | -- **E2e:** | |
| 161 | - - create a page on the web, clone the wiki over HTTPS and see it, push a change and see it on the web; | |
| 162 | - - a Read-only user can't edit; | |
| 163 | - - backup then verify includes the wiki. | |
| 164 | - | |
| 165 | -## D1: discussions (wave 1) | |
| 166 | -- **`0028_discussions.sql`:** | |
| 167 | - - `repos.discussions_enabled INTEGER NOT NULL DEFAULT 0`; | |
| 168 | - - `discussions (id, repo_id, number, category ('general','ideas','qa','announcements'), title, body, author_id, answer_comment_id, locked, created_at, updated_at)`. Number them with the same per-repo counter as issues and PRs if one exists, or their own counter otherwise; check how issues number. | |
| 169 | - - `discussion_comments (id, discussion_id, parent_id NULL /* one level of replies */, author_id, body, created_at, updated_at, deleted_at)`. | |
| 170 | -- **Pages:** | |
| 171 | - - `/{o}/{r}/discussions`, with a category filter; | |
| 172 | - - `/{o}/{r}/discussions/new`; | |
| 173 | - - `/{o}/{r}/discussions/{n}`, showing replies, "mark as answer" in `qa` (author or Triage+), lock (Triage+), edit and delete own. | |
| 174 | - - Only Maintain+ may post in `announcements`. | |
| 175 | -- Reuse reactions and the markdown renderer. Mentions notify, the same way issues do. | |
| 176 | -- A repo settings toggle and a repo tab. | |
| 177 | -- **E2e:** create, reply, nested reply, answer, lock refusal, and the toggle hiding the tab. | |
| 178 | - | |
| 179 | -## S1: snippets (wave 1) | |
| 180 | -- **`0029_snippets.sql`:** | |
| 181 | - - `snippets (id, owner_id, title, description, visibility ('public','unlisted','private'), created_at, updated_at)`; | |
| 182 | - - `snippet_files (snippet_id, name, content, position)`; | |
| 183 | - - `snippet_revisions (id, snippet_id, author_id, created_at, files_json)`. | |
| 184 | - Limits: 10 files, 1 MiB total. | |
| 185 | -- **Routes:** use a reserved prefix, adding `snippets` to `names.rs` if needed. | |
| 186 | - - `/snippets`: your snippets plus public ones; | |
| 187 | - - `/snippets/new`; | |
| 188 | - - `/snippets/{id}`, with syntax highlighting via the existing highlighter, and markdown rendered for `.md`; | |
| 189 | - - `/snippets/{id}/edit`; | |
| 190 | - - `/snippets/{id}/revisions`; | |
| 191 | - - raw on the user-content origin at `/-/snippets/{id}/{file}`. Private snippets use the same token mechanism as private raw files. | |
| 192 | -- `unlisted` means viewable by link, not listed. | |
| 193 | -- **E2e:** create, edit (revision recorded), private snippet hidden from others (404), raw served with the right content type. | |
| 194 | - | |
| 195 | -## T1: theme and language preferences (wave 1) | |
| 196 | -- **`0030_ui_prefs.sql`:** | |
| 197 | - - `users.theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto','light','dark'))`; | |
| 198 | - - `users.language TEXT NOT NULL DEFAULT 'auto'`. | |
| 199 | -- **Theme:** `views/mod.rs` `shell` emits `<html data-theme="light|dark">` when the preference isn't auto. | |
| 200 | - - SCSS: the existing dark palette under `@media (prefers-color-scheme: dark)` must apply only to `:root:not([data-theme="light"])`, and also under `:root[data-theme="dark"]`. The same goes for the highlight CSS. | |
| 201 | - - The settings page `/settings/appearance` has theme and language selects. I18 provides the language list; until then, offer only `auto` and `en`. | |
| 202 | - - Anonymous visitors get a footer form POSTing to `/-/theme`, which sets a `forge_theme` cookie (Lax, 1 year). The cookie is honoured when no user is signed in. | |
| 203 | -- **E2e:** the setting persists, the html attribute is rendered, and the anonymous cookie works. | |
| 204 | - | |
| 205 | -## I18: i18n infrastructure (wave 1). Follow im's design (`~/Documents/Code/Rust/im/crates/im-web/src/i18n.rs`). | |
| 206 | -- `crates/forge-web/src/i18n.rs` defines `Lang { En, Tr }`, a `Key` enum, and an exhaustive `t(lang, key) -> &'static str`, plus `fmt` helpers for strings with arguments. | |
| 207 | -- **Language selection:** the user's `language` column (T1 adds it; until it lands, read it defensively or wait), else `Accept-Language`, else En. `shell` sets `<html lang>`. | |
| 208 | -- **Wave 1 scope:** the shell, nav, footer, login, setup, register, settings pages, and the 404 and error pages. Turkish translations must be real Turkish (the user is Turkish). | |
| 209 | -- Expose `Lang` to page code through a `Cx` helper (`lang(cx)`). | |
| 210 | -- **Wave 3 (later):** one worker per page group converts the rest. Don't do that now. | |
| 211 | -- **Tests:** an exhaustiveness test (every Key non-empty in every Lang), Accept-Language parsing, and an e2e that the Turkish login page renders Turkish. | |
| 212 | - | |
| 213 | -## A1: security advisories (wave 1). Opt-in, because it downloads data. | |
| 214 | -- **Config:** `[advisories] enabled = false`, `ecosystems = ["crates.io","npm","Go"]`, `refresh_hours = 24`. | |
| 215 | -- **Data:** OSV per-ecosystem dumps from `https://osv-vulnerabilities.storage.googleapis.com/<ecosystem>/all.zip`, downloaded with the existing HTTP client setup (timeouts, size cap 500 MiB) into `<data_dir>/advisories/<eco>/`. Parse only `id`, `summary`, `aliases`, `severity`, `affected[].package.{ecosystem,name}`, `affected[].ranges` (SEMVER and ECOSYSTEM events) and `affected[].versions`. | |
| 216 | -- **`0031_advisories.sql`:** | |
| 217 | - - `advisories (id, ecosystem, package, summary, severity, ranges_json, versions_json, modified)`; | |
| 218 | - - `repo_dependencies (repo_id, ecosystem, package, version, manifest_path)`; | |
| 219 | - - `repo_alerts (repo_id, advisory_id, package, version, state ('open','dismissed','fixed'), created_at, updated_at)`. | |
| 220 | -- **Scanning:** after a push to the default branch (hook into `post_update.rs`), read `Cargo.lock`, `package-lock.json` (v2 and v3 `packages`) and `go.sum` from the tree (root and up to depth 3). Recompute dependencies, open new alerts, and mark disappeared ones fixed. Rescan all repos after each advisory refresh. | |
| 221 | -- **Version matching:** semver for crates.io and npm, and Go pseudo-versions as semver. Use the `semver` crate. | |
| 222 | -- **Page:** `/{o}/{r}/security` (Write+ only) lists alerts with dismiss and reopen. New alerts notify the repo's admins. | |
| 223 | -- **Tests:** parsers for all three lockfiles, range matching (introduced, fixed, last_affected), and an e2e with a hand-written OSV fixture dir and a pushed `Cargo.lock`. | |
| 224 | - | |
| 225 | -## P1: repo transfer acceptance (wave 2, after O1) | |
| 226 | -- **`0032_repo_transfers.sql`:** `repo_transfers (repo_id PRIMARY KEY, from_owner_id, to_owner_id, initiated_by, created_at, expires_at)`. | |
| 227 | -- **Flow:** | |
| 228 | - - A transfer now creates a pending row with a 7-day expiry and notifies the target (the user, or an org's owners). | |
| 229 | - - The target accepts or declines on `/{o}/{r}/transfer` or the notification. Accepting runs the existing `transfer_repo`. | |
| 230 | - - The initiator may cancel. | |
| 231 | - - Admin transfers skip acceptance. | |
| 232 | -- **Org targets:** the target may be an org where the actor is an owner (this moved here from O2). For an org target, any owner of the org may accept. | |
| 233 | -- **E2e:** initiate, the repo is unchanged until accepted, accept moves it and the redirect works, decline, expiry, and a transfer to an org. | |
| 234 | - | |
| 235 | -## Round-2 follow-ups (from the round-1 reports) | |
| 236 | -- **F-A (advisories):** | |
| 237 | - - add a Security tab in the repo tabs, shown to Write+ only and only when advisories are enabled; | |
| 238 | - - new alerts create a notification for the repo admins (owner, or the org owners plus admin collaborators) through `notify.rs`, plus a test. | |
| 239 | -- **F-D (discussions):** | |
| 240 | - - the notifications inbox lists `discussion_notifications` (mentions, and replies to your discussion); | |
| 241 | - - lock is enforced in the store too, not only in the pages; | |
| 242 | - - deleting a user reassigns their discussions and comments to the ghost user, the same way issues do (`store/user_delete.rs`); | |
| 243 | - - tests for each. | |
| 244 | -- **F-W (wiki):** | |
| 245 | - - CI job tokens can read (clone) the wiki of the job's repo; | |
| 246 | - - wiki pushes write an audit entry; | |
| 247 | - - document in `docs/` (wherever mirrors are documented) that mirrors don't include wikis; | |
| 248 | - - add a backup round-trip test that includes a wiki. |
docs/webhooks-interfaces.md| @@ -1,67 +0,0 @@ | ||
| 1 | -# Webhooks (contract between workers) | |
| 2 | - | |
| 3 | -The global rules from the earlier interface docs apply: | |
| 4 | -- No JS and no inline styles; no unwrap in non-test code. | |
| 5 | -- Tests use `tempfile::TempDir`. | |
| 6 | -- Never put cargo target dirs or big files in /tmp. | |
| 7 | -- Every new crate is added at its **latest** version with `cargo add` and recorded in the root `[workspace.dependencies]`. | |
| 8 | - | |
| 9 | -## Model | |
| 10 | -- A repo webhook has: | |
| 11 | - - a URL (https, or http only when the config allows it); | |
| 12 | - - a secret, encrypted at rest with the instance key the same way TOTP secrets are; | |
| 13 | - - a content type (always `application/json`); | |
| 14 | - - an event list: `push`, `pull_request`, `issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `ping`; | |
| 15 | - - an `active` flag. | |
| 16 | -- Managing hooks needs Admin on the repo. | |
| 17 | -- **Deliveries** are rows in the DB: | |
| 18 | - - the request: headers and body. The headers are queued without `X-Forge-Delivery` or the signature; after each attempt the stored headers are replaced with the ones actually sent; | |
| 19 | - - the response: status, the first 64 KiB of the body, and timing; | |
| 20 | - - `attempt`, a state (`pending` | `ok` | `failed` | `gave_up`), `next_attempt_at`, and a GUID. | |
| 21 | -- **Retries** use backoff 1m, 5m, 30m, 2h, 6h, then `gave_up`. "Redeliver" in the UI creates a new delivery with the same payload. | |
| 22 | -- **Headers:** | |
| 23 | - - `X-Forge-Event: <event>`, `X-Forge-Delivery: <guid>`; | |
| 24 | - - `X-Forge-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)>`; | |
| 25 | - - `User-Agent: forge-hookshot/<version>`. | |
| 26 | -- **Payloads** are our own JSON. We deliberately don't copy the GitHub or Gitea schemas, but a similar shape is fine: | |
| 27 | - - `repository {id, owner, name, full_name, private, html_url, clone_url}`, `sender {id, username}`; | |
| 28 | - - push: `ref, before, after, commits[{id, message, author{name,email}, timestamp, url}] (max 20), total_commits, compare_url`; | |
| 29 | - - pull_request: `action (opened|closed|reopened|merged|synchronize|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), number, pull_request {title, body, state, merged, base, head, html_url}`; the label, assignee and milestone actions add `label {name, color}`, `assignee {username}` or `milestone {title}` at the top level, as the issues event does; | |
| 30 | - - issues: `action (opened|closed|reopened|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), issue {number, title, body, state, html_url}`, plus `label {name, color}`, `assignee {username}` or `milestone {title}` for those actions; | |
| 31 | - - issue_comment: `action (created|edited|deleted), issue {…}, comment {id, body, html_url}`; | |
| 32 | - - pull_request_review: `action submitted, review {id, state, body}, pull_request {…}`. | |
| 33 | - | |
| 34 | -## SSRF guard (mandatory) | |
| 35 | -- Resolve the host once, check every resolved address, then connect to exactly that checked address. | |
| 36 | - - In reqwest, use a custom `dns::Resolve` that filters addresses, so a DNS rebind can't bypass the check. | |
| 37 | -- Reject: loopback, private (RFC1918, fc00::/7), link-local (169.254/16, fe80::/10), CGNAT 100.64/10, unspecified, multicast, broadcast, documentation ranges, IPv4-mapped IPv6 of any of these, and 0.0.0.0/8. | |
| 38 | -- Config `[webhooks] allowed_hosts = []` (exact hostnames or CIDRs) whitelists internal targets; the default is empty. | |
| 39 | -- **No redirects.** Timeouts: 10s connect, 20s total. Response bodies are read up to 64 KiB. | |
| 40 | -- The URL must have no userinfo, and its port must be 1–65535. | |
| 41 | -- `allow_http = false` by default. | |
| 42 | - | |
| 43 | -## Delivery worker | |
| 44 | -- A tokio task started at boot. Each loop it claims due deliveries (`state = 'pending' AND next_attempt_at <= now`), sends them with bounded concurrency (4), and records the result. | |
| 45 | -- A 2xx response means `ok`. | |
| 46 | -- It is woken by a `Notify` when a new delivery is queued, so the first attempt is immediate; otherwise it polls every 30s. | |
| 47 | -- Deliveries older than 14 days are purged daily by the same task. | |
| 48 | - | |
| 49 | -## Integration points | |
| 50 | -- Enqueue through the sender-aware helpers in `crates/forge-web/src/webhooks/emit.rs`. Each one builds its event's payload, looks up the sender, and hands off to the generic `emit(state, repo, sender, event, action, payload)`. That function finds the active hooks subscribed to the event and inserts a delivery for each. Nothing here fails the caller; errors are logged. | |
| 51 | - - `Sender` says who made the change. `lookup_sender(state, actor_id)` builds one; an empty id means a system change and is sent as `"sender": null`. | |
| 52 | - - `emit_ref_update(…)`: `push`, plus `create`/`delete` for branch and tag creation and deletion. Only `refs/heads/*` and `refs/tags/*` produce events. | |
| 53 | - - `emit_pull(…)`: `pull_request` (for example, on PR create in `pages/compare.rs`). | |
| 54 | - - `emit_item_action(…)`: `pull_request` or `issues` action events, reloaded from the store. Used for close, reopen, merge and `synchronize`. | |
| 55 | - - `emit_issue(…)`: `issues` create (`pages/issues_list.rs`). | |
| 56 | - - `emit_label_changes(…)` and `emit_assignee_changes(…)`: one `labeled`/`unlabeled` or `assigned`/`unassigned` event per changed label (matched by name) or username. `pull` picks the `pull_request` event for a pull request and `issues` for an issue. | |
| 57 | - - `emit_milestone_change(…)`: `demilestoned` for the milestone before and `milestoned` for the one after, given their titles; nothing when they match. Same `pull` choice. | |
| 58 | - - `emit_comment(…)`: `issue_comment` create, edit and delete. | |
| 59 | - - `emit_review(…)`: `pull_request_review` submit. | |
| 60 | -- Call sites: `post_update.rs`, `pulls_sync.rs`, `pages/{compare,issue_view,issue_parts,issue_meta,issues_list,pulls_merge,pull_reviews}.rs`. The label, assignee and milestone saves in `pages/issue_meta.rs` run for issues and pull requests alike. | |
| 61 | -- A "ping" event is sent by `emit_ping(state, hook)` when a hook is created and from the "Test" button. It ignores the hook's subscriptions. | |
| 62 | - | |
| 63 | -## UI (repo settings, a "Webhooks" section) | |
| 64 | -- List hooks with their last delivery status. | |
| 65 | -- Create and edit form: URL, secret (write-only; leave it blank to keep the current one), event checkboxes, active. | |
| 66 | -- Delete hooks. | |
| 67 | -- Hook detail page: the last 50 deliveries, each expandable with `<details>` to show the request headers and body and the response; a Redeliver button and a Test (ping) button. |