steid

@jamesgill /

1//! The composition root: every dependency the running application has, wired once.
2//!
3//! It lived in `main.rs` until 2026-09-10, which meant it lived outside the library and
4//! nothing could boot the application but the binary. That is why fifteen HTTP-level
5//! facts — the security headers on a 404, the CSRF 403, `/metrics` answering 404 rather
6//! than 401 — were only ever asserted by hand in a browser. [`build`] is the whole of
7//! the fix: it returns the same builder `main` used to assemble inline, so a test can
8//! serve it on `127.0.0.1:0` and ask real questions of it. See `tests/http.rs`.
9//!
10//! **The `app_context` chain is append-only.** Everything a request can reach is on it,
11//! in one place, and the order does not matter — so a new dependency goes on the end
12//! with the sentence explaining why it is process-wide rather than per-request, and
13//! nothing above it moves.
14
15use sqlx::SqlitePool;
16use topcoat::{
17 asset::{AssetBundle, RouterBuilderAssetExt},
18 cookie::RouterBuilderCookieExt,
19 router::{Router, RouterBuilder, RouterBuilderDiscoverExt},
20 session::{RouterBuilderSessionExt, SessionConfig},
21};
22
23use crate::{
24 application::{
25 AppConfig, port::GitStorage, sweep_interrupted_imports, sweep_orphan_directories,
26 },
27 domain::SetupToken,
28 infrastructure::{
29 self,
30 cat_file::CatFilePool,
31 git::{DiskGitStorage, GitFeatures},
32 repository::{SqliteOrgRepo, SqliteRepoRepo},
33 web::{
34 context::SetupState,
35 csrf::CsrfSecret,
36 import::ImportQueue,
37 instance::StartedAt,
38 rate_limit::{
39 ArchiveLimiter, BackupLimiter, GitAuthLimiter, InstanceLimiter, MetricsLimiter,
40 RateLimiter, ResetLimiter, SearchLimiter,
41 },
42 session_cookie::InsecureCookieTokenStore,
43 },
44 },
45};
46
47/// Assembles the router: routes discovered, assets served, and every process-wide
48/// value a handler can ask its `Cx` for.
49///
50/// `setup` is the claim token, and `Some` only while the instance is unclaimed — a
51/// claimed installation has no token in context for a claim attempt to match against.
52/// The decision of *which* token (generated, or the operator's) and the announcement
53/// that goes with it belong to the caller, because they are the only things here that
54/// print.
55///
56/// Returns the builder rather than a [`Router`] so a caller may append one more
57/// `app_context` — a test's clock, say — without this growing a parameter for it.
58pub async fn build(
59 mut config: AppConfig,
60 pool: SqlitePool,
61 assets: AssetBundle,
62 setup: Option<SetupToken>,
63) -> RouterBuilder {
64 // Defensively, and not only because `main` already took it: the only copy of the
65 // claim secret the running application holds must be the `SetupToken` in
66 // `SetupState`, which is redacted in `Debug` and absent entirely once claimed. The
67 // config goes into app context whole, one `dbg!` away from standard error.
68 config.setup_token = None;
69
70 let insecure_cookies = config.insecure_cookies;
71
72 let mut builder = Router::builder()
73 .assets(assets)
74 .cookies()
75 .sessions(session_config(insecure_cookies))
76 .discover()
77 .app_context(config)
78 .app_context(pool)
79 // One limiter for the process, shared by every request. Constructing it per
80 // request would count each attempt against an empty window and limit nothing.
81 .app_context(RateLimiter::default())
82 // A budget of its own for the git transport's Basic auth, so a clone cannot
83 // spend a person's sign-in allowance and a flood of clones cannot lock them
84 // out of the login form.
85 .app_context(GitAuthLimiter::default())
86 // And one for archive downloads, the most expensive anonymous request there is.
87 .app_context(ArchiveLimiter::default())
88 // And one for the unauthenticated `/api/instance` poll, so a provisioner
89 // watching a machine boot cannot spend the owner's sign-in allowance.
90 .app_context(InstanceLimiter::default())
91 // When this process started. Read per request it would report zero every time,
92 // which is why the operator surface's uptime is fixed here at the composition
93 // root rather than anywhere nearer the route.
94 .app_context(StartedAt::now())
95 // And one bounding how fast the operator token can be guessed at `/metrics`. Only
96 // requests that failed the bearer check count against it, so a flood cannot starve
97 // an authorized scraper.
98 .app_context(MetricsLimiter::default())
99 // And one for instance backups, which are more expensive again — a full copy of
100 // the database plus every repository — on their own hourly window.
101 .app_context(BackupLimiter::default())
102 // And one for `/auth/reset/{token}`, which is a page a person reloads: sharing
103 // the sign-in budget meant a few loads of a mailed link locked them out of the
104 // login form.
105 .app_context(ResetLimiter::default())
106 // And one for code search: one open-ended `git grep` per request, on its own
107 // budget so a crawler walking `?q=` cannot spend anyone's sign-in allowance.
108 .app_context(SearchLimiter::default())
109 // Likewise one queue: a semaphore constructed per request would let every
110 // submission through and cap nothing.
111 .app_context(ImportQueue::default())
112 // And one pool of `git cat-file` processes for the whole instance. This is the
113 // composition-root decision the Milestone 5 amendment to 0006 said had to be
114 // made before the first batch adapter existed: built per request, the pool
115 // would spawn and kill a pair of processes per request and pool nothing.
116 .app_context(std::sync::Arc::new(CatFilePool::new()))
117 // And one highlight cache, for the same reason: highlighting is tens of
118 // milliseconds of regex over a large file, and a cache built per request would
119 // remember nothing. `highlight::cache()` owns it so that the README renderer —
120 // a plain function with no request context — reaches the same one this puts in
121 // app context, rather than a second cache with its own budget.
122 .app_context(infrastructure::highlight::cache().clone())
123 // And one merge cache. A pull request's page asks `git merge-tree` whether its
124 // branches still merge on every view, and `--write-tree` writes objects nothing
125 // references — so an unchanged pair of branches was paying a full three-way
126 // merge, and leaving a fresh set of garbage behind, per refresh.
127 .app_context(std::sync::Arc::new(
128 infrastructure::merge_cache::MergeCache::new(),
129 ))
130 // And the counters, which every adapter already reaches through
131 // `metrics::metrics()`. This is the same `Arc`, put where `/metrics` can read it:
132 // the route asks its `Cx` like every other route rather than reaching for a
133 // process-wide value, so nothing but the module itself knows the global exists.
134 .app_context(infrastructure::metrics::metrics().clone())
135 // One `git --version`, once. Merging a pull request needs `git merge-tree
136 // --write-tree`, which arrived in git 2.38 — and before that the subcommand
137 // meant something else entirely, so an old git succeeds at the wrong thing
138 // rather than failing. Read here so the merge box can be absent rather than
139 // broken: a check that only fired at merge time is a check nobody sees until
140 // they have already written the pull request.
141 .app_context(GitFeatures::detect().await)
142 // And one CSRF secret, minted here for the life of the process. Built per
143 // request it would mint a different token on every page and verify nothing;
144 // there is no configured session secret to derive it from, so a restart
145 // invalidates whatever forms are already open. See `web::csrf`.
146 .app_context(CsrfSecret::generate());
147
148 if let Some(token) = setup {
149 builder = builder.app_context(SetupState(token));
150 }
151
152 builder
153}
154
155/// The three boot sweeps: run once, before a single request is served.
156///
157/// Together they reconcile facts that live outside the database's transactions — a row
158/// that outlived the task writing it, a repository created before Steid set a git
159/// option, a directory nothing claims. Each one is **not fatal**: a sweep that fails
160/// says so and boot continues, because every failure it can have is a worse page rather
161/// than a reason to refuse to serve.
162///
163/// It prints, which nothing else in this module does — but only when it actually did
164/// something, and it is `main` that calls it. [`build`] stays silent so a test that
165/// boots the router does not have to.
166pub async fn sweep_on_boot(config: &AppConfig, pool: &SqlitePool) {
167 // Before a single request is served, and before anything is spawned. An import runs
168 // in a background task, so every one that was running when this process's
169 // predecessor stopped is dead and its row still says otherwise. Sweeping here is
170 // safe precisely because of the timing: there is no live import to mark as failed.
171 match sweep_interrupted_imports(&SqliteRepoRepo::new(pool.clone())).await {
172 Ok(0) => {}
173 Ok(swept) => println!("steid: {swept} interrupted import(s) marked as failed"),
174 // Not fatal. A stuck row is a repository showing the wrong panel, which is worse
175 // than this line and much better than refusing to boot.
176 Err(error) => eprintln!("steid: interrupted imports could not be swept: {error}"),
177 }
178
179 // Beside the import sweep, and for the same kind of reason: a fact that lives outside
180 // the database and cannot be fixed by a migration. `git init --bare` leaves
181 // `core.logAllRefUpdates` off, so every repository created before Steid started
182 // setting it keeps no reflog, and the pusher it is served with has nowhere to land.
183 //
184 // Runs on **every** boot, which is cheap because it reads before it writes: one
185 // short-lived `git config --get` per repository on disk, and a write only for one
186 // that lacks the key. A hundred repositories is a hundred local file reads once at
187 // startup, so tracking "already swept" somewhere would cost more to get right than it
188 // saves.
189 match DiskGitStorage::new(config.data_dir.clone())
190 .enable_reflogs()
191 .await
192 {
193 Ok(0) => {}
194 Ok(fixed) => println!("steid: reflog enabled on {fixed} bare repository/repositories"),
195 // Not fatal, exactly like the import sweep: a repository this could not fix is
196 // one whose pushes stay anonymous, which is not a reason to refuse to serve.
197 Err(error) => eprintln!("steid: reflogs could not be swept: {error}"),
198 }
199
200 // Third of the boot sweeps, and the one that reconciles the two halves of a
201 // repository: a row and a directory cannot share a transaction, so a create, a rename
202 // or an import that died between them leaves a directory nothing claims. It is
203 // invisible until somebody tries to re-use the name, at which point `init_bare`
204 // refuses to adopt it and the refusal names nothing useful.
205 //
206 // **After** the import sweep, deliberately: that one only fails rows and leaves them
207 // in place, so an interrupted import's directory still has a row and is not an orphan.
208 // And before the first request, like the others, so nothing is half-written by a task
209 // that is still running.
210 match sweep_orphan_directories(
211 std::time::SystemTime::now(),
212 &SqliteOrgRepo::new(pool.clone()),
213 &SqliteRepoRepo::new(pool.clone()),
214 &DiskGitStorage::new(config.data_dir.clone()),
215 )
216 .await
217 {
218 // Each one it moved has already logged its own path, which is the point: an
219 // orphan left no other trace.
220 Ok(_) => {}
221 // Not fatal, like the other two. A directory that could not be moved is a name
222 // that stays blocked, which is where it already was.
223 Err(error) => {
224 eprintln!("steid: orphaned repository directories could not be swept: {error}")
225 }
226 }
227}
228
229/// Builds the session configuration.
230///
231/// The hardened default requires a trustworthy origin for its `Secure` cookie;
232/// browsers disagree about whether plain-HTTP localhost qualifies, and where it
233/// doesn't the cookie is dropped silently and every page renders signed out.
234///
235/// Says nothing: the warning that goes with the insecure store is `main`'s, printed
236/// beside the other boot-time warnings rather than from inside the builder — a test
237/// booting this on localhost would otherwise print it on every run.
238fn session_config(insecure_cookies: bool) -> SessionConfig {
239 if insecure_cookies {
240 SessionConfig::builder()
241 .token_store(InsecureCookieTokenStore::new())
242 .build()
243 } else {
244 SessionConfig::default()
245 }
246}