use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use rusqlite::{Connection, OpenFlags};
use super::{SourceRefusal, admit_history_database};
pub(crate) const MAX_HISTORY_DATABASE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const SNAPSHOT_PREFIX: &str = "import-snapshot-";
const SNAPSHOT_FILE: &str = "history.db";
const STALE_SNAPSHOT: Duration = Duration::from_hours(24);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Identity {
#[cfg(unix)]
device: u64,
#[cfg(unix)]
inode: u64,
regular: bool,
len: u64,
modified: Option<SystemTime>,
}
impl Identity {
fn of(metadata: &std::fs::Metadata) -> Self {
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
Self {
#[cfg(unix)]
device: metadata.dev(),
#[cfg(unix)]
inode: metadata.ino(),
regular: metadata.is_file(),
len: metadata.len(),
modified: metadata.modified().ok(),
}
}
}
#[derive(Debug)]
struct Verified {
file: File,
path: PathBuf,
identity: Identity,
}
impl Verified {
fn open(path: &Path, root: &Path) -> Result<Self, SourceRefusal> {
let file = crate::core::rooted_read::open_rooted_source(path, root)
.map_err(|_| SourceRefusal::Unreadable)?;
let identity = file
.metadata()
.map(|metadata| Identity::of(&metadata))
.map_err(|_| SourceRefusal::Unreadable)?;
if !identity.regular {
return Err(SourceRefusal::Unreadable);
}
Ok(Self {
file,
path: path.to_path_buf(),
identity,
})
}
fn unchanged(&self) -> bool {
let seen = |metadata: std::io::Result<std::fs::Metadata>| {
metadata.ok().map(|metadata| Identity::of(&metadata))
};
seen(self.file.metadata()) == Some(self.identity)
&& seen(std::fs::symlink_metadata(&self.path)) == Some(self.identity)
}
fn copy_to(&mut self, target: &Path) -> Result<(), SourceRefusal> {
use std::io::{Read, Seek, SeekFrom};
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut copy = options
.open(target)
.map_err(|_| SourceRefusal::Unreadable)?;
self.file
.seek(SeekFrom::Start(0))
.map_err(|_| SourceRefusal::Unreadable)?;
let mut bounded = self.file.by_ref().take(self.identity.len + 1);
let copied =
std::io::copy(&mut bounded, &mut copy).map_err(|_| SourceRefusal::Unreadable)?;
if copied == self.identity.len {
Ok(())
} else {
Err(SourceRefusal::Changed)
}
}
}
#[derive(Debug)]
pub(crate) struct VerifiedDatabase {
main: Verified,
wal: Option<Verified>,
}
pub(crate) fn verify_history_database(
history_root: &Path,
path: &Path,
) -> Result<VerifiedDatabase, SourceRefusal> {
let source = admit_history_database(history_root, path)?;
let main = Verified::open(&source.path, &source.root)?;
let wal_path = side_file(&source.path, "-wal");
let wal = if std::fs::symlink_metadata(&wal_path).is_ok() {
Some(Verified::open(&wal_path, &source.root)?)
} else {
None
};
let total = wal
.as_ref()
.map_or(0, |wal| wal.identity.len)
.saturating_add(main.identity.len);
if total > MAX_HISTORY_DATABASE_BYTES {
return Err(SourceRefusal::TooLarge);
}
Ok(VerifiedDatabase { main, wal })
}
impl VerifiedDatabase {
pub(crate) fn into_snapshot(mut self) -> Result<DatabaseSnapshot, SourceRefusal> {
let directory = snapshot_directory().map_err(|_| SourceRefusal::Unreadable)?;
let path = directory.path().join(SNAPSHOT_FILE);
self.main.copy_to(&path)?;
if let Some(wal) = self.wal.as_mut().filter(|wal| wal.identity.len > 0) {
wal.copy_to(&side_file(&path, "-wal"))?;
}
if !self.unchanged() {
return Err(SourceRefusal::Changed);
}
Ok(DatabaseSnapshot {
source_path: self.main.path,
path,
_directory: directory,
})
}
fn unchanged(&self) -> bool {
let wal_unchanged = match &self.wal {
Some(wal) => wal.unchanged(),
None => !non_empty(&side_file(&self.main.path, "-wal")),
};
self.main.unchanged()
&& wal_unchanged
&& !non_empty(&side_file(&self.main.path, "-journal"))
}
}
#[derive(Debug)]
pub(crate) struct DatabaseSnapshot {
source_path: PathBuf,
path: PathBuf,
_directory: tempfile::TempDir,
}
impl DatabaseSnapshot {
pub(crate) fn source_path(&self) -> &Path {
&self.source_path
}
pub(crate) fn open(&self) -> rusqlite::Result<Connection> {
Connection::open_with_flags(&self.path, OpenFlags::SQLITE_OPEN_READ_ONLY)
}
}
pub(crate) fn snapshot_history_database(
history_root: &Path,
path: &Path,
) -> Result<DatabaseSnapshot, SourceRefusal> {
verify_history_database(history_root, path)?.into_snapshot()
}
fn side_file(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
fn non_empty(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.len() > 0)
}
fn snapshot_directory() -> std::io::Result<tempfile::TempDir> {
let parent = super::scratch_parent()?;
remove_stale_snapshots(&parent);
let mut builder = tempfile::Builder::new();
builder.prefix(SNAPSHOT_PREFIX);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder.tempdir_in(parent)
}
fn remove_stale_snapshots(parent: &Path) {
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let stale = entry
.file_name()
.to_string_lossy()
.starts_with(SNAPSHOT_PREFIX)
&& entry
.metadata()
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age > STALE_SNAPSHOT));
if stale {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}