OXYGENHUB
# 0ee5c3fb32eb oxygenhub / crates / hub / src / protocol.rs
28.8 KiB · Rust · last changed Push protection: refuse a push that adds what looks like a secret 17 days ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! The wire protocol, embedded (hub spec §3.3, §5.4): `oxy-server`'s
19bb31ff Andy McCall ↶ 2
//! `Server` over a backend that resolves `owner/name`, authorises reads
3
//! and pushes by account role and token scope, and records who pushed.
d23464c0 Andy McCall ↶ 4
19bb31ff Andy McCall ↶ 5
use crate::auth::{self, Access, Role};
6
use crate::db::{self, accounts::Account};
d23464c0 Andy McCall ↶ 7
use crate::repos::{RepoName, Repos};
19bb31ff Andy McCall ↶ 8
use crate::Shared;
d23464c0 Andy McCall ↶ 9
use axum::body::Bytes;
10
use axum::extract::{Path, State};
11
use axum::http::{HeaderMap, Method, StatusCode};
12
use axum::response::{IntoResponse, Response};
62d6e969 Andy McCall ↶ 13
use oxy_core::repo::{PushReport, Repo};
19bb31ff Andy McCall ↶ 14
use oxy_server::{Backend, Endpoint, Failure};
d23464c0 Andy McCall ↶ 15
use std::sync::Arc;
16
17
pub struct HubBackend {
18
    repos: Arc<Repos>,
66fd9d6b Andy McCall ↶ 19
    /// Wikis, reached as `OWNER/NAME.wiki` (hub spec §6.11).
20
    wikis: Arc<Repos>,
d23464c0 Andy McCall ↶ 21
    pool: db::Pool,
19bb31ff Andy McCall ↶ 22
    /// Site tokens from `hub.toml`: full access, for bootstrapping.
23
    site_tokens: Vec<String>,
d23464c0 Andy McCall ↶ 24
    runtime: tokio::runtime::Handle,
18e381ea Andy McCall ↶ 25
    /// The application, set once it exists (the backend is built first).
26
    app_hint: Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>>,
3eb491e9 Andy McCall ↶ 27
    /// The hub's key, which countersigns when it accepted an update a key
28
    /// it certified signed (oxygenhub#241 step 4c, Oxygen spec §14.7).
29
    acceptor: Option<Arc<(oxy_core::identity::Identity, oxy_core::identity::SecretKey)>>,
0ee5c3fb Andy McCall ↶ 30
    /// Push protection's scan budget (#268, `[limits]`).
31
    scan_budget: crate::push_protection::Budget,
32
    /// Repositories whose push protection scan stopped early, by id: the
33
    /// note goes on the push log entry of the push that moves references
34
    /// (the parts of a long push before it move none).
35
    scan_notes: Arc<std::sync::Mutex<std::collections::HashMap<i64, String>>>,
d23464c0 Andy McCall ↶ 36
}
37
19bb31ff Andy McCall ↶ 38
/// Who a request's credential resolved to.
39
enum Caller {
40
    Anonymous,
41
    /// A site token from the configuration.
42
    Site,
43
    Token(Box<Account>, Box<db::accounts::Token>),
fd3db644 Andy McCall ↶ 44
    /// A running OxygenFlow job: reads its own repository and the subrepos
45
    /// its state pins, nothing else (hub spec §13.2).
42fa9554 Andy McCall ↶ 46
    Job {
47
        repo_id: i64,
fd3db644 Andy McCall ↶ 48
        also: Vec<i64>,
42fa9554 Andy McCall ↶ 49
    },
19bb31ff Andy McCall ↶ 50
}
51
d23464c0 Andy McCall ↶ 52
impl HubBackend {
66fd9d6b Andy McCall ↶ 53
    pub fn new(
54
        repos: Arc<Repos>,
55
        wikis: Arc<Repos>,
56
        pool: db::Pool,
57
        site_tokens: Vec<String>,
58
        runtime: tokio::runtime::Handle,
59
    ) -> HubBackend {
0ee5c3fb Andy McCall ↶ 60
        HubBackend {
61
            repos,
62
            wikis,
63
            pool,
64
            site_tokens,
65
            runtime,
66
            app_hint: Arc::new(std::sync::Mutex::new(None)),
67
            acceptor: None,
68
            scan_budget: crate::push_protection::Budget::default(),
69
            scan_notes: Arc::default(),
70
        }
18e381ea Andy McCall ↶ 71
    }
72
0ee5c3fb Andy McCall ↶ 73
    /// Scan pushes for secrets within `budget` (#268).
74
    pub fn with_scan_budget(mut self, budget: crate::push_protection::Budget) -> Self {
75
        self.scan_budget = budget;
76
        self
77
    }
78
79
    /// Who pushes, for the push log: the account (id 0 for a site token)
80
    /// and, for an agent's token, the agent's name.
81
    fn pusher(&self, headers: &HeaderMap) -> (Option<(i64, String)>, String) {
82
        match self.caller(headers) {
83
            Ok(Caller::Token(account, token)) => {
84
                (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() })
85
            }
86
            Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()),
87
            _ => (None, String::new()),
88
        }
89
    }
90
3eb491e9 Andy McCall ↶ 91
    /// Countersign accepted updates by keys this hub certified with `identity`.
92
    pub fn with_acceptor(mut self, identity: oxy_core::identity::Identity, secret: oxy_core::identity::SecretKey) -> Self {
93
        self.acceptor = Some(Arc::new((identity, secret)));
94
        self
95
    }
96
18e381ea Andy McCall ↶ 97
    /// The slot the application registers itself in after construction.
98
    pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> {
99
        self.app_hint.clone()
19bb31ff Andy McCall ↶ 100
    }
101
102
    fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T {
103
        tokio::task::block_in_place(|| self.runtime.block_on(f))
d23464c0 Andy McCall ↶ 104
    }
105
106
    fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> {
107
        let pool = self.pool.clone();
19bb31ff Andy McCall ↶ 108
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
d23464c0 Andy McCall ↶ 109
            Ok(Some(r)) => Ok(r),
d0fbea2e Andy McCall ↶ 110
            // A transferred repository still answers to its old name (#108).
111
            Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) {
112
                Ok(Some(r)) => Ok(r),
113
                Ok(None) => Err(not_found()),
114
                Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
115
            },
d23464c0 Andy McCall ↶ 116
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
117
        }
118
    }
119
19bb31ff Andy McCall ↶ 120
    fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> {
121
        let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) };
122
        if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) {
123
            return Ok(Caller::Site);
d23464c0 Andy McCall ↶ 124
        }
19bb31ff Andy McCall ↶ 125
        let hash = auth::token_hash(presented);
126
        let pool = self.pool.clone();
42fa9554 Andy McCall ↶ 127
        if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
128
            let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?;
129
            let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
130
            let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?;
131
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
fd3db644 Andy McCall ↶ 132
            return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() });
42fa9554 Andy McCall ↶ 133
        }
559c3920 Andy McCall ↶ 134
        // An agent's grant token (keyless agents, #241): its owner's
135
        // account, as an agent's token, writing when the grant lets its
136
        // keys move references. Revoking the grant stops it at once.
137
        let grant_token = presented.starts_with(crate::keyless::GRANT_TOKEN_PREFIX);
138
        let token = if grant_token {
139
            let grant = self.block(db::accounts::agent_grant_by_token(&pool, &hash)).map_err(internal)?;
140
            let Some(g) = grant.filter(|g| g.live(db::now_ms())) else {
141
                return Err(Failure(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into()));
142
            };
143
            let _ = self.block(db::accounts::touch_agent_grant(&pool, g.id));
144
            Some(db::accounts::Token {
145
                id: 0,
146
                account_id: g.account_id,
147
                name: g.agent.clone(),
148
                hash: String::new(),
149
                scopes: if g.scope.split(',').any(|s| s.trim() == "refs") { "repo:read,repo:write" } else { "repo:read" }.into(),
150
                repos: String::new(),
151
                created_ms: g.created_ms,
152
                expires_ms: Some(g.expires_ms),
153
                last_used_ms: g.last_used_ms,
154
                renew_ms: None,
155
                agent: true,
156
            })
157
        } else {
158
            self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?
159
        };
19bb31ff Andy McCall ↶ 160
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
161
        if token.expired(db::now_ms()) {
162
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
163
        }
164
        let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?;
165
        let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
166
        if account.banned {
167
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
168
        }
6a178055 Andy McCall ↶ 169
        if account.disabled {
170
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
559c3920 Andy McCall ↶ 171
        }
172
        if !grant_token {
173
            let _ = self.block(db::accounts::touch_token(&pool, token.id));
6a178055 Andy McCall ↶ 174
        }
19bb31ff Andy McCall ↶ 175
        Ok(Caller::Token(Box::new(account), Box::new(token)))
d23464c0 Andy McCall ↶ 176
    }
177
}
178
19bb31ff Andy McCall ↶ 179
fn internal(e: sqlx::Error) -> Failure {
180
    Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
181
}
182
d23464c0 Andy McCall ↶ 183
fn not_found() -> Failure {
184
    Failure(StatusCode::NOT_FOUND, "no such repository".into())
185
}
186
187
impl Backend for HubBackend {
188
    fn open(&self, name: &str) -> Result<Repo, Failure> {
d0fbea2e Andy McCall ↶ 189
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 190
        // `NAME.wiki` is the repository's wiki (§6.11); the row knows the
191
        // current name, so a transferred repository still answers to its
192
        // old one (#108).
193
        let (parent, is_wiki) = crate::wiki::parent_of(&parsed);
194
        let row = self.row(&parent)?;
d0fbea2e Andy McCall ↶ 195
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 196
        let store = if is_wiki { &self.wikis } else { &self.repos };
3eb491e9 Andy McCall ↶ 197
        let repo = store.open(&name).map_err(|e| match e {
d23464c0 Andy McCall ↶ 198
            crate::repos::RepoError::NotFound => not_found(),
199
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
3eb491e9 Andy McCall ↶ 200
        })?;
201
        Ok(match &self.acceptor {
202
            Some(a) => repo.with_acceptor(a.0.clone(), a.1.clone()),
203
            None => repo,
d23464c0 Andy McCall ↶ 204
        })
205
    }
206
19bb31ff Andy McCall ↶ 207
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
d23464c0 Andy McCall ↶ 208
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 209
        let (parent, _) = crate::wiki::parent_of(&parsed);
210
        let row = self.row(&parent)?;
19bb31ff Andy McCall ↶ 211
        let caller = self.caller(headers)?;
fd3db644 Andy McCall ↶ 212
        if let Caller::Job { repo_id, also } = &caller {
213
            // Only reads: of the job's own repository, and of the subrepos its
214
            // state pins that its pusher could read when the job was claimed.
42fa9554 Andy McCall ↶ 215
            return match access {
fd3db644 Andy McCall ↶ 216
                oxy_server::Access::Read if *repo_id == row.id || also.contains(&row.id) => Ok(()),
42fa9554 Andy McCall ↶ 217
                oxy_server::Access::Read => Err(not_found()),
218
                oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())),
219
            };
220
        }
19bb31ff Andy McCall ↶ 221
        let (role, scope_ok) = match &caller {
42fa9554 Andy McCall ↶ 222
            Caller::Job { .. } => unreachable!(),
19bb31ff Andy McCall ↶ 223
            Caller::Site => (Some(Role::Admin), true),
224
            Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true),
225
            Caller::Token(account, token) => {
226
                let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?;
227
                let needed = match access {
228
                    oxy_server::Access::Read => {
229
                        token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")
230
                    }
231
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
232
                };
d0fbea2e Andy McCall ↶ 233
                let current = format!("{}/{}", row.owner, row.name);
66fd9d6b Andy McCall ↶ 234
                let covered = token.covers(&parsed.to_string()) || token.covers(&current) || token.covers(&parent.to_string());
235
                (role, needed && covered)
19bb31ff Andy McCall ↶ 236
            }
237
        };
238
        // An unreadable repository does not exist for the caller (hub spec §4.4).
239
        if !auth::roles::allows(role, Access::Read) {
d23464c0 Andy McCall ↶ 240
            return Err(not_found());
241
        }
6e7dfa0a Andy McCall ↶ 242
        // A wiki is made, empty and unbound, when someone who may write to
243
        // its repository first reaches it, so the first page can be pushed
244
        // as well as written on the web (§6.11); the push gives it its id.
245
        let (_, is_wiki) = crate::wiki::parent_of(&parsed);
246
        let may_write = match &caller {
247
            Caller::Site => true,
248
            Caller::Token(_, token) => token.has_scope("repo:write") || token.has_scope("repo:admin"),
249
            _ => false,
250
        };
251
        if is_wiki && may_write && auth::roles::allows(role, Access::Write) && !row.archived {
252
            if let Some(name) = RepoName::new(&row.owner, &row.name) {
253
                if !self.wikis.exists(&name) {
254
                    self.wikis
255
                        .create(&name, None)
256
                        .map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, format!("make the wiki: {e}")))?;
257
                }
258
            }
259
        }
d23464c0 Andy McCall ↶ 260
        match access {
19bb31ff Andy McCall ↶ 261
            oxy_server::Access::Read if scope_ok => Ok(()),
262
            oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())),
263
            oxy_server::Access::Push => {
264
                if matches!(caller, Caller::Anonymous) {
265
                    return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into()));
266
                }
267
                if !auth::roles::allows(role, Access::Write) {
268
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
d0fbea2e Andy McCall ↶ 269
                }
270
                if row.archived {
271
                    return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into()));
19bb31ff Andy McCall ↶ 272
                }
273
                if !scope_ok {
274
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
275
                }
276
                Ok(())
277
            }
d23464c0 Andy McCall ↶ 278
        }
279
    }
280
e271a37b Andy McCall ↶ 281
    fn default_bookmark(&self, name: &str) -> Option<String> {
282
        let parsed = RepoName::parse(name)?;
66fd9d6b Andy McCall ↶ 283
        if crate::wiki::parent_of(&parsed).1 {
284
            return Some(crate::wiki::BOOKMARK.into());
285
        }
e271a37b Andy McCall ↶ 286
        self.row(&parsed).ok().map(|r| r.default_bookmark)
287
    }
288
e5b97ac3 Andy McCall ↶ 289
    fn add_log(&self, name: &str, refs: &mut oxy_core::protocol::RefsResponse) {
290
        let Some(app) = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else { return };
291
        let row = RepoName::parse(name).and_then(|p| self.row(&crate::wiki::parent_of(&p).0).ok()).map(|r| r.id);
292
        let backfill = crate::tlog::add_log(&app, row, refs);
293
        if !backfill.is_empty() {
294
            self.block(crate::tlog::log(&app, backfill));
0ee5c3fb Andy McCall ↶ 295
        }
296
    }
297
298
    /// Push protection (hub spec §15.4, #268): a push adding what looks
299
    /// like a secret is refused, saying where, unless the repository turned
300
    /// it off or a maintainer allowed that finding's fingerprint.
301
    fn vet_push(
302
        &self,
303
        name: &str,
304
        repo: &Repo,
305
        bundle: &oxy_core::protocol::Bundle,
306
        updates: &[oxy_core::protocol::RefUpdate],
307
        headers: &HeaderMap,
308
    ) -> Result<(), Failure> {
309
        if bundle.changes.is_empty() {
310
            return Ok(());
311
        }
312
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
313
        let row = self.row(&crate::wiki::parent_of(&parsed).0)?;
314
        if !row.push_protection {
315
            return Ok(());
316
        }
317
        let scan = crate::push_protection::scan(bundle, self.scan_budget);
318
        if let Some(note) = scan.note() {
319
            tracing::info!("push to {name}: {note}");
320
            self.scan_notes.lock().unwrap().insert(row.id, note);
321
        }
322
        if scan.findings.is_empty() {
323
            return Ok(());
e5b97ac3 Andy McCall ↶ 324
        }
0ee5c3fb Andy McCall ↶ 325
        let allowed = self.block(db::push_protection::allowed(&self.pool, row.id)).map_err(internal)?;
326
        let mut findings: Vec<_> = scan.findings.into_iter().filter(|f| !allowed.contains(&f.fingerprint)).collect();
327
        if findings.is_empty() {
328
            return Ok(());
329
        }
330
        crate::push_protection::locate(repo, bundle, &mut findings);
331
        let (pusher, via) = self.pusher(headers);
332
        let base = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()).map(|a| a.config.base_url().to_string());
333
        let settings = format!("{}/{}/{}/settings", base.unwrap_or_default(), row.owner, row.name);
334
        let text = crate::push_protection::refusal(&findings, &settings, &via);
335
        // Recorded for the maintainers (Settings › Push protection) and in
336
        // the push log, as every refused push is.
337
        let records: Vec<db::push_protection::NewFinding> = findings
338
            .iter()
339
            .map(|f| db::push_protection::NewFinding {
340
                rule: &f.rule,
341
                description: &f.description,
342
                path: &f.path,
343
                line: f.line as i64,
344
                fingerprint: &f.fingerprint,
345
                excerpt: &f.excerpt,
346
            })
347
            .collect();
348
        let who = pusher.as_ref().map(|(id, n)| (*id, n.as_str()));
349
        if let Err(e) = self.block(db::push_protection::record(&self.pool, row.id, who.map(|w| w.1).unwrap_or(""), &via, &records)) {
350
            tracing::warn!("recording push protection findings for {name}: {e}");
351
        }
352
        let reason = format!(
353
            "push protection: {} possible secret{} ({}); see Settings › Push protection",
354
            findings.len(),
355
            if findings.len() == 1 { "" } else { "s" },
356
            findings.iter().take(3).map(|f| format!("{}:{}", f.path, f.line)).collect::<Vec<_>>().join(", ")
357
        );
358
        let refs: Vec<String> = if updates.is_empty() {
359
            vec![oxy_core::repo::OBJECTS_RESULT.to_string()]
360
        } else {
361
            updates.iter().map(|u| u.name.clone()).collect()
362
        };
363
        let outcomes: Vec<db::repos::PushOutcome> = refs
364
            .into_iter()
365
            .map(|reference| db::repos::PushOutcome {
366
                reference,
367
                ok: false,
368
                reason: reason.clone(),
369
                old_state: String::new(),
370
                new_state: String::new(),
371
                heads: 0,
372
                changes: 0,
373
            })
374
            .collect();
375
        if let Err(e) = self.block(db::repos::record_push_via(&self.pool, row.id, who, &via, &outcomes)) {
376
            tracing::warn!("recording refused push to {name}: {e}");
377
        }
378
        tracing::info!("push to {name} refused by push protection: {} finding(s)", findings.len());
379
        Err(Failure(StatusCode::UNPROCESSABLE_ENTITY, text))
e5b97ac3 Andy McCall ↶ 380
    }
381
19bb31ff Andy McCall ↶ 382
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
d23464c0 Andy McCall ↶ 383
        let Some(parsed) = RepoName::parse(name) else { return };
66fd9d6b Andy McCall ↶ 384
        // A wiki push starts no run and rings no webhook.
385
        if crate::wiki::parent_of(&parsed).1 {
386
            return;
387
        }
d23464c0 Andy McCall ↶ 388
        let Ok(row) = self.row(&parsed) else { return };
d0fbea2e Andy McCall ↶ 389
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
0ee5c3fb Andy McCall ↶ 390
        let (pusher, via) = self.pusher(headers);
d23464c0 Andy McCall ↶ 391
        let pool = self.pool.clone();
4bae285c Andy McCall ↶ 392
        // A part of a long push (objects alone) moves nothing: the push it
393
        // belongs to is recorded when its last part names the references.
0ee5c3fb Andy McCall ↶ 394
        let mut results: Vec<db::repos::PushOutcome> = report
62d6e969 Andy McCall ↶ 395
            .results
396
            .iter()
4bae285c Andy McCall ↶ 397
            .filter(|r| r.name != oxy_core::repo::OBJECTS_RESULT)
62d6e969 Andy McCall ↶ 398
            .map(|r| {
399
                let updated = report.updated.iter().find(|u| u.name == r.name);
400
                db::repos::PushOutcome {
401
                    reference: r.name.clone(),
402
                    ok: r.ok,
403
                    reason: r.reason.clone(),
404
                    old_state: updated.map(|u| u.old_state.to_hex()).unwrap_or_default(),
405
                    new_state: updated.map(|u| u.new_state.to_hex()).unwrap_or_default(),
406
                    heads: updated.map(|u| u.heads.len() as i64).unwrap_or(0),
407
                    changes: if r.ok { report.imported.len() as i64 } else { 0 },
408
                }
409
            })
410
            .collect();
0ee5c3fb Andy McCall ↶ 411
        // Push protection's scan stopped early on this push or a part of it
412
        // (#268): its accepted references say so in the log.
413
        if !results.is_empty() {
414
            if let Some(note) = self.scan_notes.lock().unwrap().remove(&row.id) {
415
                for r in results.iter_mut().filter(|r| r.ok) {
416
                    r.reason = if r.reason.is_empty() { note.clone() } else { format!("{}; {note}", r.reason) };
417
                }
418
            }
419
        }
78001759 Andy McCall ↶ 420
        // Messages of the changes stored: `closes #N` when the default
421
        // bookmark moved, and `#N` references on any bookmark (hub spec §8).
422
        let bookmarks: Vec<String> = report
423
            .updated
424
            .iter()
425
            .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name))
426
            .map(|u| u.name.clone())
427
            .collect();
428
        let imported: Vec<(String, String)> = if bookmarks.is_empty() {
429
            vec![]
430
        } else {
18e381ea Andy McCall ↶ 431
            self.repos
432
                .open(&parsed)
df15b747 Andy McCall ↶ 433
                .map(|r| {
434
                    report.imported.iter().filter_map(|h| r.store().get_change_meta(h).ok().map(|c| (h.to_hex(), c.message))).collect()
435
                })
18e381ea Andy McCall ↶ 436
                .unwrap_or_default()
78001759 Andy McCall ↶ 437
        };
438
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
8933faff Andy McCall ↶ 439
        // Authors whose identity is a successor (a rotation, a renewal, a
440
        // delegation) of an earlier one: each step kept the key pair, so
441
        // if the chain reaches a linked key it is the same person, and the
442
        // successor is linked too, without re-uploading it (hub spec §5.3).
443
        let successors: Vec<(String, String, String, Vec<u8>, String)> = self
444
            .repos
445
            .open(&parsed)
446
            .map(|r| {
447
                let mut seen = std::collections::HashSet::new();
448
                let mut out = Vec::new();
449
                for h in &report.imported {
450
                    let Ok(meta) = r.store().get_change_meta(h) else { continue };
451
                    let mut key = meta.author;
452
                    while !key.is_zero() && seen.insert(key) {
453
                        let Ok(id) = r.store().get_identity(&key) else { break };
454
                        let Some(prev) = id.previous else { break };
455
                        let Ok(before) = r.store().get_identity(&prev) else { break };
456
                        if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk {
457
                            break;
458
                        }
459
                        out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex()));
460
                        key = prev;
461
                    }
462
                }
463
                out
464
            })
465
            .unwrap_or_default();
78001759 Andy McCall ↶ 466
        let on = if bookmarks.contains(&row.default_bookmark) {
467
            row.default_bookmark.clone()
18e381ea Andy McCall ↶ 468
        } else {
78001759 Andy McCall ↶ 469
            bookmarks.first().cloned().unwrap_or_default()
18e381ea Andy McCall ↶ 470
        };
42fa9554 Andy McCall ↶ 471
        // Bookmarks that moved, with their new heads, for OxygenFlow runs.
472
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
473
            .updated
474
            .iter()
c889911b Andy McCall ↶ 475
            // A bookmark sent again unchanged starts nothing.
476
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
42fa9554 Andy McCall ↶ 477
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
478
            .collect();
965b950f Andy McCall ↶ 479
        // The transparency log (#238): what the push brought, logged before
480
        // the push is answered.
481
        let app = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
482
        if let Some(app) = app {
483
            let to_log = self.repos.open(&parsed).map(|r| crate::tlog::push_entries(&r, Some(row.id), report)).unwrap_or_default();
484
            if !to_log.is_empty() {
485
                self.block(crate::tlog::log(&app, to_log));
486
            }
487
        }
18e381ea Andy McCall ↶ 488
        let app_hint = self.app_hint.clone();
d23464c0 Andy McCall ↶ 489
        self.runtime.spawn(async move {
18e381ea Andy McCall ↶ 490
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
2a0c09c0 Andy McCall ↶ 491
            if let Err(e) = db::repos::record_push_via(&pool, row.id, who, &via, &results).await {
d23464c0 Andy McCall ↶ 492
                tracing::warn!("recording push to {}: {e}", parsed);
8933faff Andy McCall ↶ 493
            }
494
            // Oldest first: a link made for one step lets the next step link.
495
            for (key, name, email, object, prev) in successors.iter().rev() {
496
                let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue };
497
                let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue };
498
                match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await {
499
                    Ok(()) => {
500
                        tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]);
501
                        db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok();
502
                    }
503
                    Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]),
504
                }
d23464c0 Andy McCall ↶ 505
            }
18e381ea Andy McCall ↶ 506
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
507
            if let Some(app) = app {
acc85ac0 Andy McCall ↶ 508
                // Issues close from every change newly on the default
509
                // bookmark, not only those this push imported: a client's
510
                // landing moves main to changes the hub already held.
511
                if let Some(r) = results.iter().find(|r| r.ok && r.reference == row.default_bookmark) {
512
                    let new_heads = moved.iter().find(|(b, _, _)| *b == row.default_bookmark).map(|(_, _, h)| h.clone()).unwrap_or_default();
513
                    let old = r.old_state.clone();
514
                    let old_heads = crate::repos::RepoName::new(&row.owner, &row.name);
515
                    let old_heads = match (old_heads, oxy_core::hash::ObjectRef::from_hex(&old)) {
516
                        (Some(n), Some(s)) => app.repos.read(&n, move |repo, _| Ok(repo.store().get_state(&s).map(|st| st.heads).unwrap_or_default())).await.unwrap_or_default(),
517
                        _ => Vec::new(),
518
                    };
519
                    crate::web::issues::close_from_arrived(&app, &row, old_heads, new_heads, None).await;
520
                } else {
521
                    crate::web::issues::close_from_messages(&app, &row, &messages).await;
522
                }
78001759 Andy McCall ↶ 523
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
524
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
3f4b0ea6 Andy McCall ↶ 525
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
526
                    crate::mirror::refresh(&app, &row).await;
527
                }
b64e2537 Andy McCall ↶ 528
                let accepted: Vec<serde_json::Value> = results
529
                    .iter()
530
                    .filter(|r| r.ok)
531
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
532
                    .collect();
533
                if !accepted.is_empty() {
534
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
535
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
42fa9554 Andy McCall ↶ 536
                }
537
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
39442b00 Andy McCall ↶ 538
                // The moved bookmarks' briefs, worked out now so no page
539
                // waits for them (#145).
540
                if let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) {
541
                    let all: Vec<Vec<oxy_core::hash::ChangeHash>> = moved.iter().map(|(_, _, h)| h.clone()).collect();
542
                    let warm = app.clone();
543
                    tokio::spawn(async move {
544
                        warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok();
545
                    });
546
                }
f677d627 Andy McCall ↶ 547
                crate::diffusion::after_push(&app, &row, &moved).await;
a7e6e8e6 Andy McCall ↶ 548
                // An upstream's default bookmark moved: consumers following
549
                // every move are bumped now, those following green when
550
                // its run passes (#148).
551
                if let Some((_, _, heads)) = moved.iter().find(|(b, _, _)| *b == row.default_bookmark) {
552
                    let (app, row, heads) = (app.clone(), row.clone(), heads.clone());
553
                    tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
554
                }
42fa9554 Andy McCall ↶ 555
                for (bookmark, state, heads) in &moved {
556
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
ac713df7 Andy McCall ↶ 557
                    crate::web::proposals::settle_after_push(&app, &row, bookmark, &actor).await;
b64e2537 Andy McCall ↶ 558
                }
18e381ea Andy McCall ↶ 559
            }
d23464c0 Andy McCall ↶ 560
        });
561
    }
562
}
563
564
/// `/{owner}/{name}/oxy/v1/{endpoint}`.
565
pub async fn dispatch(
566
    State(app): State<Shared>,
567
    Path((owner, name, endpoint)): Path<(String, String, String)>,
568
    method: Method,
569
    headers: HeaderMap,
570
    body: Bytes,
571
) -> Response {
572
    let Some(endpoint) = Endpoint::parse(&endpoint) else {
573
        return Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response();
574
    };
575
    let Some(repo) = RepoName::new(&owner, &name) else {
576
        return not_found().into_response();
577
    };
578
    let _guard = match endpoint {
579
        Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await),
580
        _ => Guard::Read(app.repos.read_guard(&repo).await),
581
    };
582
    app.server.handle(repo.to_string(), endpoint, &method, &headers, body).await
583
}
584
585
enum Guard {
586
    Read(#[allow(dead_code)] tokio::sync::OwnedRwLockReadGuard<()>),
587
    Write(#[allow(dead_code)] tokio::sync::OwnedRwLockWriteGuard<()>),
588
}