779 lines
31 KiB
Rust
779 lines
31 KiB
Rust
//! Filesystem-aware SQLite journal-mode selection.
|
|
//!
|
|
//! WAL keeps its wal-index in an mmap'd `-shm` file and relies on coherent
|
|
//! shared memory plus reliable POSIX locks — guarantees network filesystems
|
|
//! do not provide. When `$HOME` (and thus `~/.grok`) is NFS-mounted on
|
|
//! several machines at once, a peer host truncating/rebuilding the `-shm`
|
|
//! during WAL recovery or close rips the backing out from under our mapping
|
|
//! and the next wal-index read dies with SIGBUS. On such mounts we use a
|
|
//! rollback journal instead (SQLite's documented "WAL does not work over a
|
|
//! network filesystem" limitation), and each host opens its own per-host DB
|
|
//! file (see [`JournalMode::effective_db_path`]) so no peer — including
|
|
//! pre-fix binaries that would flip a shared DB back to WAL — ever shares
|
|
//! the file.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Wait for peers' locks instead of failing instantly; matches what every
|
|
/// consumer historically set.
|
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(5000);
|
|
|
|
/// Journal mode chosen for a SQLite database based on where it lives.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum JournalMode {
|
|
/// Write-ahead logging — the historical default, local filesystems only.
|
|
Wal,
|
|
/// Rollback journal truncated (not unlinked) at commit — safe on network
|
|
/// filesystems, and cheaper there than DELETE mode: no per-commit
|
|
/// create/unlink namespace round-trips and no NFS `.nfsXXXX`
|
|
/// silly-rename litter.
|
|
Truncate,
|
|
}
|
|
|
|
impl JournalMode {
|
|
/// Pick the journal mode for a database at `db_path`.
|
|
///
|
|
/// Classifies the parent directory (the DB file itself may not exist
|
|
/// yet), so callers must create it first. `GROK_SQLITE_JOURNAL_MODE`
|
|
/// (`wal`|`truncate`) overrides detection as a field kill-switch.
|
|
pub fn for_db_path(db_path: &Path) -> Self {
|
|
let env = std::env::var("GROK_SQLITE_JOURNAL_MODE").ok();
|
|
match mode_from_env(env.as_deref()) {
|
|
EnvOverride::Mode(mode) => {
|
|
// Loud so field flips of the kill-switch are greppable in logs.
|
|
tracing::info!(
|
|
db = %db_path.display(),
|
|
mode = mode.as_str(),
|
|
source = "env",
|
|
"sqlite journal mode forced by GROK_SQLITE_JOURNAL_MODE"
|
|
);
|
|
return mode;
|
|
}
|
|
EnvOverride::Invalid => {
|
|
// A typo in the emergency kill-switch must be loud, not silently ignored.
|
|
tracing::warn!(
|
|
value = env.as_deref().unwrap_or_default(),
|
|
"invalid GROK_SQLITE_JOURNAL_MODE (accepted: wal, truncate); using detection"
|
|
);
|
|
}
|
|
EnvOverride::Unset => {}
|
|
}
|
|
let dir = match db_path.parent() {
|
|
Some(p) if !p.as_os_str().is_empty() => p,
|
|
// Bare filename (or root): classify the CWD it resolves against.
|
|
_ => Path::new("."),
|
|
};
|
|
let mode = if is_network_fs(dir) {
|
|
Self::Truncate
|
|
} else {
|
|
Self::Wal
|
|
};
|
|
tracing::debug!(
|
|
db = %db_path.display(),
|
|
mode = mode.as_str(),
|
|
source = "statfs",
|
|
"sqlite journal mode"
|
|
);
|
|
mode
|
|
}
|
|
|
|
/// The `PRAGMA journal_mode` value for this mode.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Wal => "WAL",
|
|
Self::Truncate => "TRUNCATE",
|
|
}
|
|
}
|
|
|
|
/// The path actually opened for `db_path` under this mode.
|
|
///
|
|
/// `Wal` (local): unchanged. `Truncate` (network): a per-host sibling
|
|
/// (`worktrees.db` → `worktrees.h-<host>.db`). Journal mode is a
|
|
/// database-wide property, so a live pre-fix binary on a peer host (or
|
|
/// this host) can flip a *shared* DB back to WAL at any time and our
|
|
/// long-lived connections would silently adopt it, re-creating the
|
|
/// mmap'd `-shm`. Old binaries never know the per-host name, so the
|
|
/// no-WAL invariant — and the end of cross-host sharing, the root
|
|
/// hazard — holds by construction. These DBs are all rebuildable
|
|
/// indexes/caches, so each host starting fresh is acceptable.
|
|
///
|
|
/// Idempotent (an already-suffixed path is returned unchanged) so
|
|
/// callers may pre-resolve the path for sidecar file operations. Falls
|
|
/// back to `db_path` unchanged (still TRUNCATE) if no hostname is
|
|
/// available.
|
|
pub fn effective_db_path(self, db_path: &Path) -> PathBuf {
|
|
if self != Self::Truncate {
|
|
return db_path.to_path_buf();
|
|
}
|
|
let Some(host) = host_discriminator() else {
|
|
return db_path.to_path_buf();
|
|
};
|
|
let Some(name) = db_path
|
|
.file_name()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
else {
|
|
return db_path.to_path_buf();
|
|
};
|
|
let tag = format!(".h-{host}");
|
|
// Idempotent: pre-resolved paths pass through unchanged.
|
|
if name.ends_with(&tag) || name.contains(&format!("{tag}.")) {
|
|
return db_path.to_path_buf();
|
|
}
|
|
// Insert before the final extension ("worktrees.db" -> "worktrees.h-x.db"),
|
|
// else append; rsplit keeps dotfile names like ".hidden" on the append arm.
|
|
let new_name = match name.rsplit_once('.') {
|
|
Some((stem, ext)) if !stem.is_empty() => format!("{stem}{tag}.{ext}"),
|
|
_ => format!("{name}{tag}"),
|
|
};
|
|
db_path.with_file_name(new_name)
|
|
}
|
|
|
|
/// Open (or create) a read-write connection with this journal mode
|
|
/// applied, at [`Self::effective_db_path`] (per-host on network mounts).
|
|
/// Sets a 5s `busy_timeout` before the journal pragma (see
|
|
/// [`Self::apply`] for the conversion-lock semantics).
|
|
pub fn open(self, db_path: &Path) -> rusqlite::Result<rusqlite::Connection> {
|
|
let conn = rusqlite::Connection::open(self.effective_db_path(db_path))?;
|
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
|
self.apply(&conn)?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Open a connection for read-only use, safely for this mode's
|
|
/// filesystem, at [`Self::effective_db_path`] (per-host on network
|
|
/// mounts — until a read-write open creates that file, this errors and
|
|
/// callers fall back to their defaults). Errors if the database file
|
|
/// does not exist (never creates it).
|
|
///
|
|
/// `Wal` (local): a plain read-only open — the historical behavior.
|
|
///
|
|
/// `Truncate` (network): reading a legacy WAL-stamped DB read-only would
|
|
/// mmap its `-shm` (the SIGBUS), and the read-only escape hatch does not
|
|
/// exist: EXCLUSIVE locking's heap wal-index takes an exclusive file lock,
|
|
/// and POSIX forbids write-locking an O_RDONLY fd (`SQLITE_IOERR_LOCK`).
|
|
/// So open read-write (without CREATE) and run the idempotent conversion
|
|
/// (needed e.g. after remote sync drops in a WAL-stamped file). The fd
|
|
/// stays writable (conversion and hot-journal rollback need it), but
|
|
/// `query_only` is then set so SQL writes are rejected on this arm too —
|
|
/// both arms honor the name.
|
|
pub fn open_readonly(self, db_path: &Path) -> rusqlite::Result<rusqlite::Connection> {
|
|
use rusqlite::OpenFlags;
|
|
let flags = match self {
|
|
Self::Wal => OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
// Read-write (no CREATE): the conversion needs a writable fd.
|
|
Self::Truncate => OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
};
|
|
let conn = rusqlite::Connection::open_with_flags(self.effective_db_path(db_path), flags)?;
|
|
// Readers can hit SQLITE_BUSY too (peer recovery, a rollback writer's
|
|
// exclusive window), and the conversion below requires a busy handler.
|
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
|
if let Self::Truncate = self {
|
|
self.apply(&conn)?;
|
|
// Make the name true: reject SQL writes (statement-level) while
|
|
// the fd stays writable for lock/rollback purposes.
|
|
conn.pragma_update(None, "query_only", true)?;
|
|
}
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Apply this journal mode to a freshly opened read-write connection.
|
|
///
|
|
/// Busy semantics (single source of truth): converting a database between
|
|
/// WAL and rollback journaling takes a brief exclusive lock whose
|
|
/// acquisition only PARTIALLY honors the busy handler — some lock paths
|
|
/// wait out `busy_timeout`, others (e.g. a peer connection holding a WAL
|
|
/// read-mark) fail fast with `SQLITE_BUSY`. Callers must set
|
|
/// `busy_timeout` first ([`Self::open`] does), and sites that swallow
|
|
/// open errors need a bounded retry on busy as well (see
|
|
/// `set_journal_mode` in `xai-fast-worktree`).
|
|
pub fn apply(self, conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
|
match self {
|
|
Self::Wal => conn.pragma_update(None, "journal_mode", "WAL"),
|
|
Self::Truncate => {
|
|
// EXCLUSIVE locking keeps the wal-index in heap memory, so
|
|
// converting an already-WAL-stamped DB never mmaps a `-shm`
|
|
// that a peer NFS client may be rebuilding (SIGBUS).
|
|
conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?;
|
|
conn.pragma_update(None, "journal_mode", "TRUNCATE")?;
|
|
// Legal only after leaving WAL; the exclusive lock is released
|
|
// by the caller's next database access (e.g. schema init).
|
|
conn.pragma_update(None, "locking_mode", "NORMAL")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse result of the `GROK_SQLITE_JOURNAL_MODE` kill-switch.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum EnvOverride {
|
|
Unset,
|
|
/// Set to something unrecognized — observable (warned) but non-fatal.
|
|
Invalid,
|
|
Mode(JournalMode),
|
|
}
|
|
|
|
/// `GROK_SQLITE_JOURNAL_MODE` override parsing (pure for testability).
|
|
fn mode_from_env(value: Option<&str>) -> EnvOverride {
|
|
// Set-but-empty counts as unset: a deliberate blank, not a typo.
|
|
match value {
|
|
None | Some("") => EnvOverride::Unset,
|
|
Some(v) if v.eq_ignore_ascii_case("wal") => EnvOverride::Mode(JournalMode::Wal),
|
|
Some(v) if v.eq_ignore_ascii_case("truncate") => EnvOverride::Mode(JournalMode::Truncate),
|
|
Some(_) => EnvOverride::Invalid,
|
|
}
|
|
}
|
|
|
|
/// Short per-host discriminator for per-host DB filenames (lowercased
|
|
/// alphanumeric hostname, other bytes mapped to `-`, capped at 24 chars).
|
|
/// `None` when no hostname is available. Sanitization collisions across
|
|
/// hosts only degrade to plain shared-TRUNCATE behavior, never to WAL.
|
|
fn host_discriminator() -> Option<String> {
|
|
let raw = hostname_raw()?;
|
|
let mut s: String = raw
|
|
.trim()
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() {
|
|
c.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect();
|
|
s.truncate(24);
|
|
let s = s.trim_matches('-');
|
|
if s.is_empty() {
|
|
None
|
|
} else {
|
|
Some(s.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn hostname_raw() -> Option<String> {
|
|
let mut buf = [0u8; 256];
|
|
// SAFETY: buf is a valid out-buffer of the given length; gethostname
|
|
// NUL-terminates on success (position() guards the not-terminated case).
|
|
if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 {
|
|
return None;
|
|
}
|
|
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
|
String::from_utf8(buf[..len].to_vec()).ok()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn hostname_raw() -> Option<String> {
|
|
std::env::var("COMPUTERNAME").ok()
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
fn hostname_raw() -> Option<String> {
|
|
None
|
|
}
|
|
|
|
/// Best-effort: whether `path` lives on a network/remote filesystem.
|
|
///
|
|
/// Any detection failure returns `false` (treat as local) so unclassifiable
|
|
/// filesystems keep the historical WAL behavior.
|
|
pub fn is_network_fs(path: &Path) -> bool {
|
|
imp::is_network_fs(path)
|
|
}
|
|
|
|
/// Classify a Linux `statfs(2)` `f_type` as a network/remote filesystem.
|
|
///
|
|
/// Magic values from `include/uapi/linux/magic.h` (Lustre's from its module
|
|
/// sources). Only the low 32 bits are compared: `f_type` is a signed word
|
|
/// whose width varies by architecture, so 32-bit kernels sign-extend magics
|
|
/// with the high bit set (e.g. CIFS 0xFF534D42).
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn is_network_fs_magic(f_type: u64) -> bool {
|
|
const NFS_SUPER_MAGIC: u64 = 0x6969;
|
|
const SMB_SUPER_MAGIC: u64 = 0x517B;
|
|
const SMB2_SUPER_MAGIC: u64 = 0xFE53_4D42;
|
|
const CIFS_SUPER_MAGIC: u64 = 0xFF53_4D42;
|
|
const V9FS_MAGIC: u64 = 0x0102_1997;
|
|
const CODA_SUPER_MAGIC: u64 = 0x7375_7245;
|
|
const AFS_SUPER_MAGIC: u64 = 0x5346_414F;
|
|
const AFS_FS_MAGIC: u64 = 0x6B41_4653; // in-kernel kAFS client
|
|
const CEPH_SUPER_MAGIC: u64 = 0x00C3_6400;
|
|
const LUSTRE_SUPER_MAGIC: u64 = 0x0BD0_0BD0;
|
|
const GFS2_MAGIC: u64 = 0x0116_1970;
|
|
const GPFS_SUPER_MAGIC: u64 = 0x4750_4653; // "GPFS" (Spectrum Scale)
|
|
const OCFS2_SUPER_MAGIC: u64 = 0x7461_636F;
|
|
// WekaFS: parallel filesystem sometimes used for network-mounted home
|
|
// directories. Its magic is not in linux/magic.h — the value is confirmed
|
|
// empirically from `statfs` on wekafs mounts (`findmnt` reports
|
|
// FSTYPE=wekafs; coreutils `stat -f` still prints it as UNKNOWN). Like NFS
|
|
// it offers no coherent cross-host shared memory, so WAL's mmap'd `-shm`
|
|
// SIGBUSes when a peer host rebuilds it.
|
|
const WEKAFS_SUPER_MAGIC: u64 = 0x1803_1977;
|
|
// FUSE is deliberately treated as network: sshfs/s3fs/gluster and other
|
|
// FUSE-backed homes cannot guarantee coherent mmap across writers, and
|
|
// rollback journaling costs little there.
|
|
const FUSE_SUPER_MAGIC: u64 = 0x6573_5546;
|
|
|
|
matches!(
|
|
f_type & 0xFFFF_FFFF,
|
|
NFS_SUPER_MAGIC
|
|
| SMB_SUPER_MAGIC
|
|
| SMB2_SUPER_MAGIC
|
|
| CIFS_SUPER_MAGIC
|
|
| V9FS_MAGIC
|
|
| CODA_SUPER_MAGIC
|
|
| AFS_SUPER_MAGIC
|
|
| AFS_FS_MAGIC
|
|
| CEPH_SUPER_MAGIC
|
|
| LUSTRE_SUPER_MAGIC
|
|
| GFS2_MAGIC
|
|
| GPFS_SUPER_MAGIC
|
|
| OCFS2_SUPER_MAGIC
|
|
| WEKAFS_SUPER_MAGIC
|
|
| FUSE_SUPER_MAGIC
|
|
)
|
|
}
|
|
|
|
/// Mirrors macOS `libc::MNT_LOCAL` so the pure classifier is testable on
|
|
/// non-mac hosts; pinned to libc by a macOS-only test.
|
|
#[cfg(any(target_os = "macos", test))]
|
|
const MNT_LOCAL: u32 = 0x0000_1000;
|
|
|
|
/// Classify a macOS `statfs(2)` result as a network/remote filesystem.
|
|
///
|
|
/// Absence of `MNT_LOCAL` in `f_flags` is the authoritative remote signal
|
|
/// (covers unknown/future remote fs types). The `f_fstypename` allowlist is
|
|
/// kept as a conservative extra trigger for remote-backed mounts that still
|
|
/// set MNT_LOCAL (e.g. FUSE bridges).
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn is_network_fs_mac(f_flags: u32, fstype: &str) -> bool {
|
|
(f_flags & MNT_LOCAL) == 0 || is_network_fs_name(fstype)
|
|
}
|
|
|
|
/// Classify a macOS `statfs(2)` `f_fstypename` as a network/remote filesystem.
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn is_network_fs_name(fstype: &str) -> bool {
|
|
// macfuse/osxfuse mirror Linux's FUSE-is-network stance (sshfs etc.);
|
|
// fuse-t needs no entry — its mounts already surface as "nfs".
|
|
fstype.eq_ignore_ascii_case("nfs")
|
|
|| fstype.eq_ignore_ascii_case("smbfs")
|
|
|| fstype.eq_ignore_ascii_case("cifs")
|
|
|| fstype.eq_ignore_ascii_case("afpfs")
|
|
|| fstype.eq_ignore_ascii_case("webdav")
|
|
|| fstype.eq_ignore_ascii_case("macfuse")
|
|
|| fstype.eq_ignore_ascii_case("osxfuse")
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod imp {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::path::Path;
|
|
|
|
pub(crate) fn is_network_fs(path: &Path) -> bool {
|
|
let Ok(cpath) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
|
return false;
|
|
};
|
|
// SAFETY: statfs is zero-initializable POD; cpath is NUL-terminated
|
|
// and st is a valid out-pointer for the duration of the call.
|
|
let mut st: libc::statfs = unsafe { std::mem::zeroed() };
|
|
if unsafe { libc::statfs(cpath.as_ptr(), &mut st) } != 0 {
|
|
return false;
|
|
}
|
|
// Cast through u64: f_type is i64 on 64-bit targets, i32 on some
|
|
// 32-bit ones; the classifier masks to the meaningful low 32 bits.
|
|
super::is_network_fs_magic(st.f_type as u64)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod imp {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
use std::path::Path;
|
|
|
|
pub(crate) fn is_network_fs(path: &Path) -> bool {
|
|
let Ok(cpath) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
|
return false;
|
|
};
|
|
// SAFETY: statfs is zero-initializable POD; cpath is NUL-terminated
|
|
// and st is a valid out-pointer for the duration of the call.
|
|
let mut st: libc::statfs = unsafe { std::mem::zeroed() };
|
|
if unsafe { libc::statfs(cpath.as_ptr(), &mut st) } != 0 {
|
|
return false;
|
|
}
|
|
// Stack-copy the fixed array to u8 (c_char is i8 here), no heap;
|
|
// take up to the first NUL.
|
|
let bytes = st.f_fstypename.map(|c| c as u8);
|
|
let len = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
|
let name = std::str::from_utf8(&bytes[..len]).unwrap_or("");
|
|
super::is_network_fs_mac(st.f_flags, name)
|
|
}
|
|
}
|
|
|
|
/// Classify a Windows path string as UNC (network) — `\\server\share` or
|
|
/// `\\?\UNC\server\share`; the `\\.\` and `\\?\C:\` device/verbatim-local
|
|
/// forms are not network. Pure for testability; mapped drives are caught by
|
|
/// the `GetDriveTypeW` probe instead.
|
|
#[cfg(any(windows, test))]
|
|
fn is_windows_unc(path: &str) -> bool {
|
|
let Some(rest) = path.strip_prefix(r"\\") else {
|
|
return false;
|
|
};
|
|
if let Some(verbatim) = rest.strip_prefix(r"?\") {
|
|
return verbatim
|
|
.get(..4)
|
|
.is_some_and(|p| p.eq_ignore_ascii_case(r"UNC\"));
|
|
}
|
|
!rest.starts_with(r".\")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
mod imp {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use std::path::Path;
|
|
use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetVolumePathNameW};
|
|
use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE;
|
|
|
|
pub(crate) fn is_network_fs(path: &Path) -> bool {
|
|
if super::is_windows_unc(&path.to_string_lossy()) {
|
|
return true;
|
|
}
|
|
// Mapped drives (e.g. Z: on SMB): resolve the volume root and ask
|
|
// for its drive type; any failure → local (historical behavior).
|
|
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
|
let mut root = [0u16; 261];
|
|
// SAFETY: wide is NUL-terminated; root is a valid out-buffer whose
|
|
// length is passed in characters, as the API requires.
|
|
unsafe {
|
|
if GetVolumePathNameW(wide.as_ptr(), root.as_mut_ptr(), root.len() as u32) == 0 {
|
|
return false;
|
|
}
|
|
GetDriveTypeW(root.as_ptr()) == DRIVE_REMOTE
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anything else: no cheap, reliable remote-FS probe and no field reports of
|
|
// the WAL-over-network crash there — keep the WAL default.
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
|
|
mod imp {
|
|
use std::path::Path;
|
|
|
|
pub(crate) fn is_network_fs(_path: &Path) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
fn journal_mode(conn: &rusqlite::Connection) -> String {
|
|
conn.query_row("PRAGMA journal_mode", [], |r| r.get(0))
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn network_magics_classify_as_network() {
|
|
for magic in [
|
|
0x6969u64, // NFS
|
|
0x517B, // SMB
|
|
0xFE53_4D42, // SMB2
|
|
0xFF53_4D42, // CIFS
|
|
0x0102_1997, // 9p
|
|
0x7375_7245, // CODA
|
|
0x5346_414F, // AFS
|
|
0x6B41_4653, // kAFS
|
|
0x00C3_6400, // CEPH
|
|
0x0BD0_0BD0, // Lustre
|
|
0x0116_1970, // GFS2
|
|
0x4750_4653, // GPFS
|
|
0x7461_636F, // OCFS2
|
|
0x1803_1977, // wekafs
|
|
0x6573_5546, // FUSE
|
|
] {
|
|
assert!(is_network_fs_magic(magic), "magic {magic:#x}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_magics_classify_as_local() {
|
|
for magic in [
|
|
0xEF53u64, // ext2/3/4
|
|
0x0102_1994, // tmpfs
|
|
0x9123_683E, // btrfs
|
|
0x5846_5342, // XFS
|
|
0x794C_7630, // overlayfs
|
|
0x2FC1_2FC1, // zfs
|
|
0x0,
|
|
] {
|
|
assert!(!is_network_fs_magic(magic), "magic {magic:#x}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sign_extended_magic_still_matches() {
|
|
// A 32-bit kernel reports CIFS's 0xFF534D42 as a negative f_type.
|
|
assert!(is_network_fs_magic(0xFFFF_FFFF_FF53_4D42));
|
|
}
|
|
|
|
#[test]
|
|
fn fstypenames_classify() {
|
|
for name in [
|
|
"nfs", "smbfs", "cifs", "afpfs", "webdav", "NFS", "macfuse", "osxfuse",
|
|
] {
|
|
assert!(is_network_fs_name(name), "{name}");
|
|
}
|
|
for name in ["apfs", "hfs", "tmpfs", "devfs", ""] {
|
|
assert!(!is_network_fs_name(name), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn env_override_parses() {
|
|
use EnvOverride::{Invalid, Mode, Unset};
|
|
assert_eq!(mode_from_env(Some("wal")), Mode(JournalMode::Wal));
|
|
assert_eq!(mode_from_env(Some("WAL")), Mode(JournalMode::Wal));
|
|
assert_eq!(mode_from_env(Some("truncate")), Mode(JournalMode::Truncate));
|
|
assert_eq!(mode_from_env(Some("TRUNCATE")), Mode(JournalMode::Truncate));
|
|
// Typos are Invalid (warned), not silently treated as unset.
|
|
assert_eq!(mode_from_env(Some("delete")), Invalid);
|
|
assert_eq!(mode_from_env(Some("wall")), Invalid);
|
|
assert_eq!(mode_from_env(Some("")), Unset);
|
|
assert_eq!(mode_from_env(None), Unset);
|
|
}
|
|
|
|
#[test]
|
|
fn mac_classifier_uses_mnt_local_and_name_override() {
|
|
// Unknown remote type without MNT_LOCAL → network.
|
|
assert!(is_network_fs_mac(0, "somefutfs"));
|
|
assert!(is_network_fs_mac(0, "macfuse_sshfs"));
|
|
// Plain local APFS → local.
|
|
assert!(!is_network_fs_mac(MNT_LOCAL, "apfs"));
|
|
// Allowlisted name wins even when the mount claims MNT_LOCAL.
|
|
assert!(is_network_fs_mac(MNT_LOCAL, "smbfs"));
|
|
assert!(is_network_fs_mac(MNT_LOCAL, "macfuse"));
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn mnt_local_matches_libc() {
|
|
// libc::MNT_LOCAL is c_int; ours is u32 to match statfs.f_flags.
|
|
assert_eq!(u64::from(MNT_LOCAL), libc::MNT_LOCAL as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_unc_classifies() {
|
|
assert!(is_windows_unc(r"\\server\share\grok"));
|
|
assert!(is_windows_unc(r"\\?\UNC\server\share\grok"));
|
|
assert!(is_windows_unc(r"\\?\unc\server\share"));
|
|
assert!(!is_windows_unc(r"\\?\C:\Users\x"));
|
|
assert!(!is_windows_unc(r"\\.\pipe\grok"));
|
|
assert!(!is_windows_unc(r"C:\Users\x"));
|
|
assert!(!is_windows_unc("/home/x"));
|
|
}
|
|
|
|
#[test]
|
|
fn local_and_error_paths_are_not_network() {
|
|
let tmp = TempDir::new().unwrap();
|
|
// CI runners use plain local filesystems (ext4/tmpfs/APFS).
|
|
assert!(!is_network_fs(tmp.path()));
|
|
assert!(!is_network_fs(&tmp.path().join("does-not-exist")));
|
|
}
|
|
|
|
#[test]
|
|
fn for_db_path_defaults_to_wal_on_local_fs() {
|
|
// Ambient override would invalidate the assertion; skip if set.
|
|
if std::env::var("GROK_SQLITE_JOURNAL_MODE").is_ok() {
|
|
return;
|
|
}
|
|
let tmp = TempDir::new().unwrap();
|
|
let mode = JournalMode::for_db_path(&tmp.path().join("x.sqlite"));
|
|
assert_eq!(mode, JournalMode::Wal);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_db_path_is_per_host_only_in_truncate_mode() {
|
|
let p = Path::new("/tmp/dir/worktrees.db");
|
|
// Local mode: untouched.
|
|
assert_eq!(JournalMode::Wal.effective_db_path(p), p);
|
|
|
|
// Network mode: deterministic per-host sibling in the same dir.
|
|
let a = JournalMode::Truncate.effective_db_path(p);
|
|
let b = JournalMode::Truncate.effective_db_path(p);
|
|
assert_eq!(a, b);
|
|
assert_ne!(a, p, "CI hosts always have a hostname");
|
|
assert_eq!(a.parent(), p.parent());
|
|
let name = a.file_name().unwrap().to_string_lossy().into_owned();
|
|
assert!(name.starts_with("worktrees.h-"), "{name}");
|
|
assert!(name.ends_with(".db"), "{name}");
|
|
|
|
// Idempotent: pre-resolved paths (for sidecar file ops) don't re-suffix.
|
|
assert_eq!(JournalMode::Truncate.effective_db_path(&a), a);
|
|
|
|
// Extension-less names get the suffix appended.
|
|
let bare = JournalMode::Truncate.effective_db_path(Path::new("/tmp/dir/state"));
|
|
let bare_name = bare.file_name().unwrap().to_string_lossy().into_owned();
|
|
assert!(bare_name.starts_with("state.h-"), "{bare_name}");
|
|
}
|
|
|
|
#[test]
|
|
fn network_mode_survives_legacy_wal_flip_back() {
|
|
// Journal mode is database-wide: a live old binary can flip a SHARED
|
|
// DB back to WAL underneath us. The per-host file makes that
|
|
// impossible by construction — old binaries never open it.
|
|
let tmp = TempDir::new().unwrap();
|
|
let legacy = tmp.path().join("db.sqlite");
|
|
|
|
let a = JournalMode::Truncate.open(&legacy).unwrap();
|
|
a.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('new');")
|
|
.unwrap();
|
|
assert_eq!(journal_mode(&a), "truncate");
|
|
|
|
// Simulated old binary: opens the legacy path, stamps WAL, writes.
|
|
let b = rusqlite::Connection::open(&legacy).unwrap();
|
|
b.pragma_update(None, "journal_mode", "WAL").unwrap();
|
|
b.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('old');")
|
|
.unwrap();
|
|
|
|
// A is unaffected: still truncate, reads and writes fine, and its
|
|
// own file never grows WAL sidecars.
|
|
assert_eq!(journal_mode(&a), "truncate");
|
|
a.execute("INSERT INTO t VALUES ('more')", []).unwrap();
|
|
let n: i64 = a
|
|
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(n, 2);
|
|
|
|
let eff = JournalMode::Truncate.effective_db_path(&legacy);
|
|
assert_ne!(eff, legacy);
|
|
let eff_base = eff.display().to_string();
|
|
assert!(!std::fs::exists(format!("{eff_base}-wal")).unwrap());
|
|
assert!(!std::fs::exists(format!("{eff_base}-shm")).unwrap());
|
|
// The legacy file really is in WAL with live sidecars — the two
|
|
// connections diverged onto different files.
|
|
let legacy_base = legacy.display().to_string();
|
|
assert!(std::fs::exists(format!("{legacy_base}-wal")).unwrap());
|
|
drop(b);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_sets_requested_mode() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let wal = rusqlite::Connection::open(tmp.path().join("wal.sqlite")).unwrap();
|
|
JournalMode::Wal.apply(&wal).unwrap();
|
|
assert_eq!(journal_mode(&wal), "wal");
|
|
|
|
let trunc = rusqlite::Connection::open(tmp.path().join("trunc.sqlite")).unwrap();
|
|
JournalMode::Truncate.apply(&trunc).unwrap();
|
|
assert_eq!(journal_mode(&trunc), "truncate");
|
|
}
|
|
|
|
#[test]
|
|
fn wal_stamped_db_converts_to_truncate() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let path = tmp.path().join("db.sqlite");
|
|
|
|
{
|
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
|
JournalMode::Wal.apply(&conn).unwrap();
|
|
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('keep');")
|
|
.unwrap();
|
|
}
|
|
|
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
|
JournalMode::Truncate.apply(&conn).unwrap();
|
|
assert_eq!(journal_mode(&conn), "truncate");
|
|
let v: String = conn.query_row("SELECT v FROM t", [], |r| r.get(0)).unwrap();
|
|
assert_eq!(v, "keep");
|
|
// Exercise the locking-mode downgrade: the next write transaction
|
|
// must acquire and release locks normally.
|
|
conn.execute("INSERT INTO t VALUES ('more')", []).unwrap();
|
|
drop(conn);
|
|
|
|
let base = path.display().to_string();
|
|
assert!(!std::fs::exists(format!("{base}-wal")).unwrap());
|
|
assert!(!std::fs::exists(format!("{base}-shm")).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn open_applies_mode() {
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let wal = JournalMode::Wal
|
|
.open(&tmp.path().join("wal.sqlite"))
|
|
.unwrap();
|
|
assert_eq!(journal_mode(&wal), "wal");
|
|
|
|
let trunc = JournalMode::Truncate
|
|
.open(&tmp.path().join("trunc.sqlite"))
|
|
.unwrap();
|
|
assert_eq!(journal_mode(&trunc), "truncate");
|
|
}
|
|
|
|
#[test]
|
|
fn open_readonly_missing_db_errors_and_creates_nothing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let path = tmp.path().join("missing.sqlite");
|
|
assert!(JournalMode::Wal.open_readonly(&path).is_err());
|
|
assert!(JournalMode::Truncate.open_readonly(&path).is_err());
|
|
// The Truncate arm opens read-write but must never create a file —
|
|
// neither the given path nor its per-host sibling.
|
|
assert!(!std::fs::exists(&path).unwrap());
|
|
assert!(!std::fs::exists(JournalMode::Truncate.effective_db_path(&path)).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn open_readonly_wal_rejects_writes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let path = tmp.path().join("db.sqlite");
|
|
{
|
|
let conn = JournalMode::Wal.open(&path).unwrap();
|
|
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('ro');")
|
|
.unwrap();
|
|
}
|
|
|
|
let conn = JournalMode::Wal.open_readonly(&path).unwrap();
|
|
let v: String = conn.query_row("SELECT v FROM t", [], |r| r.get(0)).unwrap();
|
|
assert_eq!(v, "ro");
|
|
assert!(conn.execute("INSERT INTO t VALUES ('nope')", []).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn open_readonly_truncate_rejects_writes() {
|
|
// The network arm's fd is read-write (conversion needs it), but
|
|
// query_only must make the connection honor the name.
|
|
let tmp = TempDir::new().unwrap();
|
|
let path = tmp.path().join("db.sqlite");
|
|
{
|
|
let conn = JournalMode::Truncate.open(&path).unwrap();
|
|
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('ro');")
|
|
.unwrap();
|
|
}
|
|
|
|
let conn = JournalMode::Truncate.open_readonly(&path).unwrap();
|
|
assert_eq!(journal_mode(&conn), "truncate");
|
|
let v: String = conn.query_row("SELECT v FROM t", [], |r| r.get(0)).unwrap();
|
|
assert_eq!(v, "ro");
|
|
assert!(conn.execute("INSERT INTO t VALUES ('nope')", []).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn open_readonly_truncate_never_opens_legacy_file() {
|
|
// A legacy (possibly WAL-poisoned) file at the shared path must not
|
|
// be touched: network-mode opens resolve to the per-host sibling,
|
|
// which doesn't exist yet → error → callers use their defaults.
|
|
let tmp = TempDir::new().unwrap();
|
|
let legacy = tmp.path().join("db.sqlite");
|
|
{
|
|
let conn = rusqlite::Connection::open(&legacy).unwrap();
|
|
JournalMode::Wal.apply(&conn).unwrap();
|
|
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('legacy');")
|
|
.unwrap();
|
|
}
|
|
|
|
assert!(JournalMode::Truncate.open_readonly(&legacy).is_err());
|
|
// The legacy file stays WAL-stamped and unconverted.
|
|
let conn = rusqlite::Connection::open(&legacy).unwrap();
|
|
assert_eq!(journal_mode(&conn), "wal");
|
|
}
|
|
}
|