db: work on sqlite, make tests pass

This commit is contained in:
Vincent Breitmoser
2022-01-05 17:32:39 +01:00
parent 7beb5209af
commit 5778aaed84
4 changed files with 267 additions and 142 deletions

View File

@@ -1,12 +1,11 @@
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs::{create_dir_all, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::fs::create_dir_all;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::SystemTime;
use std::time::{SystemTime, UNIX_EPOCH};
use openpgp::policy::StandardPolicy;
use types::{Email, Fingerprint, KeyID};
use Result;
use {Database, Query};
@@ -17,11 +16,12 @@ use r2d2_sqlite::rusqlite::params;
use r2d2_sqlite::rusqlite::OptionalExtension;
use r2d2_sqlite::SqliteConnectionManager;
use crate::wkd;
pub const POLICY: StandardPolicy = StandardPolicy::new();
pub struct Sqlite {
pool: r2d2::Pool<SqliteConnectionManager>,
keys_dir_log: PathBuf,
dry_run: bool,
}
impl Sqlite {
@@ -75,74 +75,38 @@ impl Sqlite {
let keys_dir_log = base_dir.join("log");
create_dir_all(&keys_dir_log)?;
let dry_run = false;
let pool = Self::build_pool(manager)?;
let conn = pool.get()?;
conn.execute(
"CREATE TABLE IF NOT EXISTS certs (
fingerprint TEXT NOT NULL PRIMARY KEY,
conn.pragma_update(None, "journal_mode", "wal")?;
conn.pragma_update(None, "synchronous", "normal")?;
conn.pragma_update(None, "user_version", "1")?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS certs (
primary_fingerprint TEXT NOT NULL PRIMARY KEY,
full TEXT NOT NULL,
published TEXT,
published_not_armored BLOB
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS cert_identifiers (
published_not_armored BLOB,
updated_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS cert_identifiers (
fingerprint TEXT NOT NULL PRIMARY KEY,
keyid TEXT NOT NULL,
primary_fingerprint TEXT NOT NULL,
fingerprint TEXT NOT NULL,
keyid TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS emails (
created_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS emails (
email TEXT NOT NULL PRIMARY KEY,
primary_fingerprint TEXT NOT NULL
)",
[],
domain TEXT NOT NULL,
wkd_hash TEXT NOT NULL,
primary_fingerprint TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
",
)?;
Ok(Self {
pool,
keys_dir_log,
dry_run,
})
}
fn link_email_vks(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
todo!()
}
fn link_email_wkd(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
todo!()
}
fn unlink_email_vks(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
todo!()
}
fn unlink_email_wkd(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
todo!()
}
fn open_logfile(&self, file_name: &str) -> Result<File> {
let file_path = self.keys_dir_log.join(file_name);
Ok(OpenOptions::new()
.create(true)
.append(true)
.open(file_path)?)
}
fn perform_checks(
&self,
checks_dir: &Path,
tpks: &mut HashMap<Fingerprint, Cert>,
check: impl Fn(&Path, &Cert, &Fingerprint) -> Result<()>,
) -> Result<()> {
// XXX: stub
Ok(())
Ok(Self { pool })
}
}
@@ -151,6 +115,7 @@ impl Database for Sqlite {
type TempCert = Vec<u8>;
fn lock(&self) -> Result<Self::MutexGuard> {
// no need to lock the db. we *should* introduce transactions, though!
Ok("locked :)".to_owned())
}
@@ -158,43 +123,43 @@ impl Database for Sqlite {
Ok(content.to_vec())
}
fn write_log_append(&self, filename: &str, fpr_primary: &Fingerprint) -> Result<()> {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let fingerprint_line = format!("{:010} {}\n", timestamp, fpr_primary.to_string());
self.open_logfile(filename)?
.write_all(fingerprint_line.as_bytes())?;
fn write_log_append(&self, _filename: &str, _fpr_primary: &Fingerprint) -> Result<()> {
// this is done implicitly via created_at in sqlite, no need to do anything here
Ok(())
}
fn move_tmp_to_full(&self, file: Self::TempCert, fpr: &Fingerprint) -> Result<()> {
let conn = self.pool.get()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
let file = String::from_utf8(file)?;
conn.execute(
"
INSERT INTO certs (fingerprint, full)
VALUES (?1, ?2)
ON CONFLICT(fingerprint) DO UPDATE SET full=excluded.full;
INSERT INTO certs (primary_fingerprint, full, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(primary_fingerprint) DO UPDATE SET full=excluded.full, updated_at = excluded.updated_at
",
params![fpr.to_string(), file],
params![fpr.to_string(), file, now],
)?;
Ok(())
}
fn move_tmp_to_published(&self, file: Self::TempCert, fpr: &Fingerprint) -> Result<()> {
let conn = self.pool.get()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
let file = String::from_utf8(file)?;
conn.execute(
"
UPDATE certs
SET published = ?2
WHERE fingerprint = ?1
SET published = ?2, updated_at = ?3
WHERE primary_fingerprint = ?1
",
params![fpr.to_string(), file],
params![fpr.to_string(), file, now],
)?;
Ok(())
}
@@ -205,27 +170,32 @@ impl Database for Sqlite {
fpr: &Fingerprint,
) -> Result<()> {
let conn = self.pool.get()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
conn.execute(
"
UPDATE certs
SET published_not_armored = ?2
WHERE fingerprint = ?1
SET published_not_armored = ?2, updated_at = ?3
WHERE primary_fingerprint = ?1
",
params![fpr.to_string(), file],
params![fpr.to_string(), file, now],
)?;
Ok(())
}
fn write_to_quarantine(&self, fpr: &Fingerprint, content: &[u8]) -> Result<()> {
fn write_to_quarantine(&self, _fpr: &Fingerprint, _content: &[u8]) -> Result<()> {
Ok(())
}
fn check_link_fpr(
&self,
fpr: &Fingerprint,
fpr_target: &Fingerprint,
_fpr_target: &Fingerprint,
) -> Result<Option<Fingerprint>> {
Ok(None)
// a desync here cannot happen structurally, so always return true here
Ok(Some(fpr.clone()))
}
fn lookup_primary_fingerprint(&self, term: &Query) -> Option<Fingerprint> {
@@ -269,14 +239,18 @@ impl Database for Sqlite {
fn link_email(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let conn = self.pool.get()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
let (domain, wkd_hash) = wkd::encode_wkd(email.as_str()).expect("email must be vaild");
conn.execute(
"
INSERT INTO emails (email, primary_fingerprint)
VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE
SET email=excluded.email, primary_fingerprint=excluded.primary_fingerprint
INSERT INTO emails (email, wkd_hash, domain, primary_fingerprint, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(email) DO UPDATE SET primary_fingerprint = excluded.primary_fingerprint
",
params![email.to_string(), fpr.to_string(),],
params![email.to_string(), domain, wkd_hash, fpr.to_string(), now],
)?;
Ok(())
}
@@ -290,23 +264,28 @@ impl Database for Sqlite {
AND primary_fingerprint = ?2
",
params![email.to_string(), fpr.to_string(),],
)?;
)
.unwrap();
Ok(())
}
fn link_fpr(&self, from: &Fingerprint, primary_fpr: &Fingerprint) -> Result<()> {
let conn = self.pool.get()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64;
conn.execute(
"
INSERT INTO cert_identifiers (primary_fingerprint, fingerprint, keyid)
VALUES (?1, ?2, ?3)
ON CONFLICT(primary_fingerprint) DO UPDATE
SET fingerprint=excluded.fingerprint, keyid=excluded.keyid;
INSERT INTO cert_identifiers (fingerprint, keyid, primary_fingerprint, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(fingerprint) DO UPDATE SET primary_fingerprint = excluded.primary_fingerprint;
",
params![
primary_fpr.to_string(),
from.to_string(),
KeyID::try_from(from)?.to_string()
KeyID::try_from(from)?.to_string(),
primary_fpr.to_string(),
now,
],
)?;
Ok(())
@@ -330,7 +309,6 @@ impl Database for Sqlite {
Ok(())
}
// XXX: slow
// Lookup straight from certs table, no link resolution
fn by_fpr_full(&self, fpr: &Fingerprint) -> Option<String> {
let conn = self.pool.get().unwrap();
@@ -339,7 +317,7 @@ impl Database for Sqlite {
"
SELECT full
FROM certs
WHERE fingerprint = ?1
WHERE primary_fingerprint = ?1
",
[fpr.to_string()],
|row| row.get(0),
@@ -353,22 +331,21 @@ impl Database for Sqlite {
// Lookup the published cert straight from certs table, no link resolution
fn by_primary_fpr(&self, fpr: &Fingerprint) -> Option<String> {
let conn = self.pool.get().unwrap();
let armored_cert: Option<String> = conn
let armored_cert: Option<Option<String>> = conn
.query_row(
"
SELECT published
FROM certs
WHERE fingerprint = ?1
WHERE primary_fingerprint = ?1
",
[fpr.to_string()],
|row| row.get(0),
)
.optional()
.unwrap();
armored_cert
armored_cert.flatten()
}
// XXX: slow
fn by_fpr(&self, fpr: &Fingerprint) -> Option<String> {
let conn = self.pool.get().unwrap();
let primary_fingerprint: Option<String> = conn
@@ -390,7 +367,6 @@ impl Database for Sqlite {
}
}
// XXX: slow
fn by_email(&self, email: &Email) -> Option<String> {
let conn = self.pool.get().unwrap();
let primary_fingerprint: Option<String> = conn
@@ -412,7 +388,6 @@ impl Database for Sqlite {
}
}
// XXX: slow
fn by_email_wkd(&self, email: &Email) -> Option<Vec<u8>> {
let conn = self.pool.get().unwrap();
let primary_fingerprint: Option<String> = conn
@@ -432,7 +407,7 @@ impl Database for Sqlite {
"
SELECT published_not_armored
FROM certs
WHERE fingerprint = ?1
WHERE primary_fingerprint = ?1
",
[primary_fingerprint],
|row| row.get(0),
@@ -442,7 +417,6 @@ impl Database for Sqlite {
binary_cert
}
// XXX: slow
fn by_kid(&self, kid: &KeyID) -> Option<String> {
let conn = self.pool.get().unwrap();
let primary_fingerprint: Option<String> = conn
@@ -464,13 +438,114 @@ impl Database for Sqlite {
}
}
fn by_domain_and_hash_wkd(&self, domain: &str, wkd_hash: &str) -> Option<Vec<u8>> {
let conn = self.pool.get().unwrap();
let primary_fingerprint: Option<String> = conn
.query_row(
"
SELECT primary_fingerprint
FROM emails
WHERE domain = ?1, wkd_hash = ?2
",
[domain, wkd_hash],
|row| row.get(0),
)
.optional()
.unwrap();
let binary_cert: Option<Vec<u8>> = conn
.query_row(
"
SELECT published_not_armored
FROM certs
WHERE primary_fingerprint = ?1
",
[primary_fingerprint],
|row| row.get(0),
)
.optional()
.unwrap();
binary_cert
}
/// Checks the database for consistency.
///
/// Note that this operation may take a long time, and is
/// generally only useful for testing.
fn check_consistency(&self) -> Result<()> {
let conn = self.pool.get().unwrap();
let mut stmt = conn.prepare("SELECT primary_fingerprint, published FROM certs")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let primary_fpr: Fingerprint = row.get(0)?;
let published: String = row.get(1)?;
let cert = Cert::from_str(&published).unwrap();
let mut cert_emails: Vec<Email> = cert
.userids()
.map(|uid| uid.userid().email2().unwrap())
.flatten()
.map(|email| Email::from_str(&email))
.flatten()
.collect();
let mut db_emails: Vec<Email> = conn
.prepare("SELECT email FROM emails WHERE primary_fingerprint = ?1")?
.query_map([primary_fpr.to_string()], |row| row.get::<_, String>(0))
.unwrap()
.map(|email| Email::from_str(&email.unwrap()))
.flatten()
.collect();
cert_emails.sort();
cert_emails.dedup();
db_emails.sort();
if cert_emails != db_emails {
return Err(format_err!(
"{:?} does not have correct emails indexed, cert ${:?} db {:?}",
primary_fpr,
cert_emails,
db_emails,
));
}
let policy = &POLICY;
let mut cert_fprs: Vec<Fingerprint> = cert
.keys()
.with_policy(policy, None)
.for_certification()
.for_signing()
.map(|amalgamation| amalgamation.key().fingerprint())
.map(Fingerprint::try_from)
.flatten()
.collect();
let mut db_fprs: Vec<Fingerprint> = conn
.prepare("SELECT fingerprint FROM cert_identifiers WHERE primary_fingerprint = ?1")?
.query_map([primary_fpr.to_string()], |row| {
row.get::<_, Fingerprint>(0)
})
.unwrap()
.flatten()
.collect();
cert_fprs.sort();
db_fprs.sort();
if cert_fprs != db_fprs {
return Err(format_err!(
"{:?} does not have correct fingerprints indexed, cert ${:?} db {:?}",
primary_fpr,
cert_fprs,
db_fprs,
));
}
}
Ok(())
}
fn get_last_log_entry(&self) -> Result<Fingerprint> {
let conn = self.pool.get().unwrap();
Ok(conn.query_row(
"SELECT primary_fingerprint FROM certs ORDER BY updated_at DESC LIMIT 1",
[],
|row| row.get::<_, Fingerprint>(0),
)?)
}
}
#[cfg(test)]
@@ -480,18 +555,19 @@ mod tests {
use tempfile::TempDir;
use test;
fn open_db() -> (TempDir, Sqlite, PathBuf) {
const DATA_1: &str = "data, content doesn't matter";
const DATA_2: &str = "other data, content doesn't matter";
const FINGERPRINT_1: &str = "D4AB192964F76A7F8F8A9B357BD18320DEADFA11";
fn open_db() -> (TempDir, Sqlite) {
let tmpdir = TempDir::new().unwrap();
let db = Sqlite::new_file(tmpdir.path()).unwrap();
let log_path = db.keys_dir_log.join(db.get_current_log_filename());
(tmpdir, db, log_path)
(tmpdir, db)
}
#[test]
fn new() {
let (_tmp_dir, db, _log_path) = open_db();
let (_tmp_dir, db) = open_db();
let k1 = CertBuilder::new()
.add_userid("a@invalid.example.org")
.generate()
@@ -537,114 +613,151 @@ mod tests {
assert!(!db.merge(k3).unwrap().into_tpk_status().email_status.len() > 0);
}
#[test]
fn xx_by_fpr_full() -> Result<()> {
let (_tmp_dir, db) = open_db();
let fpr1 = Fingerprint::from_str(FINGERPRINT_1)?;
db.move_tmp_to_full(db.write_to_temp(DATA_1.as_bytes())?, &fpr1)?;
db.link_fpr(&fpr1, &fpr1)?;
assert_eq!(db.by_fpr_full(&fpr1).expect("must find key"), DATA_1);
Ok(())
}
#[test]
fn xx_by_kid() -> Result<()> {
let (_tmp_dir, db) = open_db();
let fpr1 = Fingerprint::from_str(FINGERPRINT_1)?;
db.move_tmp_to_full(db.write_to_temp(DATA_1.as_bytes())?, &fpr1)?;
db.move_tmp_to_published(db.write_to_temp(DATA_2.as_bytes())?, &fpr1)?;
db.link_fpr(&fpr1, &fpr1)?;
assert_eq!(db.by_kid(&fpr1.into()).expect("must find key"), DATA_2);
Ok(())
}
#[test]
fn xx_by_primary_fpr() -> Result<()> {
let (_tmp_dir, db) = open_db();
let fpr1 = Fingerprint::from_str(FINGERPRINT_1)?;
db.move_tmp_to_full(db.write_to_temp(DATA_1.as_bytes())?, &fpr1)?;
db.move_tmp_to_published(db.write_to_temp(DATA_2.as_bytes())?, &fpr1)?;
assert_eq!(db.by_primary_fpr(&fpr1).expect("must find key"), DATA_2);
Ok(())
}
#[test]
fn uid_verification() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_uid_verification(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn uid_deletion() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_uid_deletion(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn subkey_lookup() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_subkey_lookup(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn kid_lookup() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_kid_lookup(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn upload_revoked_tpk() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_upload_revoked_tpk(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn uid_revocation() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_uid_revocation(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn regenerate() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_regenerate(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn key_reupload() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_reupload(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn uid_replacement() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_uid_replacement(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn uid_unlinking() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_unlink_uid(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn same_email_1() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_same_email_1(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn same_email_2() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_same_email_2(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn same_email_3() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_same_email_3(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn same_email_4() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_same_email_4(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn no_selfsig() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_no_selfsig(&mut db);
db.check_consistency().expect("inconsistent database");
}
#[test]
fn bad_uids() {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::test_bad_uids(&mut db);
db.check_consistency().expect("inconsistent database");
}
@@ -664,7 +777,7 @@ mod tests {
#[test]
fn attested_key_signatures() -> Result<()> {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::attested_key_signatures(&mut db)?;
db.check_consistency()?;
Ok(())
@@ -672,7 +785,7 @@ mod tests {
#[test]
fn nonexportable_sigs() -> Result<()> {
let (_tmp_dir, mut db, log_path) = open_db();
let (_tmp_dir, mut db) = open_db();
test::nonexportable_sigs(&mut db)?;
db.check_consistency()?;
Ok(())

View File

@@ -314,17 +314,17 @@ pub fn test_regenerate(db: &mut impl Database) {
db.unlink_email(&email1, &fpr).unwrap();
assert!(db.check_consistency().is_err());
db.regenerate_links(&fpr).unwrap();
assert!(db.check_consistency().is_ok());
db.check_consistency().expect("consistency must return Ok");
db.unlink_fpr(&fpr, &fpr).unwrap();
assert!(db.check_consistency().is_err());
db.regenerate_links(&fpr).unwrap();
assert!(db.check_consistency().is_ok());
db.check_consistency().expect("consistency must return Ok");
db.unlink_fpr(&fpr_sign, &fpr).unwrap();
assert!(db.check_consistency().is_err());
db.regenerate_links(&fpr).unwrap();
assert!(db.check_consistency().is_ok());
db.check_consistency().expect("consistency must return Ok");
}
pub fn test_reupload(db: &mut impl Database) {

View File

@@ -5,6 +5,10 @@ use std::str::FromStr;
use anyhow::Error;
use openpgp::packet::UserID;
use r2d2_sqlite::rusqlite::types::FromSql;
use r2d2_sqlite::rusqlite::types::FromSqlError;
use r2d2_sqlite::rusqlite::types::FromSqlResult;
use r2d2_sqlite::rusqlite::types::ValueRef;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use Result;
@@ -77,9 +81,17 @@ impl FromStr for Email {
}
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Fingerprint([u8; 20]);
impl FromSql for Fingerprint {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
value
.as_str()
.and_then(|s| Self::from_str(s).map_err(|_| FromSqlError::InvalidType))
}
}
impl TryFrom<sequoia_openpgp::Fingerprint> for Fingerprint {
type Error = Error;

View File

@@ -319,7 +319,7 @@ pub fn key_to_response_plain(
return MyResponse::not_found_plain(describe_query_error(&i18n, &query));
};
match db.by_fpr(&fp) {
match db.by_primary_fpr(&fp) {
Some(armored) => MyResponse::key(armored, &fp),
None => MyResponse::not_found_plain(describe_query_error(&i18n, &query)),
}