From 38d32811e48ef44360d104196513ff3d6fa02236 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 30 Sep 2026 11:26:18 -0700 Subject: [PATCH] Create the archive readable by its owner only An archive holds a whole mailbox, its contacts and calendars, and it was created with SQLite's default mode, readable by every user on the machine. On Unix a new archive is now created with mode 0600 before SQLite opens it; SQLite gives the -wal and -shm files the database file's mode, so they follow, which a test confirms. An existing archive that others can read is left as it is, with a warning naming it and the chmod that fixes it. Nothing changes on Windows. --- src/db/init.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/db/init.rs b/src/db/init.rs index 1e15265..ced5a17 100644 --- a/src/db/init.rs +++ b/src/db/init.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -9,6 +10,7 @@ use rusqlite::Connection; pub const SCHEMA_SQL: &str = include_str!("schema.sql"); pub fn open(path: &std::path::Path) -> Result { + private_archive(path)?; let conn = Connection::open(path)?; apply_pragmas(&conn)?; apply_schema(&conn)?; @@ -72,6 +74,50 @@ fn ensure_graph_ids_accept_file_nodes(conn: &Connection) -> Result<(), OpenError Ok(()) } +/// An archive holds a whole mailbox, so it is created readable by its owner +/// only. SQLite gives its `-wal` and `-shm` files the database file's mode, +/// so they follow. An existing archive others can read is left as it is, +/// with a warning and the command that fixes it. +#[cfg(unix)] +fn private_archive(path: &std::path::Path) -> Result<(), OpenError> { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + { + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if let Some(warning) = permission_warning(path) { + eprintln!("warning: {warning}"); + } + Ok(()) + } + Err(e) => Err(OpenError::Create(e)), + } +} + +#[cfg(not(unix))] +fn private_archive(_path: &std::path::Path) -> Result<(), OpenError> { + Ok(()) +} + +/// The warning for an archive that someone other than its owner can read. +#[cfg(unix)] +fn permission_warning(path: &std::path::Path) -> Option { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(path).ok()?.permissions().mode(); + (mode & 0o077 != 0).then(|| { + format!( + "archive {} can be read by other users (mode {:o}); run: chmod 600 {}", + path.display(), + mode & 0o777, + path.display() + ) + }) +} + fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; @@ -83,4 +129,53 @@ fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> { pub enum OpenError { #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), + #[error("cannot create the archive: {0}")] + Create(std::io::Error), +} + +#[cfg(all(test, unix))] +mod permission_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn mode(p: &std::path::Path) -> u32 { + std::fs::metadata(p).unwrap().permissions().mode() & 0o777 + } + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("inbuxa-migrate-perm-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("a.sqlite") + } + + #[test] + fn a_new_archive_and_its_wal_are_private() { + let path = scratch("new"); + let conn = open(&path).unwrap(); + conn.execute_batch("CREATE TABLE t(x); INSERT INTO t VALUES (1);") + .unwrap(); + assert_eq!(mode(&path), 0o600); + let wal = path.with_extension("sqlite-wal"); + assert!(wal.exists(), "WAL mode writes a -wal file"); + assert_eq!(mode(&wal), 0o600); + drop(conn); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn an_existing_readable_archive_is_warned_about_not_changed() { + let path = scratch("existing"); + drop(open(&path).unwrap()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let warning = permission_warning(&path).expect("warns"); + assert!(warning.contains("chmod 600"), "{warning}"); + assert!(warning.contains("644"), "{warning}"); + drop(open(&path).unwrap()); + assert_eq!(mode(&path), 0o644, "left as it is"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(permission_warning(&path).is_none()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } }