OXYGENHUB
# 2c47eecfeab6 oxygenhub / crates / hub / src / protocol.rs
23.6 KiB · Rust · last changed Refs answers carry the transparency log (step A3) 18 days ago source blame history raw
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
}
31
32
/// Who a request's credential resolved to.
33
enum Caller {
34
    Anonymous,
35
    /// A site token from the configuration.
36
    Site,
37
    Token(Box<Account>, Box<db::accounts::Token>),
38
    /// A running OxygenFlow job: reads its own repository and the subrepos
39
    /// its state pins, nothing else (hub spec §13.2).
40
    Job {
41
        repo_id: i64,
42
        also: Vec<i64>,
43
    },
44
}
45
46
impl HubBackend {
47
    pub fn new(
48
        repos: Arc<Repos>,
49
        wikis: Arc<Repos>,
50
        pool: db::Pool,
51
        site_tokens: Vec<String>,
52
        runtime: tokio::runtime::Handle,
53
    ) -> HubBackend {
54
        HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)), acceptor: None }
55
    }
56
57
    /// Countersign accepted updates by keys this hub certified with `identity`.
58
    pub fn with_acceptor(mut self, identity: oxy_core::identity::Identity, secret: oxy_core::identity::SecretKey) -> Self {
59
        self.acceptor = Some(Arc::new((identity, secret)));
60
        self
61
    }
62
63
    /// The slot the application registers itself in after construction.
64
    pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> {
65
        self.app_hint.clone()
66
    }
67
68
    fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T {
69
        tokio::task::block_in_place(|| self.runtime.block_on(f))
70
    }
71
72
    fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> {
73
        let pool = self.pool.clone();
74
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
75
            Ok(Some(r)) => Ok(r),
76
            // A transferred repository still answers to its old name (#108).
77
            Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) {
78
                Ok(Some(r)) => Ok(r),
79
                Ok(None) => Err(not_found()),
80
                Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
81
            },
82
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
83
        }
84
    }
85
86
    fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> {
87
        let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) };
88
        if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) {
89
            return Ok(Caller::Site);
90
        }
91
        let hash = auth::token_hash(presented);
92
        let pool = self.pool.clone();
93
        if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
94
            let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?;
95
            let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
96
            let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?;
97
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
98
            return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() });
99
        }
100
        // An agent's grant token (keyless agents, #241): its owner's
101
        // account, as an agent's token, writing when the grant lets its
102
        // keys move references. Revoking the grant stops it at once.
103
        let grant_token = presented.starts_with(crate::keyless::GRANT_TOKEN_PREFIX);
104
        let token = if grant_token {
105
            let grant = self.block(db::accounts::agent_grant_by_token(&pool, &hash)).map_err(internal)?;
106
            let Some(g) = grant.filter(|g| g.live(db::now_ms())) else {
107
                return Err(Failure(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into()));
108
            };
109
            let _ = self.block(db::accounts::touch_agent_grant(&pool, g.id));
110
            Some(db::accounts::Token {
111
                id: 0,
112
                account_id: g.account_id,
113
                name: g.agent.clone(),
114
                hash: String::new(),
115
                scopes: if g.scope.split(',').any(|s| s.trim() == "refs") { "repo:read,repo:write" } else { "repo:read" }.into(),
116
                repos: String::new(),
117
                created_ms: g.created_ms,
118
                expires_ms: Some(g.expires_ms),
119
                last_used_ms: g.last_used_ms,
120
                renew_ms: None,
121
                agent: true,
122
            })
123
        } else {
124
            self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?
125
        };
126
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
127
        if token.expired(db::now_ms()) {
128
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
129
        }
130
        let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?;
131
        let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
132
        if account.banned {
133
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
134
        }
135
        if account.disabled {
136
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
137
        }
138
        if !grant_token {
139
            let _ = self.block(db::accounts::touch_token(&pool, token.id));
140
        }
141
        Ok(Caller::Token(Box::new(account), Box::new(token)))
142
    }
143
}
144
145
fn internal(e: sqlx::Error) -> Failure {
146
    Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
147
}
148
149
fn not_found() -> Failure {
150
    Failure(StatusCode::NOT_FOUND, "no such repository".into())
151
}
152
153
impl Backend for HubBackend {
154
    fn open(&self, name: &str) -> Result<Repo, Failure> {
155
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
156
        // `NAME.wiki` is the repository's wiki (§6.11); the row knows the
157
        // current name, so a transferred repository still answers to its
158
        // old one (#108).
159
        let (parent, is_wiki) = crate::wiki::parent_of(&parsed);
160
        let row = self.row(&parent)?;
161
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
162
        let store = if is_wiki { &self.wikis } else { &self.repos };
163
        let repo = store.open(&name).map_err(|e| match e {
164
            crate::repos::RepoError::NotFound => not_found(),
165
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
166
        })?;
167
        Ok(match &self.acceptor {
168
            Some(a) => repo.with_acceptor(a.0.clone(), a.1.clone()),
169
            None => repo,
170
        })
171
    }
172
173
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
174
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
175
        let (parent, _) = crate::wiki::parent_of(&parsed);
176
        let row = self.row(&parent)?;
177
        let caller = self.caller(headers)?;
178
        if let Caller::Job { repo_id, also } = &caller {
179
            // Only reads: of the job's own repository, and of the subrepos its
180
            // state pins that its pusher could read when the job was claimed.
181
            return match access {
182
                oxy_server::Access::Read if *repo_id == row.id || also.contains(&row.id) => Ok(()),
183
                oxy_server::Access::Read => Err(not_found()),
184
                oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())),
185
            };
186
        }
187
        let (role, scope_ok) = match &caller {
188
            Caller::Job { .. } => unreachable!(),
189
            Caller::Site => (Some(Role::Admin), true),
190
            Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true),
191
            Caller::Token(account, token) => {
192
                let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?;
193
                let needed = match access {
194
                    oxy_server::Access::Read => {
195
                        token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")
196
                    }
197
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
198
                };
199
                let current = format!("{}/{}", row.owner, row.name);
200
                let covered = token.covers(&parsed.to_string()) || token.covers(&current) || token.covers(&parent.to_string());
201
                (role, needed && covered)
202
            }
203
        };
204
        // An unreadable repository does not exist for the caller (hub spec §4.4).
205
        if !auth::roles::allows(role, Access::Read) {
206
            return Err(not_found());
207
        }
208
        // A wiki is made, empty and unbound, when someone who may write to
209
        // its repository first reaches it, so the first page can be pushed
210
        // as well as written on the web (§6.11); the push gives it its id.
211
        let (_, is_wiki) = crate::wiki::parent_of(&parsed);
212
        let may_write = match &caller {
213
            Caller::Site => true,
214
            Caller::Token(_, token) => token.has_scope("repo:write") || token.has_scope("repo:admin"),
215
            _ => false,
216
        };
217
        if is_wiki && may_write && auth::roles::allows(role, Access::Write) && !row.archived {
218
            if let Some(name) = RepoName::new(&row.owner, &row.name) {
219
                if !self.wikis.exists(&name) {
220
                    self.wikis
221
                        .create(&name, None)
222
                        .map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, format!("make the wiki: {e}")))?;
223
                }
224
            }
225
        }
226
        match access {
227
            oxy_server::Access::Read if scope_ok => Ok(()),
228
            oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())),
229
            oxy_server::Access::Push => {
230
                if matches!(caller, Caller::Anonymous) {
231
                    return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into()));
232
                }
233
                if !auth::roles::allows(role, Access::Write) {
234
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
235
                }
236
                if row.archived {
237
                    return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into()));
238
                }
239
                if !scope_ok {
240
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
241
                }
242
                Ok(())
243
            }
244
        }
245
    }
246
247
    fn default_bookmark(&self, name: &str) -> Option<String> {
248
        let parsed = RepoName::parse(name)?;
249
        if crate::wiki::parent_of(&parsed).1 {
250
            return Some(crate::wiki::BOOKMARK.into());
251
        }
252
        self.row(&parsed).ok().map(|r| r.default_bookmark)
253
    }
254
255
    fn add_log(&self, name: &str, refs: &mut oxy_core::protocol::RefsResponse) {
256
        let Some(app) = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else { return };
257
        let row = RepoName::parse(name).and_then(|p| self.row(&crate::wiki::parent_of(&p).0).ok()).map(|r| r.id);
258
        let backfill = crate::tlog::add_log(&app, row, refs);
259
        if !backfill.is_empty() {
260
            self.block(crate::tlog::log(&app, backfill));
261
        }
262
    }
263
264
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
265
        let Some(parsed) = RepoName::parse(name) else { return };
266
        // A wiki push starts no run and rings no webhook.
267
        if crate::wiki::parent_of(&parsed).1 {
268
            return;
269
        }
270
        let Ok(row) = self.row(&parsed) else { return };
271
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
272
        let (pusher, via) = match self.caller(headers) {
273
            Ok(Caller::Token(account, token)) => {
274
                (Some((account.id, account.username)), if token.agent { token.name.clone() } else { String::new() })
275
            }
276
            Ok(Caller::Site) => (Some((0, "site token".to_string())), String::new()),
277
            _ => (None, String::new()),
278
        };
279
        let pool = self.pool.clone();
280
        // A part of a long push (objects alone) moves nothing: the push it
281
        // belongs to is recorded when its last part names the references.
282
        let results: Vec<db::repos::PushOutcome> = report
283
            .results
284
            .iter()
285
            .filter(|r| r.name != oxy_core::repo::OBJECTS_RESULT)
286
            .map(|r| {
287
                let updated = report.updated.iter().find(|u| u.name == r.name);
288
                db::repos::PushOutcome {
289
                    reference: r.name.clone(),
290
                    ok: r.ok,
291
                    reason: r.reason.clone(),
292
                    old_state: updated.map(|u| u.old_state.to_hex()).unwrap_or_default(),
293
                    new_state: updated.map(|u| u.new_state.to_hex()).unwrap_or_default(),
294
                    heads: updated.map(|u| u.heads.len() as i64).unwrap_or(0),
295
                    changes: if r.ok { report.imported.len() as i64 } else { 0 },
296
                }
297
            })
298
            .collect();
299
        // Messages of the changes stored: `closes #N` when the default
300
        // bookmark moved, and `#N` references on any bookmark (hub spec §8).
301
        let bookmarks: Vec<String> = report
302
            .updated
303
            .iter()
304
            .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name))
305
            .map(|u| u.name.clone())
306
            .collect();
307
        let imported: Vec<(String, String)> = if bookmarks.is_empty() {
308
            vec![]
309
        } else {
310
            self.repos
311
                .open(&parsed)
312
                .map(|r| {
313
                    report.imported.iter().filter_map(|h| r.store().get_change_meta(h).ok().map(|c| (h.to_hex(), c.message))).collect()
314
                })
315
                .unwrap_or_default()
316
        };
317
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
318
        // Authors whose identity is a successor (a rotation, a renewal, a
319
        // delegation) of an earlier one: each step kept the key pair, so
320
        // if the chain reaches a linked key it is the same person, and the
321
        // successor is linked too, without re-uploading it (hub spec §5.3).
322
        let successors: Vec<(String, String, String, Vec<u8>, String)> = self
323
            .repos
324
            .open(&parsed)
325
            .map(|r| {
326
                let mut seen = std::collections::HashSet::new();
327
                let mut out = Vec::new();
328
                for h in &report.imported {
329
                    let Ok(meta) = r.store().get_change_meta(h) else { continue };
330
                    let mut key = meta.author;
331
                    while !key.is_zero() && seen.insert(key) {
332
                        let Ok(id) = r.store().get_identity(&key) else { break };
333
                        let Some(prev) = id.previous else { break };
334
                        let Ok(before) = r.store().get_identity(&prev) else { break };
335
                        if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk {
336
                            break;
337
                        }
338
                        out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex()));
339
                        key = prev;
340
                    }
341
                }
342
                out
343
            })
344
            .unwrap_or_default();
345
        let on = if bookmarks.contains(&row.default_bookmark) {
346
            row.default_bookmark.clone()
347
        } else {
348
            bookmarks.first().cloned().unwrap_or_default()
349
        };
350
        // Bookmarks that moved, with their new heads, for OxygenFlow runs.
351
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
352
            .updated
353
            .iter()
354
            // A bookmark sent again unchanged starts nothing.
355
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
356
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
357
            .collect();
358
        // The transparency log (#238): what the push brought, logged before
359
        // the push is answered.
360
        let app = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
361
        if let Some(app) = app {
362
            let to_log = self.repos.open(&parsed).map(|r| crate::tlog::push_entries(&r, Some(row.id), report)).unwrap_or_default();
363
            if !to_log.is_empty() {
364
                self.block(crate::tlog::log(&app, to_log));
365
            }
366
        }
367
        let app_hint = self.app_hint.clone();
368
        self.runtime.spawn(async move {
369
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
370
            if let Err(e) = db::repos::record_push_via(&pool, row.id, who, &via, &results).await {
371
                tracing::warn!("recording push to {}: {e}", parsed);
372
            }
373
            // Oldest first: a link made for one step lets the next step link.
374
            for (key, name, email, object, prev) in successors.iter().rev() {
375
                let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue };
376
                let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue };
377
                match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await {
378
                    Ok(()) => {
379
                        tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]);
380
                        db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok();
381
                    }
382
                    Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]),
383
                }
384
            }
385
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
386
            if let Some(app) = app {
387
                // Issues close from every change newly on the default
388
                // bookmark, not only those this push imported: a client's
389
                // landing moves main to changes the hub already held.
390
                if let Some(r) = results.iter().find(|r| r.ok && r.reference == row.default_bookmark) {
391
                    let new_heads = moved.iter().find(|(b, _, _)| *b == row.default_bookmark).map(|(_, _, h)| h.clone()).unwrap_or_default();
392
                    let old = r.old_state.clone();
393
                    let old_heads = crate::repos::RepoName::new(&row.owner, &row.name);
394
                    let old_heads = match (old_heads, oxy_core::hash::ObjectRef::from_hex(&old)) {
395
                        (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(),
396
                        _ => Vec::new(),
397
                    };
398
                    crate::web::issues::close_from_arrived(&app, &row, old_heads, new_heads, None).await;
399
                } else {
400
                    crate::web::issues::close_from_messages(&app, &row, &messages).await;
401
                }
402
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
403
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
404
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
405
                    crate::mirror::refresh(&app, &row).await;
406
                }
407
                let accepted: Vec<serde_json::Value> = results
408
                    .iter()
409
                    .filter(|r| r.ok)
410
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
411
                    .collect();
412
                if !accepted.is_empty() {
413
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
414
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
415
                }
416
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
417
                // The moved bookmarks' briefs, worked out now so no page
418
                // waits for them (#145).
419
                if let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) {
420
                    let all: Vec<Vec<oxy_core::hash::ChangeHash>> = moved.iter().map(|(_, _, h)| h.clone()).collect();
421
                    let warm = app.clone();
422
                    tokio::spawn(async move {
423
                        warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok();
424
                    });
425
                }
426
                crate::diffusion::after_push(&app, &row, &moved).await;
427
                // An upstream's default bookmark moved: consumers following
428
                // every move are bumped now, those following green when
429
                // its run passes (#148).
430
                if let Some((_, _, heads)) = moved.iter().find(|(b, _, _)| *b == row.default_bookmark) {
431
                    let (app, row, heads) = (app.clone(), row.clone(), heads.clone());
432
                    tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
433
                }
434
                for (bookmark, state, heads) in &moved {
435
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
436
                    crate::web::proposals::settle_after_push(&app, &row, bookmark, &actor).await;
437
                }
438
            }
439
        });
440
    }
441
}
442
443
/// `/{owner}/{name}/oxy/v1/{endpoint}`.
444
pub async fn dispatch(
445
    State(app): State<Shared>,
446
    Path((owner, name, endpoint)): Path<(String, String, String)>,
447
    method: Method,
448
    headers: HeaderMap,
449
    body: Bytes,
450
) -> Response {
451
    let Some(endpoint) = Endpoint::parse(&endpoint) else {
452
        return Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response();
453
    };
454
    let Some(repo) = RepoName::new(&owner, &name) else {
455
        return not_found().into_response();
456
    };
457
    let _guard = match endpoint {
458
        Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await),
459
        _ => Guard::Read(app.repos.read_guard(&repo).await),
460
    };
461
    app.server.handle(repo.to_string(), endpoint, &method, &headers, body).await
462
}
463
464
enum Guard {
465
    Read(#[allow(dead_code)] tokio::sync::OwnedRwLockReadGuard<()>),
466
    Write(#[allow(dead_code)] tokio::sync::OwnedRwLockWriteGuard<()>),
467
}