OXYGENHUB

Coverage / crates/hub/src/protocol.rs 94.9%

559 of 589 lines run, 119 of 232 functions · run #507 on main · run missed

1
//! The wire protocol, embedded (hub spec §3.3, §5.4): `oxy-server`'s
2
//! `Server` over a backend that resolves `owner/name`, authorises reads
3
//! and pushes by account role and token scope, and records who pushed.
4
5
use crate::auth::{self, Access, Role};
6
use crate::db::{self, accounts::Account};
7
use crate::repos::{RepoName, Repos};
8
use crate::Shared;
9
use axum::body::Bytes;
10
use axum::extract::{Path, State};
11
use axum::http::{HeaderMap, Method, StatusCode};
12
use axum::response::{IntoResponse, Response};
13
use oxy_core::repo::{PushReport, Repo};
14
use oxy_server::{Backend, Endpoint, Failure};
15
use std::sync::Arc;
16
17
pub struct HubBackend {
18
    repos: Arc<Repos>,
19
    /// Wikis, reached as `OWNER/NAME.wiki` (hub spec §6.11).
20
    wikis: Arc<Repos>,
21
    pool: db::Pool,
22
    /// Site tokens from `hub.toml`: full access, for bootstrapping.
23
    site_tokens: Vec<String>,
24
    runtime: tokio::runtime::Handle,
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>>>>,
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)>>,
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>>>,
36
    /// The keys that signed what a push to each repository (by name)
37
    /// brought, noted when it is vetted and recorded once it is accepted
38
    /// (#286, Settings › Keys' "last used").
39
    signers: Arc<std::sync::Mutex<std::collections::HashMap<String, std::collections::BTreeSet<String>>>>,
40
}
41
42
/// Who a request's credential resolved to.
43
enum Caller {
44
    Anonymous,
45
    /// A site token from the configuration.
46
    Site,
47
    Token(Box<Account>, Box<db::accounts::Token>),
48
    /// A running OxygenFlow job: reads its own repository and the subrepos
49
    /// its state pins, nothing else (hub spec §13.2).
50
    Job {
51
        repo_id: i64,
52
        also: Vec<i64>,
53
    },
54
}
55
56
impl HubBackend {
57196×
    pub fn new(
58196×
        repos: Arc<Repos>,
59196×
        wikis: Arc<Repos>,
60196×
        pool: db::Pool,
61196×
        site_tokens: Vec<String>,
62196×
        runtime: tokio::runtime::Handle,
63196×
    ) -> HubBackend {
64196×
        HubBackend {
65196×
            repos,
66196×
            wikis,
67196×
            pool,
68196×
            site_tokens,
69196×
            runtime,
70196×
            app_hint: Arc::new(std::sync::Mutex::new(None)),
71196×
            acceptor: None,
72196×
            scan_budget: crate::push_protection::Budget::default(),
73196×
            scan_notes: Arc::default(),
74196×
            signers: Arc::default(),
75196×
        }
76196×
    }
77
78
    /// Scan pushes for secrets within `budget` (#268).
79196×
    pub fn with_scan_budget(mut self, budget: crate::push_protection::Budget) -> Self {
80196×
        self.scan_budget = budget;
81196×
        self
82196×
    }
83
84
    /// Who pushes, for the push log: the account (id 0 for a site token)
85
    /// and, for an agent's token, the agent's name.
86259×
    fn pusher(&self, headers: &HeaderMap) -> (Option<(i64, String)>, String) {
87259×
        match self.caller(headers) {
8842×
            Ok(Caller::Token(account, token)) => {
8942×
                (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() })
90
            }
91217×
            Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()),
920×
            _ => (None, String::new()),
93
        }
94259×
    }
95
96
    /// Countersign accepted updates by keys this hub certified with `identity`.
97196×
    pub fn with_acceptor(mut self, identity: oxy_core::identity::Identity, secret: oxy_core::identity::SecretKey) -> Self {
98196×
        self.acceptor = Some(Arc::new((identity, secret)));
99196×
        self
100196×
    }
101
102
    /// The slot the application registers itself in after construction.
103196×
    pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> {
104196×
        self.app_hint.clone()
105196×
    }
106
1075097×
    fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T {
1085097×
        tokio::task::block_in_place(|| self.runtime.block_on(f))
1095097×
    }
110
1113819×
    fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> {
1123819×
        let pool = self.pool.clone();
1133819×
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
1143787×
            Ok(Some(r)) => Ok(r),
115
            // A transferred repository still answers to its old name (#108).
11632×
            Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) {
11731×
                Ok(Some(r)) => Ok(r),
1181×
                Ok(None) => Err(not_found()),
1190×
                Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
120
            },
1210×
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
122
        }
1233819×
    }
124
1251344×
    fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> {
1261344×
        let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) };
1271230×
        if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) {
1281025×
            return Ok(Caller::Site);
129205×
        }
130205×
        let hash = auth::token_hash(presented);
131205×
        let pool = self.pool.clone();
132205×
        if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
13312×
            let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?;
13412×
            let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
13510×
            let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?;
13610×
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
13710×
            return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() });
138193×
        }
139
        // An agent's grant token (keyless agents, #241): its owner's
140
        // account, as an agent's token, writing when the grant lets its
141
        // keys move references. Revoking the grant stops it at once.
142193×
        let grant_token = presented.starts_with(crate::keyless::GRANT_TOKEN_PREFIX);
143193×
        let token = if grant_token {
14417×
            let grant = self.block(db::accounts::agent_grant_by_token(&pool, &hash)).map_err(internal)?;
14517×
            let Some(g) = grant.filter(|g| g.live(db::now_ms())) else {
1461×
                return Err(Failure(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into()));
147
            };
14816×
            let _ = self.block(db::accounts::touch_agent_grant(&pool, g.id));
149
            Some(db::accounts::Token {
150
                id: 0,
15116×
                account_id: g.account_id,
15216×
                name: g.agent.clone(),
15316×
                hash: String::new(),
15432×
                scopes: if g.scope.split(',').any(|s| s.trim() == "refs") { "repo:read,repo:write" } else { "repo:read" }.into(),
15516×
                repos: String::new(),
15616×
                created_ms: g.created_ms,
15716×
                expires_ms: Some(g.expires_ms),
15816×
                last_used_ms: g.last_used_ms,
15916×
                renew_ms: None,
160
                agent: true,
161
            })
162
        } else {
163176×
            self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?
164
        };
165192×
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
166192×
        if token.expired(db::now_ms()) {
1670×
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
168192×
        }
169192×
        let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?;
170192×
        let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
171192×
        if account.banned {
1720×
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
173192×
        }
174192×
        if account.disabled {
1750×
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
176192×
        }
177192×
        if !grant_token {
178176×
            let _ = self.block(db::accounts::touch_token(&pool, token.id));
179176×
        }
180192×
        Ok(Caller::Token(Box::new(account), Box::new(token)))
1811344×
    }
182
}
183
1840×
fn internal(e: sqlx::Error) -> Failure {
1850×
    Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
1860×
}
187
1889×
fn not_found() -> Failure {
1899×
    Failure(StatusCode::NOT_FOUND, "no such repository".into())
1909×
}
191
192
impl Backend for HubBackend {
1931070×
    fn open(&self, name: &str) -> Result<Repo, Failure> {
1941070×
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
195
        // `NAME.wiki` is the repository's wiki (§6.11); the row knows the
196
        // current name, so a transferred repository still answers to its
197
        // old one (#108).
1981070×
        let (parent, is_wiki) = crate::wiki::parent_of(&parsed);
1991070×
        let row = self.row(&parent)?;
2001070×
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
2011070×
        let store = if is_wiki { &self.wikis } else { &self.repos };
2021070×
        let repo = store.open(&name).map_err(|e| match e {
2031×
            crate::repos::RepoError::NotFound => not_found(),
2040×
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
2051×
        })?;
2061069×
        Ok(match &self.acceptor {
2071069×
            Some(a) => repo.with_acceptor(a.0.clone(), a.1.clone()),
2080×
            None => repo,
209
        })
2101070×
    }
211
2121086×
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
2131086×
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
2141086×
        let (parent, _) = crate::wiki::parent_of(&parsed);
2151086×
        let row = self.row(&parent)?;
2161085×
        let caller = self.caller(headers)?;
2171082×
        if let Caller::Job { repo_id, also } = &caller {
218
            // Only reads: of the job's own repository, and of the subrepos its
219
            // state pins that its pusher could read when the job was claimed.
2209×
            return match access {
2219×
                oxy_server::Access::Read if *repo_id == row.id || also.contains(&row.id) => Ok(()),
2223×
                oxy_server::Access::Read => Err(not_found()),
2231×
                oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())),
224
            };
2251072×
        }
2261072×
        let (role, scope_ok) = match &caller {
2270×
            Caller::Job { .. } => unreachable!(),
228808×
            Caller::Site => (Some(Role::Admin), true),
229114×
            Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true),
230150×
            Caller::Token(account, token) => {
231150×
                let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?;
232150×
                let needed = match access {
233
                    oxy_server::Access::Read => {
234106×
                        token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")
235
                    }
23644×
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
237
                };
238150×
                let current = format!("{}/{}", row.owner, row.name);
239150×
                let covered = token.covers(&parsed.to_string()) || token.covers(&current) || token.covers(&parent.to_string());
240150×
                (role, needed && covered)
241
            }
242
        };
243
        // An unreadable repository does not exist for the caller (hub spec §4.4).
2441072×
        if !auth::roles::allows(role, Access::Read) {
2454×
            return Err(not_found());
2461068×
        }
247
        // A wiki is made, empty and unbound, when someone who may write to
248
        // its repository first reaches it, so the first page can be pushed
249
        // as well as written on the web (§6.11); the push gives it its id.
2501068×
        let (_, is_wiki) = crate::wiki::parent_of(&parsed);
2511068×
        let may_write = match &caller {
252808×
            Caller::Site => true,
253149×
            Caller::Token(_, token) => token.has_scope("repo:write") || token.has_scope("repo:admin"),
254111×
            _ => false,
255
        };
2561068×
        if is_wiki && may_write && auth::roles::allows(role, Access::Write) && !row.archived {
2576×
            if let Some(name) = RepoName::new(&row.owner, &row.name) {
2586×
                if !self.wikis.exists(&name) {
2591×
                    self.wikis
2601×
                        .create(&name, None)
2611×
                        .map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, format!("make the wiki: {e}")))?;
2625×
                }
2630×
            }
2641062×
        }
265803×
        match access {
266803×
            oxy_server::Access::Read if scope_ok => Ok(()),
2670×
            oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())),
268
            oxy_server::Access::Push => {
269265×
                if matches!(caller, Caller::Anonymous) {
2701×
                    return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into()));
271264×
                }
272264×
                if !auth::roles::allows(role, Access::Write) {
2731×
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
274263×
                }
275263×
                if row.archived {
2761×
                    return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into()));
277262×
                }
278262×
                if !scope_ok {
2791×
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
280261×
                }
281261×
                Ok(())
282
            }
283
        }
2841086×
    }
285
286594×
    fn default_bookmark(&self, name: &str) -> Option<String> {
287594×
        let parsed = RepoName::parse(name)?;
288594×
        if crate::wiki::parent_of(&parsed).1 {
2896×
            return Some(crate::wiki::BOOKMARK.into());
290588×
        }
291588×
        self.row(&parsed).ok().map(|r| r.default_bookmark)
292594×
    }
293
294347×
    fn add_log(&self, name: &str, refs: &mut oxy_core::protocol::RefsResponse) {
295347×
        let Some(app) = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else { return };
296347×
        let row = RepoName::parse(name).and_then(|p| self.row(&crate::wiki::parent_of(&p).0).ok()).map(|r| r.id);
297347×
        let backfill = crate::tlog::add_log(&app, row, refs);
298347×
        if !backfill.is_empty() {
2997×
            self.block(crate::tlog::log(&app, backfill));
300340×
        }
301347×
    }
302
303
    /// Tailor the refs answer to the client asking (oxygen#131): one that
304
    /// does not declare `certified-tags` in `Oxy-Features` (oxy 0.5.0 and
305
    /// older) is not sent a tag that only a certified key's certificate
306
    /// authorizes, which it would refuse and, with it, the whole clone.
307347×
    fn adjust_refs(&self, _name: &str, repo: &Repo, refs: &mut oxy_core::protocol::RefsResponse, headers: &HeaderMap) {
308347×
        oxy_server::tailor_refs(repo, refs, headers);
309347×
    }
310
311
    /// Push protection (hub spec §15.4, #268): a push adding what looks
312
    /// like a secret is refused, saying where, unless the repository turned
313
    /// it off or a maintainer allowed that finding's fingerprint.
314261×
    fn vet_push(
315261×
        &self,
316261×
        name: &str,
317261×
        repo: &Repo,
318261×
        bundle: &oxy_core::protocol::Bundle,
319261×
        updates: &[oxy_core::protocol::RefUpdate],
320261×
        headers: &HeaderMap,
321261×
    ) -> Result<(), Failure> {
322261×
        let keys: std::collections::BTreeSet<String> = bundle
323261×
            .changes
324261×
            .iter()
325261×
            .flat_map(|(_, sigs)| sigs.iter())
326261×
            .chain(bundle.attests.iter().flat_map(|a| a.sigs.iter()))
327261×
            .map(|sig| sig.key.to_hex())
328261×
            .collect();
329261×
        if !keys.is_empty() {
33094×
            self.signers.lock().unwrap().entry(name.to_string()).or_default().extend(keys);
331167×
        }
332261×
        if bundle.changes.is_empty() {
33322×
            return Ok(());
334239×
        }
335239×
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
336239×
        let row = self.row(&crate::wiki::parent_of(&parsed).0)?;
337239×
        if !row.push_protection {
33810×
            return Ok(());
339229×
        }
340229×
        let scan = crate::push_protection::scan(bundle, self.scan_budget);
341229×
        if let Some(note) = scan.note() {
3421×
            tracing::info!("push to {name}: {note}");
3431×
            self.scan_notes.lock().unwrap().insert(row.id, note);
344228×
        }
345229×
        if scan.findings.is_empty() {
346214×
            return Ok(());
34715×
        }
34815×
        let allowed = self.block(db::push_protection::allowed(&self.pool, row.id)).map_err(internal)?;
34919×
        let mut findings: Vec<_> = scan.findings.into_iter().filter(|f| !allowed.contains(&f.fingerprint)).collect();
35015×
        if findings.is_empty() {
3511×
            return Ok(());
35214×
        }
35314×
        crate::push_protection::locate(repo, bundle, &mut findings);
35414×
        let (pusher, via) = self.pusher(headers);
35514×
        let base = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()).map(|a| a.config.base_url().to_string());
35614×
        let settings =
35714×
            if row.is_drop() { String::new() } else { format!("{}/{}/{}/settings", base.unwrap_or_default(), row.owner, row.name) };
35814×
        let text = crate::push_protection::refusal(&findings, &settings, &via);
359
        // Recorded for the maintainers (Settings › Push protection) and in
360
        // the push log, as every refused push is.
36114×
        let records: Vec<db::push_protection::NewFinding> = findings
36214×
            .iter()
36314×
            .map(|f| db::push_protection::NewFinding {
36418×
                rule: &f.rule,
36518×
                description: &f.description,
36618×
                path: &f.path,
36718×
                line: f.line as i64,
36818×
                fingerprint: &f.fingerprint,
36918×
                excerpt: &f.excerpt,
37018×
            })
37114×
            .collect();
37214×
        let who = pusher.as_ref().map(|(id, n)| (*id, n.as_str()));
37314×
        if let Err(e) = self.block(db::push_protection::record(&self.pool, row.id, who.map(|w| w.1).unwrap_or(""), &via, &records)) {
3740×
            tracing::warn!("recording push protection findings for {name}: {e}");
37514×
        }
37614×
        let reason = format!(
377
            "push protection: {} possible secret{} ({}){}",
37814×
            findings.len(),
37914×
            if findings.len() == 1 { "" } else { "s" },
38018×
            findings.iter().take(3).map(|f| format!("{}:{}", f.path, f.line)).collect::<Vec<_>>().join(", "),
38114×
            if row.is_drop() { "" } else { "; see Settings › Push protection" }
382
        );
38314×
        let refs: Vec<String> = if updates.is_empty() {
3840×
            vec![oxy_core::repo::OBJECTS_RESULT.to_string()]
385
        } else {
38614×
            updates.iter().map(|u| u.name.clone()).collect()
387
        };
38814×
        let outcomes: Vec<db::repos::PushOutcome> = refs
38914×
            .into_iter()
39014×
            .map(|reference| db::repos::PushOutcome {
39114×
                reference,
392
                ok: false,
39314×
                reason: reason.clone(),
39414×
                old_state: String::new(),
39514×
                new_state: String::new(),
396
                heads: 0,
397
                changes: 0,
39814×
            })
39914×
            .collect();
40014×
        if let Err(e) = self.block(db::repos::record_push_via(&self.pool, row.id, who, &via, &outcomes)) {
4010×
            tracing::warn!("recording refused push to {name}: {e}");
40214×
        }
40314×
        tracing::info!("push to {name} refused by push protection: {} finding(s)", findings.len());
40414×
        Err(Failure(StatusCode::UNPROCESSABLE_ENTITY, text))
405261×
    }
406
407
    /// A tag a release names is not withdrawn (#301): the release would
408
    /// point at nothing. Delete the release first.
4092×
    fn vet_tag_withdrawal(&self, name: &str, tag: &str) -> Result<(), String> {
4102×
        let Some(parsed) = RepoName::parse(name) else { return Ok(()) };
4112×
        let Ok(row) = self.row(&parsed) else { return Ok(()) };
4122×
        match self.block(db::releases::get(&self.pool, row.id, tag)) {
4131×
            Ok(Some(rel)) => Err(format!("the release {:?} uses tags/{tag}: delete the release first", rel.title)),
4141×
            Ok(None) => Ok(()),
4150×
            Err(e) => Err(format!("could not check the releases of {name}: {e}")),
416
        }
4172×
    }
418
419
    /// A push that moved the bookmark of a proposal that has landed, with
420
    /// none open from it now, says so beside the bookmark (#329): those
421
    /// changes reach no target until a new proposal takes them.
422247×
    fn push_notes(&self, name: &str, report: &PushReport, _headers: &HeaderMap) -> Vec<(String, String)> {
423247×
        let Some(parsed) = RepoName::parse(name) else { return Vec::new() };
424247×
        if report.updated.is_empty() || crate::wiki::parent_of(&parsed).1 {
4255×
            return Vec::new();
426242×
        }
427242×
        let Ok(row) = self.row(&parsed) else { return Vec::new() };
428242×
        let Ok(proposals) = self.block(db::proposals::list(&self.pool, row.id, &[])) else { return Vec::new() };
429253×
        let moved: Vec<&str> = report.updated.iter().map(|u| u.name.as_str()).collect();
430242×
        landed_notes(&proposals, row.id, &moved)
431247×
    }
432
433247×
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
434
        // The keys that signed what it brought were used (#286): linked
435
        // ones show it under Settings › Keys.
436247×
        let signers = self.signers.lock().unwrap().remove(name);
437247×
        if let Some(keys) = signers.filter(|_| report.results.iter().any(|r| r.ok)) {
43880×
            let pool = self.pool.clone();
43980×
            let keys: Vec<String> = keys.into_iter().collect();
44080×
            tokio::spawn(async move {
44180×
                if let Err(e) = db::accounts::identities_used(&pool, &keys).await {
4420×
                    tracing::warn!("noting keys used: {e}");
44380×
                }
44480×
            });
445167×
        }
446247×
        let Some(parsed) = RepoName::parse(name) else { return };
447
        // A wiki push starts no run and rings no webhook.
448247×
        if crate::wiki::parent_of(&parsed).1 {
4492×
            return;
450245×
        }
451245×
        let Ok(row) = self.row(&parsed) else { return };
452245×
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
453245×
        let (pusher, via) = self.pusher(headers);
454245×
        let pool = self.pool.clone();
455
        // A part of a long push (objects alone) moves nothing: the push it
456
        // belongs to is recorded when its last part names the references.
457245×
        let mut results: Vec<db::repos::PushOutcome> = report
458245×
            .results
459245×
            .iter()
460266×
            .filter(|r| r.name != oxy_core::repo::OBJECTS_RESULT)
461265×
            .map(|r| {
462276×
                let updated = report.updated.iter().find(|u| u.name == r.name);
463
                db::repos::PushOutcome {
464265×
                    reference: r.name.clone(),
465265×
                    ok: r.ok,
466265×
                    reason: r.reason.clone(),
467265×
                    old_state: updated.map(|u| u.old_state.to_hex()).unwrap_or_default(),
468265×
                    new_state: updated.map(|u| u.new_state.to_hex()).unwrap_or_default(),
469265×
                    heads: updated.map(|u| u.heads.len() as i64).unwrap_or(0),
470265×
                    changes: if r.ok { report.imported.len() as i64 } else { 0 },
471
                }
472265×
            })
473245×
            .collect();
474
        // Push protection's scan stopped early on this push or a part of it
475
        // (#268): its accepted references say so in the log.
476245×
        if !results.is_empty() {
477244×
            if let Some(note) = self.scan_notes.lock().unwrap().remove(&row.id) {
4781×
                for r in results.iter_mut().filter(|r| r.ok) {
4791×
                    r.reason = if r.reason.is_empty() { note.clone() } else { format!("{}; {note}", r.reason) };
480
                }
481243×
            }
4821×
        }
483
        // Messages of the changes stored: `closes #N` when the default
484
        // bookmark moved, and `#N` references on any bookmark (hub spec §8).
485245×
        let bookmarks: Vec<String> = report
486245×
            .updated
487245×
            .iter()
488253×
            .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name))
489245×
            .map(|u| u.name.clone())
490245×
            .collect();
491245×
        let imported: Vec<(String, String)> = if bookmarks.is_empty() {
4924×
            vec![]
493
        } else {
494241×
            self.repos
495241×
                .open(&parsed)
496241×
                .map(|r| {
497241×
                    report.imported.iter().filter_map(|h| r.store().get_change_meta(h).ok().map(|c| (h.to_hex(), c.message))).collect()
498241×
                })
499241×
                .unwrap_or_default()
500
        };
501245×
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
502
        // Authors whose identity is a successor (a rotation, a renewal, a
503
        // delegation) of an earlier one: each step kept the key pair, so
504
        // if the chain reaches a linked key it is the same person, and the
505
        // successor is linked too, without re-uploading it (hub spec §5.3).
506245×
        let successors: Vec<(String, String, String, Vec<u8>, String)> = self
507245×
            .repos
508245×
            .open(&parsed)
509245×
            .map(|r| {
510245×
                let mut seen = std::collections::HashSet::new();
511245×
                let mut out = Vec::new();
512245×
                for h in &report.imported {
513238×
                    let Ok(meta) = r.store().get_change_meta(h) else { continue };
514238×
                    let mut key = meta.author;
515239×
                    while !key.is_zero() && seen.insert(key) {
51679×
                        let Ok(id) = r.store().get_identity(&key) else { break };
51779×
                        let Some(prev) = id.previous else { break };
5182×
                        let Ok(before) = r.store().get_identity(&prev) else { break };
5192×
                        if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk {
5201×
                            break;
5211×
                        }
5221×
                        out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex()));
5231×
                        key = prev;
524
                    }
525
                }
526245×
                out
527245×
            })
528245×
            .unwrap_or_default();
529245×
        let on = if bookmarks.contains(&row.default_bookmark) {
530208×
            row.default_bookmark.clone()
531
        } else {
53237×
            bookmarks.first().cloned().unwrap_or_default()
533
        };
534
        // Bookmarks that moved, with their new heads, for OxygenFlow runs.
535245×
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
536245×
            .updated
537245×
            .iter()
538
            // A bookmark sent again unchanged starts nothing.
539263×
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
540245×
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
541245×
            .collect();
542
        // The transparency log (#238): what the push brought, logged before
543
        // the push is answered.
544245×
        let app = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
545245×
        if let Some(app) = app {
546245×
            let to_log = self.repos.open(&parsed).map(|r| crate::tlog::push_entries(&r, Some(row.id), report)).unwrap_or_default();
547245×
            if !to_log.is_empty() {
54889×
                self.block(crate::tlog::log(&app, to_log));
549156×
            }
5500×
        }
551245×
        let app_hint = self.app_hint.clone();
552245×
        self.runtime.spawn(async move {
553245×
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
554245×
            if let Err(e) = db::repos::record_push_via(&pool, row.id, who, &via, &results).await {
5550×
                tracing::warn!("recording push to {}: {e}", parsed);
556245×
            }
557
            // Oldest first: a link made for one step lets the next step link.
558245×
            for (key, name, email, object, prev) in successors.iter().rev() {
5591×
                let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue };
5601×
                let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue };
5611×
                match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await {
562
                    Ok(()) => {
5631×
                        tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]);
5641×
                        db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok();
565
                    }
5660×
                    Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]),
567
                }
568
            }
569
            // A drop (#279) has no issues, webhooks, mirror, Diffusion or
570
            // runs: its push is recorded, and that is all.
571245×
            if row.is_drop() {
5721×
                return;
573244×
            }
574244×
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
575244×
            if let Some(app) = app {
576
                // Issues close from every change newly on the default
577
                // bookmark, not only those this push imported: a client's
578
                // landing moves main to changes the hub already held.
579253×
                if let Some(r) = results.iter().find(|r| r.ok && r.reference == row.default_bookmark) {
580207×
                    let new_heads = moved.iter().find(|(b, _, _)| *b == row.default_bookmark).map(|(_, _, h)| h.clone()).unwrap_or_default();
581207×
                    let old = r.old_state.clone();
582207×
                    let old_heads = crate::repos::RepoName::new(&row.owner, &row.name);
583207×
                    let old_heads = match (old_heads, oxy_core::hash::ObjectRef::from_hex(&old)) {
584207×
                        (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(),
5850×
                        _ => Vec::new(),
586
                    };
587207×
                    crate::web::issues::close_from_arrived(&app, &row, old_heads, new_heads, None).await;
588
                } else {
58937×
                    crate::web::issues::close_from_messages(&app, &row, &messages).await;
590
                }
591243×
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
592243×
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
593252×
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
594
                    // Failed, it is done again from the work queue (#276).
595206×
                    if crate::mirror::refresh(&app, &row).await.is_err() {
5960×
                        crate::queue::queue_mirror(&app, row.id).await;
597206×
                    }
59837×
                }
599243×
                let accepted: Vec<serde_json::Value> = results
600243×
                    .iter()
601243×
                    .filter(|r| r.ok)
602261×
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
603243×
                    .collect();
604243×
                if !accepted.is_empty() {
605240×
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
606240×
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
6073×
                }
608241×
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
609
                // The moved bookmarks' briefs, worked out now so no page
610
                // waits for them (#145).
611241×
                if let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) {
612241×
                    let all: Vec<Vec<oxy_core::hash::ChangeHash>> = moved.iter().map(|(_, _, h)| h.clone()).collect();
613241×
                    let warm = app.clone();
614241×
                    tokio::spawn(async move {
615240×
                        warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok();
616240×
                    });
6170×
                }
618241×
                crate::diffusion::after_push(&app, &row, &moved).await;
619
                // An upstream's default bookmark moved: consumers following
620
                // every move are bumped now, those following green when
621
                // its run passes (#148).
622240×
                if let Some((_, _, heads)) = moved.iter().find(|(b, _, _)| *b == row.default_bookmark) {
623195×
                    let (app, row, heads) = (app.clone(), row.clone(), heads.clone());
624195×
                    tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
62545×
                }
626240×
                for (bookmark, state, heads) in &moved {
627240×
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
628240×
                    crate::web::proposals::settle_after_push(&app, &row, bookmark, &actor).await;
629
                }
6300×
            }
631239×
        });
632247×
    }
633
}
634
635
/// `/{owner}/{name}/oxy/v1/{endpoint}`.
6361088×
pub async fn dispatch(
6371088×
    State(app): State<Shared>,
6381088×
    Path((owner, name, endpoint)): Path<(String, String, String)>,
6391088×
    method: Method,
6401088×
    headers: HeaderMap,
6411088×
    body: Bytes,
6421088×
) -> Response {
6431088×
    let Some(endpoint) = Endpoint::parse(&endpoint) else {
6441×
        return Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response();
645
    };
6461087×
    let Some(repo) = RepoName::new(&owner, &name) else {
6470×
        return not_found().into_response();
648
    };
6491087×
    let _guard = match endpoint {
650266×
        Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await),
651821×
        _ => Guard::Read(app.repos.read_guard(&repo).await),
652
    };
6531087×
    let (name, received) = (endpoint.name(), body.len() as u64);
6541087×
    let res = app.server.handle(repo.to_string(), endpoint, &method, &headers, body).await;
6551087×
    let sent = axum::body::HttpBody::size_hint(res.body()).exact().unwrap_or(0);
6561087×
    crate::metrics::protocol_bytes(name, received, sent);
6571087×
    res
6581088×
}
659
660
/// `/{owner}/drops/{id}/oxy/v1/{endpoint}`: a drop is a repository to the
661
/// wire protocol, named `owner/drops/ID` (#279).
66222×
pub async fn dispatch_drop(
66322×
    State(app): State<Shared>,
66422×
    Path((owner, id, endpoint)): Path<(String, String, String)>,
66522×
    method: Method,
66622×
    headers: HeaderMap,
66722×
    body: Bytes,
66822×
) -> Response {
66922×
    let Some(name) = RepoName::drop(&owner, &id) else {
6700×
        return not_found().into_response();
671
    };
67222×
    dispatch(State(app), Path((name.owner, name.name, endpoint)), method, headers, body).await
67322×
}
674
675
enum Guard {
676
    Read(#[allow(dead_code)] crate::repos::ReadGuard),
677
    Write(#[allow(dead_code)] crate::repos::WriteGuard),
678
}
679
680
/// For each bookmark in `moved` whose latest proposal in this repository
681
/// (`repo_id`) has landed and none is open from it, a note with what to do
682
/// (#329).
683245×
fn landed_notes(proposals: &[db::proposals::ProposalRow], repo_id: i64, moved: &[&str]) -> Vec<(String, String)> {
684245×
    let mut notes = Vec::new();
685258×
    for bookmark in moved {
686258×
        let from: Vec<&db::proposals::ProposalRow> = proposals
687258×
            .iter()
688258×
            .filter(|p| p.source_kind == "bookmark" && p.source_repo_id.is_none_or(|id| id == repo_id) && p.source_bookmark == *bookmark)
689258×
            .collect();
690258×
        if from.iter().any(|p| p.state == "open") {
6912×
            continue;
692256×
        }
693256×
        if let Some(p) = from.iter().filter(|p| p.state == "landed").max_by_key(|p| p.landed_ms) {
6942×
            notes.push((
6952×
                bookmark.to_string(),
6962×
                format!(
6972×
                    "its proposal #{} has landed in {}, and these changes are not on {}: open a new proposal with `oh proposal create --from {bookmark}`",
6982×
                    p.number, p.target, p.target
6992×
                ),
7002×
            ));
701254×
        }
702
    }
703245×
    notes
704245×
}
705
706
#[cfg(test)]
707
mod landed_tests {
708
    use super::*;
709
7106×
    fn proposal(number: i64, state: &str, source: &str, landed_ms: i64) -> db::proposals::ProposalRow {
7116×
        db::proposals::ProposalRow {
7126×
            id: number,
7136×
            repo_id: 1,
7146×
            number,
7156×
            state: state.into(),
7166×
            target: "develop".into(),
7176×
            source_kind: "bookmark".into(),
7186×
            source_bookmark: source.into(),
7196×
            landed_ms,
7206×
            ..Default::default()
7216×
        }
7226×
    }
723
724
    #[test]
7251×
    fn a_push_to_a_landed_proposals_bookmark_says_what_to_do() {
7261×
        let ps = [proposal(3, "landed", "audio-irq", 10), proposal(5, "landed", "audio-irq", 20), proposal(7, "open", "camera", 0)];
7271×
        let notes = landed_notes(&ps, 1, &["audio-irq", "camera", "fresh"]);
7281×
        assert_eq!(notes.len(), 1, "{notes:?}");
7291×
        assert_eq!(notes[0].0, "audio-irq");
7301×
        assert!(
7311×
            notes[0].1.contains("#5 has landed in develop") && notes[0].1.contains("oh proposal create --from audio-irq"),
732
            "{}",
7330×
            notes[0].1
734
        );
735
        // An open proposal from it takes the changes: nothing to say.
7361×
        let reopened = [proposal(3, "landed", "audio-irq", 10), proposal(9, "open", "audio-irq", 0)];
7371×
        assert!(landed_notes(&reopened, 1, &["audio-irq"]).is_empty());
738
        // A fork's proposal names another repository's bookmark.
7391×
        let mut fork = proposal(4, "landed", "audio-irq", 10);
7401×
        fork.source_repo_id = Some(2);
7411×
        assert!(landed_notes(&[fork], 1, &["audio-irq"]).is_empty());
7421×
    }
743
}