use std::path::{Path, PathBuf};
use autumn_web::replication;
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
use diesel::sql_types::Text;
const BUSY_TIMEOUT_MS: u32 = 30_000;
#[derive(Debug)]
pub enum SnapshotError {
NotAFile {
target: String,
},
Missing {
path: String,
},
Open {
path: String,
detail: String,
},
Query {
op: &'static str,
detail: String,
},
Integrity {
detail: String,
},
Io {
context: String,
detail: String,
},
}
impl std::fmt::Display for SnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotAFile { target } => write!(
f,
"the target {:?} names no SQLite database file (an in-memory database cannot \
be backed up, and a Postgres URL is not a SQLite target).\n Point \
`database.url` at a file, e.g. sqlite:///var/lib/myapp/app.db.",
replication::redact_credentials(target)
),
Self::Missing { path } => write!(
f,
"the SQLite database file {path} does not exist.\n Run `autumn migrate` (or \
start the app) to create it before backing it up."
),
Self::Open { path, detail } => {
write!(f, "could not open the SQLite database at {path}: {detail}")
}
Self::Query { op, detail } => write!(f, "{op} failed: {detail}"),
Self::Integrity { detail } => write!(f, "SQLite integrity check failed: {detail}"),
Self::Io { context, detail } => write!(f, "{context}: {detail}"),
}
}
}
impl SnapshotError {
fn io(context: impl Into<String>) -> impl FnOnce(std::io::Error) -> Self {
let context = context.into();
move |e| Self::Io {
context,
detail: e.to_string(),
}
}
}
pub fn database_path(target: &str) -> Result<PathBuf, SnapshotError> {
replication::database_file(target).ok_or_else(|| SnapshotError::NotAFile {
target: target.to_owned(),
})
}
fn open(path: &Path) -> Result<SqliteConnection, SnapshotError> {
if !path.exists() {
return Err(SnapshotError::Missing {
path: path.display().to_string(),
});
}
let Some(target) = replication::connection_string(path) else {
return Err(SnapshotError::Open {
path: path.display().to_string(),
detail: "the path is not valid UTF-8, and SQLite is opened through a UTF-8 \
connection string"
.to_owned(),
});
};
let mut conn = SqliteConnection::establish(&target).map_err(|e| SnapshotError::Open {
path: target.clone(),
detail: e.to_string(),
})?;
conn.batch_execute(&format!("PRAGMA busy_timeout = {BUSY_TIMEOUT_MS};"))
.map_err(|e| SnapshotError::Query {
op: "busy_timeout",
detail: e.to_string(),
})?;
Ok(conn)
}
pub fn snapshot(target: &str, out_path: &Path) -> Result<(), SnapshotError> {
let db = database_path(target)?;
let mut conn = open(&db)?;
let out = out_path.to_string_lossy().into_owned();
diesel::sql_query("VACUUM INTO ?")
.bind::<Text, _>(&out)
.execute(&mut conn)
.map_err(|e| SnapshotError::Query {
op: "VACUUM INTO",
detail: e.to_string(),
})?;
flush(out_path)
}
pub fn verify(path: &Path) -> Result<(), SnapshotError> {
let len = std::fs::metadata(path)
.map_err(SnapshotError::io(format!("stat {}", path.display())))?
.len();
if len == 0 {
return Err(SnapshotError::Integrity {
detail: format!("{} is empty (0 bytes)", path.display()),
});
}
let mut conn = open(path)?;
replication::sqlite::integrity_check(&mut conn).map_err(|e| SnapshotError::Integrity {
detail: e.to_string(),
})
}
fn sidecars(db: &Path) -> [PathBuf; 3] {
let mut journal = db.as_os_str().to_owned();
journal.push("-journal");
[
replication::wal::wal_path(db),
replication::wal::shm_path(db),
PathBuf::from(journal),
]
}
fn staging_path(db: &Path) -> PathBuf {
let mut path = db.as_os_str().to_owned();
path.push(format!(".autumn-restore-{}.tmp", std::process::id()));
PathBuf::from(path)
}
fn stage(artifact: &Path, staged: &Path, db: &Path) -> Result<(), SnapshotError> {
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(staged)
.map_err(SnapshotError::io(format!(
"creating the staging file {}",
staged.display()
)))?;
let mut source = std::fs::File::open(artifact)
.map_err(SnapshotError::io(format!("opening {}", artifact.display())))?;
std::io::copy(&mut source, &mut out).map_err(SnapshotError::io(format!(
"copying {} to {}",
artifact.display(),
staged.display()
)))?;
inherit_target_permissions_via(db, &out, staged)?;
out.sync_all()
.map_err(SnapshotError::io(format!("flushing {}", staged.display())))?;
same_file_as_handle(&out, staged)?;
verify(staged)?;
same_file_as_handle(&out, staged)
}
#[cfg(unix)]
fn same_file_as_handle(handle: &std::fs::File, path: &Path) -> Result<(), SnapshotError> {
use std::os::unix::fs::MetadataExt as _;
let owned = handle
.metadata()
.map_err(SnapshotError::io("inspecting the staging file".to_owned()))?;
let named = std::fs::symlink_metadata(path)
.map_err(SnapshotError::io(format!("inspecting {}", path.display())))?;
if owned.dev() == named.dev() && owned.ino() == named.ino() {
return Ok(());
}
Err(SnapshotError::Io {
context: format!("publishing the staging file {}", path.display()),
detail: "it was replaced while the restore was preparing it, so it is no longer \
the verified copy — refusing to publish it"
.to_owned(),
})
}
#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
fn same_file_as_handle(_handle: &std::fs::File, _path: &Path) -> Result<(), SnapshotError> {
Ok(())
}
fn flush(path: &Path) -> Result<(), SnapshotError> {
let file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(SnapshotError::io(format!("opening {}", path.display())))?;
file.sync_all()
.map_err(SnapshotError::io(format!("flushing {}", path.display())))
}
#[cfg(unix)]
fn inherit_target_permissions_via(
db: &Path,
staged: &std::fs::File,
staged_path: &Path,
) -> Result<(), SnapshotError> {
use std::os::unix::fs::MetadataExt as _;
let Ok(existing) = std::fs::metadata(db) else {
return Ok(());
};
staged
.set_permissions(existing.permissions())
.map_err(SnapshotError::io(format!(
"setting the mode of {}",
staged_path.display()
)))?;
let _ = std::os::unix::fs::fchown(staged, Some(existing.uid()), Some(existing.gid()));
Ok(())
}
#[cfg(not(unix))]
fn inherit_target_permissions_via(
db: &Path,
staged: &std::fs::File,
staged_path: &Path,
) -> Result<(), SnapshotError> {
let Ok(existing) = std::fs::metadata(db) else {
return Ok(());
};
staged
.set_permissions(existing.permissions())
.map_err(SnapshotError::io(format!(
"setting the mode of {}",
staged_path.display()
)))
}
fn follow_link(db: PathBuf) -> PathBuf {
if let Ok(real) = std::fs::canonicalize(&db) {
return real;
}
match std::fs::read_link(&db) {
Ok(target) if target.is_absolute() => target,
Ok(target) => db
.parent()
.map_or_else(|| target.clone(), |dir| dir.join(&target)),
Err(_) => db,
}
}
fn sync_dir(dir: &Path) {
if let Ok(handle) = std::fs::File::open(dir) {
let _ = handle.sync_all();
}
}
pub fn restore(artifact: &Path, target: &str) -> Result<(), SnapshotError> {
let db = follow_link(database_path(target)?);
verify(artifact)?;
if let Some(parent) = db.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.map_err(SnapshotError::io(format!("creating {}", parent.display())))?;
}
let staged = staging_path(&db);
let _ = std::fs::remove_file(&staged);
if let Err(e) = stage(artifact, &staged, &db) {
let _ = std::fs::remove_file(&staged);
return Err(e);
}
for sidecar in sidecars(&db) {
if let Err(e) = std::fs::remove_file(&sidecar)
&& e.kind() != std::io::ErrorKind::NotFound
{
let _ = std::fs::remove_file(&staged);
return Err(SnapshotError::io(format!("removing {}", sidecar.display()))(e));
}
}
if let Err(e) = std::fs::rename(&staged, &db) {
let _ = std::fs::remove_file(&staged);
return Err(SnapshotError::Io {
context: format!(
"replacing {} with the restored database. Its write-ahead log was already \
removed, so uncommitted-to-disk writes from before the restore are gone",
db.display()
),
detail: e.to_string(),
});
}
if let Some(parent) = db.parent().filter(|p| !p.as_os_str().is_empty()) {
sync_dir(parent);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded(path: &Path) -> SqliteConnection {
let mut conn =
SqliteConnection::establish(&path.to_string_lossy()).expect("establish sqlite");
conn.batch_execute(
"PRAGMA journal_mode = WAL; \
CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL); \
INSERT INTO t (v) VALUES ('a'), ('b');",
)
.expect("seed");
conn
}
#[derive(QueryableByName)]
struct Count {
#[diesel(sql_type = diesel::sql_types::BigInt)]
n: i64,
}
fn row_count(path: &Path) -> i64 {
let mut conn =
SqliteConnection::establish(&path.to_string_lossy()).expect("establish sqlite");
let rows: Vec<Count> = diesel::sql_query("SELECT COUNT(*) AS n FROM t")
.load(&mut conn)
.expect("count");
rows.first().map_or(-1, |r| r.n)
}
#[test]
fn database_path_resolves_every_accepted_spelling_and_refuses_in_memory() {
assert_eq!(
database_path("sqlite:///var/lib/app.db").expect("abs"),
PathBuf::from("/var/lib/app.db")
);
assert_eq!(
database_path("sqlite:app.db").expect("rel"),
PathBuf::from("app.db")
);
for memory in ["sqlite::memory:", "sqlite://:memory:", "sqlite://"] {
let err = database_path(memory).expect_err("in-memory must be refused");
assert!(
matches!(err, SnapshotError::NotAFile { .. }),
"{memory}: {err}"
);
assert!(
err.to_string().contains("in-memory"),
"the refusal must name the reason: {err}"
);
}
let err = database_path("postgres://app:hunter2@db.example.com/app")
.expect_err("a Postgres URL is not a SQLite target");
let message = err.to_string();
assert!(!message.contains("hunter2"), "credential leaked: {message}");
assert!(message.contains("<redacted>@"), "{message}");
}
#[test]
fn database_path_names_the_file_diesel_actually_opens_for_a_file_uri() {
let dir = tempfile::tempdir().expect("tempdir");
let url = format!("file:{}/app%20data.db?mode=rwc", dir.path().display());
let mut conn = SqliteConnection::establish(&url).expect("diesel opens the URI");
conn.batch_execute("CREATE TABLE t (id INTEGER PRIMARY KEY);")
.expect("write");
drop(conn);
let resolved = database_path(&url).expect("a file: URI names a file");
assert!(
resolved.is_file(),
"the resolver named {resolved:?}, which does not exist"
);
assert_eq!(resolved, dir.path().join("app data.db"));
}
#[test]
fn snapshot_captures_a_live_wal_database_and_the_copy_verifies() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("app.db");
let mut live = seeded(&db);
assert!(
dir.path().join("app.db-wal").exists(),
"the seed must leave a -wal beside the database"
);
let out = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", db.display()), &out).expect("snapshot");
verify(&out).expect("the snapshot must verify");
assert_eq!(row_count(&out), 2, "the snapshot must carry the WAL's rows");
live.batch_execute("INSERT INTO t (v) VALUES ('c');")
.expect("the live app must still write after a backup");
assert_eq!(row_count(&db), 3);
assert_eq!(row_count(&out), 2, "the snapshot is a point in time");
}
#[test]
fn snapshot_refuses_a_missing_database_instead_of_creating_one() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("absent.db");
let out = dir.path().join("control.sqlite");
let err = snapshot(&format!("sqlite://{}", db.display()), &out)
.expect_err("a missing database must fail");
assert!(matches!(err, SnapshotError::Missing { .. }), "{err}");
assert!(
!db.exists(),
"the failed backup must not create the database"
);
assert!(!out.exists(), "no artifact may be left behind");
}
#[test]
fn verify_rejects_an_empty_and_a_truncated_artifact() {
let dir = tempfile::tempdir().expect("tempdir");
let empty = dir.path().join("empty.sqlite");
std::fs::write(&empty, b"").expect("write");
assert!(matches!(
verify(&empty).expect_err("empty must fail"),
SnapshotError::Integrity { .. }
));
let db = dir.path().join("app.db");
drop(seeded(&db));
let good = dir.path().join("good.sqlite");
snapshot(&format!("sqlite://{}", db.display()), &good).expect("snapshot");
let bytes = std::fs::read(&good).expect("read");
let torn = dir.path().join("torn.sqlite");
std::fs::write(&torn, &bytes[..bytes.len() / 2]).expect("write");
verify(&torn).expect_err("a truncated artifact must fail");
}
#[test]
fn restore_replaces_the_target_and_clears_the_stale_wal() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
let target = dir.path().join("target.db");
let mut live = seeded(&target);
live.batch_execute("INSERT INTO t (v) VALUES ('c');")
.expect("third row");
drop(live);
let wal = dir.path().join("target.db-wal");
let shm = dir.path().join("target.db-shm");
std::fs::write(&wal, b"stale wal frames").expect("write");
std::fs::write(&shm, b"stale shm").expect("write");
restore(&artifact, &format!("sqlite://{}", target.display())).expect("restore");
assert!(!wal.exists(), "the stale -wal must be removed");
assert!(!shm.exists(), "the stale -shm must be removed");
assert_eq!(row_count(&target), 2, "the target must hold the artifact");
}
#[test]
fn restore_clears_the_rollback_journal_sidecar_too() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
let target = dir.path().join("target.db");
drop(seeded(&target));
let journal = dir.path().join("target.db-journal");
std::fs::write(&journal, b"stale rollback journal").expect("write");
restore(&artifact, &format!("sqlite://{}", target.display())).expect("restore");
assert!(!journal.exists(), "the stale -journal must be removed");
assert_eq!(row_count(&target), 2);
}
#[cfg(unix)]
#[test]
fn restore_keeps_the_targets_mode() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
std::fs::set_permissions(&artifact, std::fs::Permissions::from_mode(0o644))
.expect("chmod artifact");
let target = dir.path().join("target.db");
drop(seeded(&target));
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600))
.expect("chmod target");
restore(&artifact, &format!("sqlite://{}", target.display())).expect("restore");
let mode = std::fs::metadata(&target)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"the restored database must keep the target's mode"
);
}
#[cfg(unix)]
#[test]
fn restore_follows_a_symlink_to_the_real_database() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
let shared = dir.path().join("shared");
std::fs::create_dir_all(&shared).expect("mkdir");
let real = shared.join("app.db");
let mut live = seeded(&real);
live.batch_execute("INSERT INTO t (v) VALUES ('c');")
.expect("third row");
drop(live);
let stale_wal = shared.join("app.db-wal");
std::fs::write(&stale_wal, b"stale wal frames").expect("write");
let release = dir.path().join("release");
std::fs::create_dir_all(&release).expect("mkdir");
let link = release.join("app.db");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
restore(&artifact, &format!("sqlite://{}", link.display())).expect("restore");
assert!(
std::fs::symlink_metadata(&link)
.expect("stat")
.file_type()
.is_symlink(),
"the release path must still be a link, not a real file"
);
assert_eq!(
row_count(&real),
2,
"the shared file must hold the artifact"
);
assert!(
!stale_wal.exists(),
"the sidecars must be cleared beside the REAL file"
);
}
#[cfg(unix)]
#[test]
fn staging_never_follows_a_symlink_planted_at_its_path() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
let target = dir.path().join("target.db");
drop(seeded(&target));
let victim = dir.path().join("victim");
std::fs::write(&victim, b"MUST NOT BE TRUNCATED").expect("write victim");
let staged = staging_path(&target);
std::os::unix::fs::symlink(&victim, &staged).expect("plant symlink");
stage(&artifact, &staged, &target).expect_err("staging must refuse an occupied path");
assert_eq!(
std::fs::read(&victim).expect("victim readable"),
b"MUST NOT BE TRUNCATED",
"the symlink target must be untouched"
);
}
#[cfg(unix)]
#[test]
fn a_staging_file_swapped_after_creation_is_never_published() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("source.db");
drop(seeded(&source));
let artifact = dir.path().join("control.sqlite");
snapshot(&format!("sqlite://{}", source.display()), &artifact).expect("snapshot");
let target = dir.path().join("target.db");
drop(seeded(&target));
let victim = dir.path().join("victim");
std::fs::write(&victim, b"MUST NOT BE TOUCHED").expect("write victim");
let victim_before = std::fs::metadata(&victim).expect("victim metadata");
let staged = staging_path(&target);
let handle = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&staged)
.expect("create the staging file");
std::fs::remove_file(&staged).expect("unlink it");
std::os::unix::fs::symlink(&victim, &staged).expect("plant symlink");
same_file_as_handle(&handle, &staged).expect_err("a swapped staging name must be refused");
let victim_after = std::fs::metadata(&victim).expect("victim metadata");
assert_eq!(
std::fs::read(&victim).expect("victim readable"),
b"MUST NOT BE TOUCHED"
);
assert_eq!(
victim_before.permissions().mode(),
victim_after.permissions().mode(),
"the victim's mode must not have been inherited onto it"
);
std::fs::remove_file(&staged).ok();
}
#[cfg(unix)]
#[test]
fn the_staging_handle_matches_its_own_unswapped_name() {
let dir = tempfile::tempdir().expect("tempdir");
let staged = dir.path().join("staged.db");
let handle = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&staged)
.expect("create");
same_file_as_handle(&handle, &staged).expect("an unswapped name must pass");
}
#[test]
fn restore_refuses_a_corrupt_artifact_without_touching_the_target() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("target.db");
drop(seeded(&target));
let before = std::fs::read(&target).expect("read");
let artifact = dir.path().join("junk.sqlite");
std::fs::write(&artifact, b"not a sqlite database at all").expect("write");
restore(&artifact, &format!("sqlite://{}", target.display()))
.expect_err("a corrupt artifact must be refused");
assert_eq!(
std::fs::read(&target).expect("read"),
before,
"a refused restore must leave the target byte-identical"
);
assert!(
!staging_path(&target).exists(),
"no staging file may be left behind"
);
}
}