use rustlavel::prelude::*;
use rustlavel::rbac::Permissions;
use rustlavel::tokio::io::AsyncWriteExt;
use std::path::{Path, PathBuf};
pub const DIRECTORY: &str = "storage/backups";
pub const FORMAT: i64 = 1;
const CHUNK: i64 = 500;
const OWN_TABLES: &[&str] = &[
"users",
"user_tokens",
"login_attempts",
"user_totp",
"user_passkeys",
"user_recovery_codes",
"settings",
];
pub fn tables(store: Option<&Permissions>) -> Vec<String> {
let mut names: Vec<String> = OWN_TABLES.iter().map(|name| name.to_string()).collect();
if let Some(store) = store {
names.extend(store.tables().all().iter().map(|name| name.to_string()));
}
names
}
#[derive(Debug, Clone, PartialEq)]
pub struct Header {
pub format: i64,
pub schema: String,
pub at: String,
pub app: String,
}
impl Header {
fn to_line(&self) -> String {
Json::object([
("backup", Json::from(self.format)),
("schema", Json::from(self.schema.as_str())),
("at", Json::from(self.at.as_str())),
("app", Json::from(self.app.as_str())),
])
.to_string()
}
fn from_json(value: &Json) -> Option<Header> {
Some(Header {
format: value.get("backup")?.as_f64()? as i64,
schema: value.get("schema")?.as_str()?.to_string(),
at: value.get("at").and_then(Json::as_str).unwrap_or_default().to_string(),
app: value.get("app").and_then(Json::as_str).unwrap_or_default().to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Section {
pub name: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<Value>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Dump {
pub header: Header,
pub sections: Vec<Section>,
}
impl Dump {
pub fn rows(&self) -> usize {
self.sections.iter().map(|section| section.rows.len()).sum()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Restored {
pub tables: usize,
pub rows: usize,
}
pub fn encode_value(value: &Value) -> Json {
match value {
Value::Null => Json::Null,
Value::Bool(flag) => Json::Bool(*flag),
Value::Int(number) => Json::Number(*number as f64),
Value::Float(number) => Json::Number(*number),
Value::Text(text) => Json::String(text.clone()),
Value::Json(inner) => Json::object([("json", inner.clone())]),
Value::Bytes(bytes) => {
Json::object([("b64", Json::from(rustlavel::db::base64::encode(bytes)))])
}
}
}
pub fn decode_value(value: &Json) -> Result<Value> {
Ok(match value {
Json::Null => Value::Null,
Json::Bool(flag) => Value::Bool(*flag),
Json::Number(number) => {
if number.fract() == 0.0 && number.abs() < 9.007e15 {
Value::Int(*number as i64)
} else {
Value::Float(*number)
}
}
Json::String(text) => Value::Text(text.clone()),
Json::Object(fields) => {
if let Some(encoded) = fields.get("b64").and_then(Json::as_str) {
let bytes = rustlavel::db::base64::decode(encoded).ok_or_else(|| {
Error::msg("a binary value in the backup is not valid base64")
})?;
Value::Bytes(bytes)
} else if let Some(inner) = fields.get("json") {
Value::Json(inner.clone())
} else {
return Err(Error::msg(
"a value in the backup is an object with no `b64` or `json` key, so this \
file was not written by a version of the backup format this build knows",
));
}
}
Json::Array(_) => {
return Err(Error::msg("a value in the backup is an array, which no column holds"));
}
})
}
fn table_line(name: &str, columns: &[String]) -> String {
Json::object([
("table", Json::from(name)),
(
"columns",
Json::Array(columns.iter().map(|column| Json::from(column.as_str())).collect()),
),
])
.to_string()
}
fn row_line(values: &[Value]) -> String {
Json::object([("row", Json::Array(values.iter().map(encode_value).collect()))]).to_string()
}
fn trailer_line(tables: usize, rows: usize) -> String {
Json::object([(
"end",
Json::object([("tables", Json::from(tables as i64)), ("rows", Json::from(rows as i64))]),
)])
.to_string()
}
pub fn parse(source: &str) -> Result<Dump> {
let mut lines = source.lines().enumerate().filter(|(_, line)| !line.trim().is_empty());
let (_, first) = lines
.next()
.ok_or_else(|| Error::msg("this backup file is empty"))?;
let header = Header::from_json(&Json::parse(first).map_err(|error| {
Error::msg(format!("the first line of this backup is not JSON: {error}"))
})?)
.ok_or_else(|| {
Error::msg("the first line of this backup is not a header, so it is not a backup file")
})?;
if header.format != FORMAT {
return Err(Error::msg(format!(
"this backup is in format {} and this build writes and reads format {FORMAT}",
header.format
)));
}
let mut sections: Vec<Section> = Vec::new();
let mut trailer: Option<(usize, usize)> = None;
for (index, line) in lines {
let number = index + 1;
if trailer.is_some() {
return Err(Error::msg(format!(
"line {number} of this backup comes after the end marker, so the file has been \
appended to or two files have been concatenated"
)));
}
let value = Json::parse(line)
.map_err(|error| Error::msg(format!("line {number} of this backup is not JSON: {error}")))?;
if let Some(name) = value.get("table").and_then(Json::as_str) {
let columns = value
.get("columns")
.and_then(Json::as_array)
.ok_or_else(|| {
Error::msg(format!("the table on line {number} does not name its columns"))
})?
.iter()
.map(|column| {
column
.as_str()
.map(str::to_string)
.ok_or_else(|| Error::msg(format!("a column name on line {number} is not text")))
})
.collect::<Result<Vec<String>>>()?;
sections.push(Section { name: name.to_string(), columns, rows: Vec::new() });
} else if let Some(values) = value.get("row").and_then(Json::as_array) {
let section = sections.last_mut().ok_or_else(|| {
Error::msg(format!("line {number} is a row before any table has been named"))
})?;
if values.len() != section.columns.len() {
return Err(Error::msg(format!(
"line {number} has {} values but `{}` was declared with {} columns",
values.len(),
section.name,
section.columns.len()
)));
}
section.rows.push(values.iter().map(decode_value).collect::<Result<Vec<Value>>>()?);
} else if let Some(end) = value.get("end") {
trailer = Some((
end.get("tables").and_then(Json::as_f64).unwrap_or(-1.0) as usize,
end.get("rows").and_then(Json::as_f64).unwrap_or(-1.0) as usize,
));
} else {
return Err(Error::msg(format!(
"line {number} of this backup is not a table, a row or the end marker"
)));
}
}
let dump = Dump { header, sections };
let Some((tables, rows)) = trailer else {
return Err(Error::msg(
"this backup has no end marker, so it was never finished — the process that wrote it \
stopped part-way and the file is missing everything that came after. It cannot be \
restored from.",
));
};
if tables != dump.sections.len() || rows != dump.rows() {
return Err(Error::msg(format!(
"this backup says it holds {tables} tables and {rows} rows but {} tables and {} rows \
are actually in the file, so it has been truncated or edited",
dump.sections.len(),
dump.rows()
)));
}
Ok(dump)
}
pub fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 100
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn path_for(name: &str) -> Result<PathBuf> {
if !valid_name(name) {
return Err(Error::msg(format!(
"`{name}` is not a backup name. A name is letters, digits, hyphens and underscores, \
which is what stops one from pointing at a file outside {DIRECTORY}."
)));
}
let directory = Path::new(DIRECTORY);
let path = directory.join(format!("{name}.ndjson"));
if path.parent() != Some(directory) {
return Err(Error::msg(format!("`{name}` does not resolve inside {DIRECTORY}")));
}
Ok(path)
}
pub fn name_for(at: &str) -> String {
at.chars().filter(|c| c.is_ascii_digit()).enumerate().fold(
String::with_capacity(18),
|mut name, (index, digit)| {
if index == 4 || index == 6 {
name.push('-');
}
if index == 8 {
name.push('-');
}
name.push(digit);
name
},
)
}
pub async fn schema_version(db: &Database) -> Result<String> {
let table = rustlavel::db::migration::DEFAULT_TABLE;
let applied = db.table(table).count(db).await?;
let newest = db
.table(table)
.select(&["name"])
.order_by("id", rustlavel::db::Direction::Desc)
.limit(1)
.first(db)
.await?
.and_then(|row| row.get::<String>("name").ok())
.unwrap_or_else(|| "none".to_string());
Ok(format!("{applied}:{newest}"))
}
pub async fn write(
db: &Database,
names: &[String],
header: &Header,
destination: &Path,
) -> Result<u64> {
if let Some(parent) = destination.parent() {
rustlavel::tokio::fs::create_dir_all(parent).await?;
}
let partial = PathBuf::from(format!("{}.part", destination.display()));
let mut file = rustlavel::tokio::io::BufWriter::new(rustlavel::tokio::fs::File::create(&partial).await?);
let mut written_tables = 0usize;
let mut written_rows = 0usize;
let outcome = async {
file.write_all(header.to_line().as_bytes()).await?;
file.write_all(b"\n").await?;
for name in names {
let mut offset = 0i64;
let mut columns: Option<Vec<String>> = None;
loop {
let rows = db
.table(name)
.order_by("id", rustlavel::db::Direction::Asc)
.limit(CHUNK)
.offset(offset)
.get(db)
.await?;
if rows.is_empty() {
break;
}
if columns.is_none() {
let names: Vec<String> = rows[0].columns().to_vec();
file.write_all(table_line(name, &names).as_bytes()).await?;
file.write_all(b"\n").await?;
written_tables += 1;
columns = Some(names);
}
for row in &rows {
let values: Vec<Value> = (0..row.len())
.map(|index| row.value_at(index).cloned().unwrap_or(Value::Null))
.collect();
file.write_all(row_line(&values).as_bytes()).await?;
file.write_all(b"\n").await?;
written_rows += 1;
}
if (rows.len() as i64) < CHUNK {
break;
}
offset += CHUNK;
}
if columns.is_none() {
file.write_all(table_line(name, &[]).as_bytes()).await?;
file.write_all(b"\n").await?;
written_tables += 1;
}
}
file.write_all(trailer_line(written_tables, written_rows).as_bytes()).await?;
file.write_all(b"\n").await?;
file.flush().await?;
file.into_inner().sync_all().await?;
Ok::<(), Error>(())
}
.await;
if let Err(error) = outcome {
let _ = rustlavel::tokio::fs::remove_file(&partial).await;
return Err(error);
}
let bytes = rustlavel::tokio::fs::metadata(&partial).await?.len();
rustlavel::tokio::fs::rename(&partial, destination).await?;
Ok(bytes)
}
pub async fn restore(db: &Database, allowed: &[String], dump: &Dump) -> Result<Restored> {
for section in &dump.sections {
if !allowed.iter().any(|name| name == §ion.name) {
return Err(Error::msg(format!(
"this backup contains a table called `{}`, which this application does not \
back up and will not write to",
section.name
)));
}
rustlavel::db::validate_identifier(§ion.name)?;
for column in §ion.columns {
rustlavel::db::validate_identifier(column)?;
}
}
let mut transaction = db.begin().await?;
let mut done = Restored::default();
for section in dump.sections.iter().rev() {
transaction
.execute(&format!("delete from {}", db.dialect().quote(§ion.name)), &[])
.await?;
}
for section in &dump.sections {
if section.columns.is_empty() {
done.tables += 1;
continue;
}
let columns: Vec<String> =
section.columns.iter().map(|column| db.dialect().quote(column)).collect();
let placeholders: Vec<String> =
(1..=section.columns.len()).map(|n| db.dialect().placeholder(n)).collect();
let sql = format!(
"insert into {} ({}) values ({})",
db.dialect().quote(§ion.name),
columns.join(", "),
placeholders.join(", ")
);
for row in §ion.rows {
transaction.execute(&sql, row).await?;
done.rows += 1;
}
done.tables += 1;
}
transaction.commit().await?;
Ok(done)
}
pub fn humanise_bytes(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut size = bytes.max(0) as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 { format!("{} B", bytes.max(0)) } else { format!("{size:.1} {}", UNITS[unit]) }
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let directory =
std::env::temp_dir().join(format!("rustlavel-backup-tests/{name}"));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("could not make the scratch directory");
directory
}
fn sample() -> String {
let header = Header {
format: FORMAT,
schema: "9:2026_09_03_000100_create_settings_table".into(),
at: "2026-09-03 10:11:12".into(),
app: "Bktest".into(),
};
let columns = vec!["id".to_string(), "name".to_string(), "note".to_string()];
let rows = vec![
vec![Value::Int(1), Value::Text("Ada".into()), Value::Null],
vec![
Value::Int(2),
Value::Text("she said \"hello\"\nand \\ left".into()),
Value::Bool(false),
],
vec![Value::Int(3), Value::Float(1.5), Value::Bytes(vec![0, 1, 254, 255])],
];
let mut out = String::new();
out.push_str(&header.to_line());
out.push('\n');
out.push_str(&table_line("users", &columns));
out.push('\n');
for row in &rows {
out.push_str(&row_line(row));
out.push('\n');
}
out.push_str(&table_line("settings", &columns));
out.push('\n');
out.push_str(&trailer_line(2, rows.len()));
out.push('\n');
out
}
#[test]
fn the_format_round_trips_every_kind_of_value() {
let dump = parse(&sample()).expect("the sample should parse");
assert_eq!(dump.header.format, FORMAT);
assert_eq!(dump.header.schema, "9:2026_09_03_000100_create_settings_table");
assert_eq!(dump.sections.len(), 2);
assert_eq!(dump.sections[0].name, "users");
assert_eq!(dump.sections[0].columns, ["id", "name", "note"]);
assert_eq!(dump.sections[1].rows.len(), 0, "the empty table has a header and no rows");
let rows = &dump.sections[0].rows;
assert_eq!(rows[0], [Value::Int(1), Value::Text("Ada".into()), Value::Null]);
assert_eq!(rows[1][1], Value::Text("she said \"hello\"\nand \\ left".into()));
assert_eq!(rows[1][2], Value::Bool(false));
assert_eq!(rows[2][1], Value::Float(1.5));
assert_eq!(rows[2][2], Value::Bytes(vec![0, 1, 254, 255]));
}
#[test]
fn a_row_with_a_newline_in_it_stays_on_one_line() {
let line = row_line(&[Value::Text("one\ntwo".into())]);
assert_eq!(line.lines().count(), 1, "a newline escaped into the line: {line}");
}
#[test]
fn a_truncated_file_is_refused_rather_than_half_restored() {
let whole = sample();
let lines: Vec<&str> = whole.lines().collect();
let cut = lines[..lines.len() - 2].join("\n");
let error = parse(&cut).expect_err("a file with no end marker must not parse");
let message = error.to_string();
assert!(message.contains("never finished"), "unhelpful message: {message}");
let mut kept: Vec<&str> = whole.lines().collect();
kept.remove(3);
let error = parse(&kept.join("\n")).expect_err("the counts must be checked too");
assert!(error.to_string().contains("truncated or edited"), "{error}");
}
#[test]
fn an_empty_or_foreign_file_is_refused() {
assert!(parse("").is_err(), "an empty file is not a backup");
assert!(parse("hello\n").is_err(), "text is not a backup");
assert!(parse("{\"table\":\"users\"}\n").is_err(), "a file with no header is not a backup");
let wrong = format!(
"{}\n{}\n",
Json::object([("backup", Json::from(99)), ("schema", Json::from("x"))]),
trailer_line(0, 0)
);
let error = parse(&wrong).expect_err("a future format must be refused");
assert!(error.to_string().contains("format 99"), "{error}");
}
#[test]
fn a_name_cannot_escape_the_directory() {
for crafted in [
"../../../etc/passwd",
"..",
".",
"a/b",
"a\\b",
"/etc/passwd",
".hidden",
"with space",
"semi;colon",
"",
] {
assert!(!valid_name(crafted), "`{crafted}` was accepted as a name");
let path = path_for(crafted);
assert!(path.is_err(), "`{crafted}` produced a path: {:?}", path.ok());
}
let path = path_for("2026-09-03-101112").expect("a timestamp is a valid name");
assert_eq!(path, Path::new(DIRECTORY).join("2026-09-03-101112.ndjson"));
assert_eq!(path.parent(), Some(Path::new(DIRECTORY)));
assert!(valid_name("nightly_2026"));
}
#[test]
fn a_name_is_the_timestamp_with_its_punctuation_stripped() {
assert_eq!(name_for("2026-09-03 10:11:12"), "2026-09-03-101112");
assert!(valid_name(&name_for("2026-09-03 10:11:12")));
}
#[rustlavel::test]
async fn the_size_recorded_is_the_size_on_disk() {
let directory = scratch("size");
let destination = directory.join("2026-09-03-101112.ndjson");
let source = sample();
rustlavel::tokio::fs::write(&destination, &source).await.expect("write");
let bytes = rustlavel::tokio::fs::metadata(&destination).await.expect("stat").len();
assert_eq!(bytes, source.len() as u64);
assert!(bytes > 0);
assert_eq!(humanise_bytes(bytes as i64), format!("{bytes} B"));
assert_eq!(humanise_bytes(1536), "1.5 KB");
assert_eq!(humanise_bytes(0), "0 B");
let read = rustlavel::tokio::fs::read_to_string(&destination).await.expect("read");
assert_eq!(parse(&read).expect("round trip").rows(), 3);
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn a_partial_file_is_not_at_the_name_a_restore_looks_for() {
let directory = scratch("partial");
let destination = directory.join("2026-09-03-101112.ndjson");
let partial = PathBuf::from(format!("{}.part", destination.display()));
std::fs::write(&partial, "{\"backup\":1}\n").expect("write");
assert!(!destination.exists(), "a half-written dump must not sit at the real name");
assert!(!valid_name("2026-09-03-101112.ndjson.part"), "a `.part` name has a dot in it");
std::fs::rename(&partial, &destination).expect("rename");
assert!(destination.exists());
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn the_backups_table_is_never_part_of_a_backup() {
assert!(!OWN_TABLES.contains(&"backups"), "a dump must not contain its own catalogue");
assert!(OWN_TABLES.contains(&"users"));
let users = OWN_TABLES.iter().position(|t| *t == "users").unwrap();
let tokens = OWN_TABLES.iter().position(|t| *t == "user_tokens").unwrap();
assert!(users < tokens, "user_tokens points at users and must be filled after it");
}
}