1use std::ffi::c_int;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use rusqlite::{Connection, ErrorCode};
8
9use crate::envelope::Key;
10
11pub const SCHEMA_VERSION: i64 = 2;
14
15const VCON_STORED_AT: &str = "
20ALTER TABLE vcon ADD COLUMN stored_at INTEGER NOT NULL DEFAULT 0;
21UPDATE vcon SET stored_at =
22 coalesce((SELECT min(stored_at) FROM version WHERE version.uuid = vcon.uuid), 0);
23CREATE INDEX vcon_listed ON vcon (stored_at, uuid);
24";
25
26pub const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
31
32#[derive(Debug)]
34pub enum CatalogError {
35 WrongKey,
37 UnknownSchema(i64),
39 Sqlite(rusqlite::Error),
41 Io(std::io::Error),
43}
44
45impl From<rusqlite::Error> for CatalogError {
46 fn from(e: rusqlite::Error) -> CatalogError {
47 CatalogError::Sqlite(e)
48 }
49}
50
51pub fn open(path: &Path, key: &Key, schema: &str) -> Result<Connection, CatalogError> {
54 let conn = Connection::open(path)?;
55 conn.busy_timeout(BUSY_TIMEOUT)?;
56 conn.execute_batch("PRAGMA cipher_log_level = NONE")?;
61 set_key(&conn, key)?;
62 match conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) {
65 Err(e) if e.sqlite_error_code() == Some(ErrorCode::NotADatabase) => {
66 return Err(CatalogError::WrongKey);
67 }
68 other => other?,
69 }
70 let mode: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get(0))?;
74 debug_assert_eq!(mode, "wal");
75 conn.pragma_update(None, "synchronous", "FULL")?;
76 conn.pragma_update(None, "secure_delete", "ON")?;
77 conn.pragma_update(None, "temp_store", "MEMORY")?;
78 match conn.pragma_query_value(None, "user_version", |r| r.get(0))? {
79 0 => {
80 let tx = conn.unchecked_transaction()?;
81 tx.execute_batch(schema)?;
82 tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
83 tx.commit()?;
84 }
85 1 => {
86 let tx = conn.unchecked_transaction()?;
88 let tenant: bool = tx.query_row(
89 "SELECT count(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = 'vcon'",
90 [],
91 |r| r.get(0),
92 )?;
93 if tenant {
94 tx.execute_batch(VCON_STORED_AT)?;
95 }
96 tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
97 tx.commit()?;
98 }
99 SCHEMA_VERSION => {}
100 other => return Err(CatalogError::UnknownSchema(other)),
101 }
102 Ok(conn)
103}
104
105pub fn reencrypt(path: &Path, old: &Key, new: &Key) -> Result<(), CatalogError> {
112 let copy = copy_under(path, old, new)?;
113 replace(path, ©)
114}
115
116pub fn copy_under(path: &Path, old: &Key, new: &Key) -> Result<PathBuf, CatalogError> {
121 let target = sibling(path, ".rekey");
122 for stale in [
123 target.clone(),
124 sibling(&target, "-wal"),
125 sibling(&target, "-shm"),
126 ] {
127 remove_if_there(&stale)?;
128 }
129 let conn = open(path, old, "")?;
130 let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
131 let target_text = target
132 .to_str()
133 .ok_or_else(|| CatalogError::Io(std::io::Error::other("the catalog path is not UTF-8")))?;
134 conn.execute("ATTACH DATABASE ?1 AS rekeyed", [target_text])?;
135 set_key_of(&conn, c"rekeyed", new)?;
136 conn.query_row("SELECT sqlcipher_export('rekeyed')", [], |_| Ok(()))?;
137 conn.execute_batch(&format!("PRAGMA rekeyed.user_version = {version}"))?;
140 conn.execute("DETACH DATABASE rekeyed", [])?;
141 Ok(target)
142}
143
144pub fn replace(path: &Path, copy: &Path) -> Result<(), CatalogError> {
149 for file in [path, copy] {
150 let wal = sibling(file, "-wal");
151 if std::fs::metadata(&wal).is_ok_and(|m| m.len() > 0) {
152 return Err(CatalogError::Io(std::io::Error::other(
153 "a catalog's WAL was not emptied",
154 )));
155 }
156 remove_if_there(&wal)?;
157 remove_if_there(&sibling(file, "-shm"))?;
158 }
159 let io = CatalogError::Io;
160 std::fs::File::open(copy)
161 .and_then(|f| f.sync_all())
162 .map_err(io)?;
163 std::fs::rename(copy, path).map_err(io)?;
164 let dir = path.parent().unwrap_or(Path::new("."));
165 std::fs::File::open(dir)
166 .and_then(|f| f.sync_all())
167 .map_err(io)?;
168 Ok(())
169}
170
171fn remove_if_there(file: &Path) -> Result<(), CatalogError> {
172 match std::fs::remove_file(file) {
173 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(CatalogError::Io(e)),
174 _ => Ok(()),
175 }
176}
177
178fn sibling(path: &Path, suffix: &str) -> PathBuf {
180 let mut name = path.as_os_str().to_owned();
181 name.push(suffix);
182 PathBuf::from(name)
183}
184
185fn set_key_of(conn: &Connection, schema: &std::ffi::CStr, key: &Key) -> Result<(), CatalogError> {
187 let literal = key.sqlcipher_raw();
188 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
189 let rc = unsafe {
193 rusqlite::ffi::sqlite3_key_v2(conn.handle(), schema.as_ptr(), literal.as_ptr().cast(), len)
194 };
195 if rc != rusqlite::ffi::SQLITE_OK {
196 return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
197 }
198 Ok(())
199}
200
201fn set_key(conn: &Connection, key: &Key) -> Result<(), CatalogError> {
205 let literal = key.sqlcipher_raw();
206 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
207 let rc = unsafe { rusqlite::ffi::sqlite3_key(conn.handle(), literal.as_ptr().cast(), len) };
210 if rc != rusqlite::ffi::SQLITE_OK {
213 return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
214 }
215 Ok(())
216}
217
218#[cfg(test)]
220pub(crate) mod testing {
221 use rusqlite::Connection;
222
223 pub(crate) fn columns_taking_null(
227 conn: &Connection,
228 table: &str,
229 row: &[(&str, &str)],
230 ) -> Vec<String> {
231 let insert = |empty: Option<&str>| {
232 let columns: Vec<&str> = row.iter().map(|(c, _)| *c).collect();
233 let values: Vec<&str> = row
234 .iter()
235 .map(|(c, v)| if Some(*c) == empty { "NULL" } else { *v })
236 .collect();
237 let sql = format!(
238 "INSERT INTO {table} ({}) VALUES ({})",
239 columns.join(", "),
240 values.join(", ")
241 );
242 let tx = conn.unchecked_transaction().expect("begin");
243 let taken = tx.execute(&sql, []).is_ok();
244 drop(tx);
245 taken
246 };
247 assert!(insert(None), "a whole {table} row inserts");
248 row.iter()
249 .filter(|(column, _)| insert(Some(column)))
250 .map(|(column, _)| format!("{table}.{column}"))
251 .collect()
252 }
253}
254
255#[cfg(test)]
256pub(crate) mod tests {
257 use super::*;
258
259 const SCHEMA: &str = "CREATE TABLE note (text TEXT NOT NULL);";
260 const MARKER: &str = "plain-record-marker-5f3c9a";
261
262 fn path(dir: &tempfile::TempDir) -> std::path::PathBuf {
263 dir.path().join("catalog.sqlite")
264 }
265
266 pub(crate) fn cipher_setting(conn: &Connection, name: &str) -> String {
269 conn.query_row(&format!("PRAGMA {name}"), [], |r| r.get(0))
270 .expect(name)
271 }
272
273 #[test]
274 fn a_catalog_runs_with_the_required_settings() {
275 let dir = tempfile::tempdir().expect("temp dir");
276 let key = Key::generate().expect("key");
277 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
278 let text = |p: &str| -> String { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
279 let number = |p: &str| -> i64 { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
280 assert_eq!(text("journal_mode"), "wal");
281 assert_eq!(number("synchronous"), 2, "FULL");
282 assert_eq!(number("secure_delete"), 1);
283 assert_eq!(number("temp_store"), 2, "MEMORY");
284 assert_eq!(number("busy_timeout"), 5000, "milliseconds");
285 assert_eq!(cipher_setting(&conn, "cipher_provider"), "openssl");
286 assert_eq!(
287 cipher_setting(&conn, "cipher_log_level"),
288 "NONE",
289 "SQLCipher writes no log of its own"
290 );
291 }
292
293 #[test]
300 fn sqlcipher_settings_read_the_same_through_the_pragma_helpers() {
301 let dir = tempfile::tempdir().expect("temp dir");
302 let key = Key::generate().expect("key");
303 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
304 let names = ["cipher_log_level", "cipher_provider", "cipher_page_size"];
305 let plain: Vec<String> = names.iter().map(|n| cipher_setting(&conn, n)).collect();
306
307 let helpers = rusqlite_fork::Connection::open(path(&dir)).expect("opens again");
308 let literal = key.sqlcipher_raw();
309 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
310 let rc = unsafe {
313 rusqlite_fork::ffi::sqlite3_key(helpers.handle(), literal.as_ptr().cast(), len)
314 };
315 assert_eq!(rc, rusqlite_fork::ffi::SQLITE_OK);
316 helpers
319 .pragma_update(None, "cipher_log_level", "NONE")
320 .expect("cipher_log_level through pragma_update");
321 let notes: i64 = helpers
322 .query_row("SELECT count(*) FROM note", [], |r| r.get(0))
323 .expect("the key opens the same catalog");
324 assert_eq!(notes, 0);
325 let read: Vec<String> = names
326 .iter()
327 .map(|n| {
328 helpers
329 .pragma_query_value(None, n, |r| r.get(0))
330 .unwrap_or_else(|e| panic!("{n} through pragma_query_value: {e}"))
331 })
332 .collect();
333 assert_eq!(read, plain);
334 assert_eq!(plain[0], "NONE");
335 }
336
337 #[test]
342 fn crates_io_rusqlite_refuses_sqlcipher_names() {
343 let conn = Connection::open_in_memory().expect("opens");
344 let refused = conn.pragma_update(None, "cipher_log_level", "NONE");
345 assert!(
346 matches!(&refused, Err(rusqlite::Error::SqliteFailure(_, Some(m)))
347 if m == "Invalid pragma \"cipher_log_level\""),
348 "this rusqlite accepts SQLCipher's pragma names ({refused:?}): set them with \
349 its pragma helpers in catalog::open and cipher_setting, and remove the \
350 rusqlite_fork dev-dependency and its patch (see the Development page)"
351 );
352 }
353
354 mod fork_docs {
357 use std::path::PathBuf;
358
359 const FORK: &str = "https://github.com/NormB/rusqlite";
360
361 fn read(relative: &str) -> String {
362 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
363 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
364 }
365
366 fn table(text: &str) -> toml::Table {
367 text.parse().expect("toml")
368 }
369
370 fn pinned() -> String {
373 let manifest = table(&read("Cargo.toml"));
374 let server = &manifest["dependencies"]["rusqlite"];
375 assert!(
376 server.get("version").is_some(),
377 "the server's rusqlite has a version"
378 );
379 assert!(
380 server.get("git").is_none() && server.get("path").is_none(),
381 "the server's rusqlite comes from crates.io"
382 );
383 let fork = &manifest["dev-dependencies"]["rusqlite_fork"];
384 assert_eq!(fork["package"].as_str(), Some("rusqlite"));
385 assert_eq!(fork["git"].as_str(), Some(FORK));
386 let rev = fork["rev"].as_str().expect("a rev").to_owned();
387 assert!(
388 rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()),
389 "a full commit id: {rev}"
390 );
391 rev
392 }
393
394 #[test]
395 fn only_the_fork_is_patched() {
396 pinned();
397 let workspace = table(&read("../../Cargo.toml"));
398 let patch = workspace["patch"].as_table().expect("a patch table");
399 assert_eq!(
400 patch.keys().collect::<Vec<_>>(),
401 [FORK],
402 "no crates-io patch"
403 );
404 let sys: Vec<&String> = patch[FORK].as_table().expect("a table").keys().collect();
405 assert_eq!(sys, ["libsqlite3-sys"]);
406 }
407
408 #[test]
409 fn the_development_page_names_the_pinned_commit() {
410 let page = read("../../website/content/docs/development.md");
411 let section = page
412 .split("\n## ")
413 .find(|s| s.starts_with("Test dependency from a fork\n"))
414 .expect("a section on the fork");
415 assert!(
416 section.contains(&format!("`{}`", pinned())),
417 "the pinned commit"
418 );
419 assert!(section.contains(FORK), "the fork's address");
420 let lock = read("../../Cargo.lock");
421 let version = lock
422 .split("[[package]]")
423 .find(|p| p.contains(&format!("source = \"git+{FORK}?rev=")))
424 .and_then(|p| p.lines().find_map(|l| l.strip_prefix("version = \"")))
425 .map(|v| v.trim_end_matches('"'))
426 .expect("the fork in Cargo.lock");
427 assert!(
428 section.contains(&format!("`{version}`")),
429 "the fork's version {version}"
430 );
431 let source = include_str!("catalog.rs");
432 for test in [
433 "sqlcipher_settings_read_the_same_through_the_pragma_helpers",
434 "crates_io_rusqlite_refuses_sqlcipher_names",
435 ] {
436 assert!(section.contains(&format!("`{test}`")), "names {test}");
437 assert!(source.contains(&format!("fn {test}()")), "{test} exists");
438 }
439 }
440
441 #[test]
442 fn getting_started_says_what_a_build_fetches() {
443 let page = read("../../website/content/docs/getting-started.md");
444 let build = page
445 .split("\n## ")
446 .find(|s| s.starts_with("Build\n"))
447 .expect("a Build section");
448 assert!(
449 build.contains("github.com/NormB/rusqlite"),
450 "names the fork"
451 );
452 assert!(
453 build.contains("mkdir -p .cargo && cargo vendor >> .cargo/config.toml\n"),
454 "the vendoring command"
455 );
456 assert!(build.contains("cargo build --release --offline\n"));
457 let release = page
458 .split("\n## ")
459 .find(|s| s.starts_with("Or download a release\n"))
460 .expect("a release section");
461 assert!(
462 release.contains("crates.io"),
463 "says which rusqlite a release holds"
464 );
465 }
466 }
467
468 #[test]
469 fn the_schema_is_made_once_at_the_current_version() {
470 let dir = tempfile::tempdir().expect("temp dir");
471 let key = Key::generate().expect("key");
472 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
473 let version: i64 = conn
474 .pragma_query_value(None, "user_version", |r| r.get(0))
475 .expect("version");
476 assert_eq!(version, SCHEMA_VERSION);
477 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
478 .expect("insert");
479 drop(conn);
480 let conn =
481 open(&path(&dir), &key, SCHEMA).expect("reopens without making the schema again");
482 let kept: String = conn
483 .query_row("SELECT text FROM note", [], |r| r.get(0))
484 .expect("row");
485 assert_eq!(kept, "kept");
486 }
487
488 #[test]
489 fn an_unknown_schema_version_is_refused() {
490 let dir = tempfile::tempdir().expect("temp dir");
491 let key = Key::generate().expect("key");
492 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
493 conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
494 .expect("set version");
495 drop(conn);
496 assert!(matches!(
497 open(&path(&dir), &key, SCHEMA),
498 Err(CatalogError::UnknownSchema(v)) if v == SCHEMA_VERSION + 1
499 ));
500 }
501
502 #[test]
506 fn a_version_1_catalog_is_upgraded_to_version_2() {
507 let dir = tempfile::tempdir().expect("temp dir");
508 let key = Key::generate().expect("key");
509 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
510 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
511 .expect("insert");
512 conn.pragma_update(None, "user_version", 1)
513 .expect("set version");
514 drop(conn);
515 let conn = open(&path(&dir), &key, "").expect("upgrades");
516 let version: i64 = conn
517 .pragma_query_value(None, "user_version", |r| r.get(0))
518 .expect("version");
519 assert_eq!(version, 2);
520 assert_eq!(notes(&conn), vec!["kept"]);
521 }
522
523 fn rekeyed(dir: &tempfile::TempDir) -> std::path::PathBuf {
525 dir.path().join("catalog.sqlite.rekey")
526 }
527
528 fn notes(conn: &Connection) -> Vec<String> {
529 conn.prepare("SELECT text FROM note ORDER BY rowid")
530 .expect("prepare")
531 .query_map([], |r| r.get(0))
532 .expect("query")
533 .collect::<Result<_, _>>()
534 .expect("rows")
535 }
536
537 #[test]
538 fn a_reencrypted_catalog_opens_under_the_new_key_only_and_keeps_every_row() {
539 let dir = tempfile::tempdir().expect("temp dir");
540 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
541 let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
542 for n in 0..300 {
543 conn.execute("INSERT INTO note (text) VALUES (?1)", [format!("note {n}")])
544 .expect("insert");
545 }
546 drop(conn);
547 reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
548 let raw = std::fs::read(path(&dir)).expect("the file");
549 assert!(
550 !raw.windows(8).any(|w| w == b"note 299"),
551 "no row is written in the clear"
552 );
553 assert!(matches!(
554 open(&path(&dir), &old, SCHEMA),
555 Err(CatalogError::WrongKey)
556 ));
557 let conn = open(&path(&dir), &new, SCHEMA).expect("opens under the new key");
558 let kept = notes(&conn);
559 assert_eq!(kept.len(), 300);
560 assert_eq!(kept[299], "note 299");
561 let version: i64 = conn
562 .pragma_query_value(None, "user_version", |r| r.get(0))
563 .expect("version");
564 assert_eq!(version, SCHEMA_VERSION, "the schema version is kept");
565 let mode: String = conn
566 .pragma_query_value(None, "journal_mode", |r| r.get(0))
567 .expect("mode");
568 assert_eq!(mode, "wal");
569 drop(conn);
570 let mut left: Vec<String> = std::fs::read_dir(dir.path())
571 .expect("list")
572 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
573 .collect();
574 left.sort();
575 assert_eq!(left, ["catalog.sqlite"], "no temporary file is left");
576 }
577
578 #[test]
579 fn a_file_left_by_an_interrupted_reencryption_is_replaced() {
580 let dir = tempfile::tempdir().expect("temp dir");
581 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
582 let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
583 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
584 .expect("insert");
585 drop(conn);
586 std::fs::write(rekeyed(&dir), b"half a file").expect("leftover");
587 reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
588 let conn = open(&path(&dir), &new, SCHEMA).expect("opens");
589 assert_eq!(notes(&conn), ["kept"]);
590 assert!(!rekeyed(&dir).exists());
591 }
592
593 #[test]
597 fn a_replace_refuses_while_either_wal_holds_frames() {
598 let dir = tempfile::tempdir().expect("temp dir");
599 let key = Key::generate().expect("key");
600 let copy = dir.path().join("catalog.sqlite.rekey");
601 for busy in [path(&dir), copy.clone()] {
602 for file in [path(&dir), copy.clone()] {
603 for leftover in ["", "-wal", "-shm"] {
604 let _ = std::fs::remove_file(sibling(&file, leftover));
605 }
606 drop(open(&file, &key, SCHEMA).expect("made"));
607 }
608 let held = open(&busy, &key, SCHEMA).expect("opens");
609 held.pragma_update(None, "wal_autocheckpoint", 0)
610 .expect("no checkpoint");
611 held.execute("INSERT INTO note (text) VALUES ('in the wal')", [])
612 .expect("insert");
613 assert!(
614 std::fs::metadata(sibling(&busy, "-wal")).is_ok_and(|m| m.len() > 0),
615 "the WAL holds the row"
616 );
617 assert!(
618 matches!(replace(&path(&dir), ©), Err(CatalogError::Io(_))),
619 "{}",
620 busy.display()
621 );
622 assert!(copy.exists(), "nothing was renamed");
623 drop(held);
624 }
625 }
626
627 #[test]
628 fn reencrypting_under_the_wrong_key_changes_nothing() {
629 let dir = tempfile::tempdir().expect("temp dir");
630 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
631 drop(open(&path(&dir), &old, SCHEMA).expect("opens"));
632 let other = Key::generate().expect("key");
633 assert!(matches!(
634 reencrypt(&path(&dir), &other, &new),
635 Err(CatalogError::WrongKey)
636 ));
637 assert!(
638 open(&path(&dir), &old, SCHEMA).is_ok(),
639 "still under the old key"
640 );
641 }
642
643 #[test]
644 fn a_wrong_key_does_not_open_the_catalog() {
645 let dir = tempfile::tempdir().expect("temp dir");
646 let key = Key::generate().expect("key");
647 drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
648 let other = Key::generate().expect("key");
649 assert!(matches!(
650 open(&path(&dir), &other, SCHEMA),
651 Err(CatalogError::WrongKey)
652 ));
653 assert!(
654 open(&path(&dir), &key, SCHEMA).is_ok(),
655 "the right key still opens it"
656 );
657 }
658
659 #[test]
662 #[should_panic(expected = "a whole note row inserts")]
663 fn the_null_check_needs_a_row_that_inserts() {
664 let dir = tempfile::tempdir().expect("temp dir");
665 let key = Key::generate().expect("key");
666 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
667 testing::columns_taking_null(&conn, "note", &[("misspelt", "'a'")]);
668 }
669
670 #[test]
672 fn no_plain_record_reaches_the_catalog_or_its_wal() {
673 let dir = tempfile::tempdir().expect("temp dir");
674 let key = Key::generate().expect("key");
675 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
676 conn.execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
677 .expect("insert");
678 let wal = std::fs::metadata(dir.path().join("catalog.sqlite-wal")).expect("a WAL");
679 assert!(wal.len() > 0, "the commit is in the WAL");
680 for entry in std::fs::read_dir(dir.path()).expect("list") {
681 let file = entry.expect("entry").path();
682 let bytes = std::fs::read(&file).expect("read");
683 assert!(
684 !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
685 "plain record in {}",
686 file.display()
687 );
688 }
689 drop(conn);
690 }
691}