Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ If a system Chromium is available, skip the browser download and run:

Linux browser dependencies can be installed with
`npx playwright install --with-deps chromium` from `web/`.
Run Unix diagnostic permission tests as an unprivileged user; they assert that
filesystem permissions deny writes, which a privileged runner can bypass.
The [pr-gate workflow](.github/workflows/pr-gate.yml) runs the web, Rust, and
browser checks on pull requests. Focused local tests help development but do
not replace required gates. Preserve exact failure evidence and report only
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Milestone 5 work. For the live demonstration deployment, see the
```sh
(cd web && npm ci && npm run build) # build the interface (embedded by cargo)
cargo run -p consolebook-server -- serve # initialize ./data and serve UI + API
cargo run -p consolebook-server -- doctor # diagnostics; see the current caveat below
cargo run -p consolebook-server -- doctor # read-only database diagnostics
cargo run -p consolebook-server -- backup # validated snapshot into ./data/backups
cargo run -p consolebook-server -- restore <snapshot> # recover from a snapshot (server stopped)
cargo run -p consolebook-server -- setup-code # fresh first-run setup code
Expand All @@ -86,10 +86,11 @@ durability, [ADR 0004](docs/decisions/0004-local-authentication.md) for
authentication, and [ADR 0005](docs/decisions/0005-embedded-web-interface.md)
for the embedded interface.

`doctor` currently has a read-only-contract defect: it may change a non-WAL
database to WAL before reporting its settings. See
[#56](https://github.com/FieldmouseWorks/consolebook/issues/56) before using it
on retained data.
`doctor` opens the database read-only and reports journal mismatches without
repairing them. SQLite may create WAL sidecars or update shared-memory
coordination; read-only storage without usable sidecars can prevent diagnosis.
Its connection-local PRAGMA checks describe the diagnostic connection.
See [ADR 0016](docs/decisions/0016-read-only-diagnostics.md) for the precise contract.

## Privacy

Expand Down
26 changes: 20 additions & 6 deletions crates/consolebook-server/src/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! `consolebook doctor`: diagnose an installation without changing it.
//!
//! Doctor never creates the database, never migrates, and never writes to the
//! data directory. It reports what it finds; the operator decides what to do.
//! Doctor never creates or writes the database, migrates, or sets journal mode.
//! SQLite may create WAL sidecars or update their coordination state (ADR 0016).
//! It reports what it finds; the operator decides what to do.

use anyhow::Result;
use sqlx::SqlitePool;
Expand Down Expand Up @@ -66,8 +67,13 @@ pub async fn run(data_dir: &DataDir) -> Vec<Finding> {
return findings;
}

match storage::open_existing(&db_path).await {
Err(err) => findings.push(Finding::fail("database", format!("cannot open: {err:#}"))),
match storage::open_diagnostic(&db_path).await {
Err(err) => findings.push(Finding::fail(
"database",
format!(
"cannot open read-only: {err:#}; WAL requires readable, usable -wal/-shm sidecars or permission to create them; doctor does not repair or bypass locking"
),
)),
Ok(pool) => {
findings.push(Finding::ok("database", format!("{}", db_path.display())));
check_invariants(&pool, &mut findings).await;
Expand Down Expand Up @@ -112,12 +118,20 @@ async fn check_invariants(pool: &SqlitePool, findings: &mut Vec<Finding>) {
Ok(checks) => {
for check in checks {
let name = format!("pragma {}", check.name);
let scope = if check.name == "journal_mode" {
"observed database mode; WAL is persisted"
} else {
"diagnostic connection only; does not inspect server connections"
};
if check.holds() {
findings.push(Finding::ok(name, check.actual));
findings.push(Finding::ok(name, format!("{} ({scope})", check.actual)));
} else {
findings.push(Finding::fail(
name,
format!("expected {}, got {}", check.expected, check.actual),
format!(
"expected {}, got {} ({scope})",
check.expected, check.actual
),
));
}
}
Expand Down
48 changes: 35 additions & 13 deletions crates/consolebook-server/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
//! `SQLite` storage with explicit, verified connection invariants.
//!
//! `docs/architecture.md` requires every connection to come from one explicit
//! options object that enables and verifies foreign-key enforcement, WAL
//! journaling, an intentional synchronous mode, a bounded busy timeout, and
//! Writable connections use explicit options that enable foreign-key enforcement,
//! WAL journaling, an intentional synchronous mode, a bounded busy timeout, and
//! application-owned migrations. Startup fails closed if any invariant does
//! not hold; `doctor` reports the same checks without failing the process.
//! not hold; `doctor` uses read-only options and reports observed mismatches.

use std::path::Path;
use std::time::Duration;
Expand Down Expand Up @@ -64,10 +63,10 @@ impl InvariantCheck {
}
}

/// The single options object every connection is created from.
/// Options for writable startup and backup connections.
///
/// `create_if_missing` is only enabled by [`open`]; diagnostic paths use
/// [`open_existing`] so `doctor` never creates a database as a side effect.
/// `create_if_missing` is only enabled by [`open`]. Diagnostics use
/// [`open_diagnostic`] and must never inherit the journal-mode setter.
fn connect_options(db_path: &Path, create: bool) -> SqliteConnectOptions {
SqliteConnectOptions::new()
.filename(db_path)
Expand Down Expand Up @@ -115,31 +114,54 @@ pub async fn open(db_path: &Path) -> Result<SqlitePool> {
}

/// Opens an existing database without creating one and without migrating.
/// Used by diagnostics and backup so they never mutate schema state.
/// Used by backup; applies writable connection settings, including WAL.
pub async fn open_existing(db_path: &Path) -> Result<SqlitePool> {
if !db_path.exists() {
bail!("database {} does not exist", db_path.display());
}
connect(db_path, false).await
}

/// Reads back the PRAGMA state the options object is supposed to guarantee.
/// Opens an existing database read-only, without migrations or journal changes.
///
/// Connection-local settings match startup, but `SQLx`'s unset journal-mode
/// default preserves the database's mode. SQLite may create WAL sidecars and
/// update shared-memory coordination state; see ADR 0016 for filesystem limits.
/// Never use `immutable`: diagnostics must see live WAL commits and take locks.
pub async fn open_diagnostic(db_path: &Path) -> Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(false)
.read_only(true)
.foreign_keys(true)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(BUSY_TIMEOUT);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.with_context(|| format!("opening database {} read-only", db_path.display()))
}

/// Reads all four PRAGMAs on one connection. Only WAL journal mode persists;
/// the other checks describe this connection, not a running server's settings.
pub async fn verify_invariants(pool: &SqlitePool) -> Result<Vec<InvariantCheck>> {
let mut connection = pool.acquire().await?;
let foreign_keys: i64 = sqlx::query("PRAGMA foreign_keys")
.fetch_one(pool)
.fetch_one(&mut *connection)
.await?
.get(0);
let journal_mode: String = sqlx::query("PRAGMA journal_mode")
.fetch_one(pool)
.fetch_one(&mut *connection)
.await?
.get(0);
// 1 = NORMAL. SQLite reports synchronous numerically.
let synchronous: i64 = sqlx::query("PRAGMA synchronous")
.fetch_one(pool)
.fetch_one(&mut *connection)
.await?
.get(0);
let busy_timeout_ms: i64 = sqlx::query("PRAGMA busy_timeout")
.fetch_one(pool)
.fetch_one(&mut *connection)
.await?
.get(0);

Expand Down
Loading