diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e8f904..efa2a04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index be8cb7b..cd3b26d 100644 --- a/README.md +++ b/README.md @@ -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 # recover from a snapshot (server stopped) cargo run -p consolebook-server -- setup-code # fresh first-run setup code @@ -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 diff --git a/crates/consolebook-server/src/doctor.rs b/crates/consolebook-server/src/doctor.rs index 2ef4180..4c9c9e0 100644 --- a/crates/consolebook-server/src/doctor.rs +++ b/crates/consolebook-server/src/doctor.rs @@ -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; @@ -66,8 +67,13 @@ pub async fn run(data_dir: &DataDir) -> Vec { 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; @@ -112,12 +118,20 @@ async fn check_invariants(pool: &SqlitePool, findings: &mut Vec) { 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 + ), )); } } diff --git a/crates/consolebook-server/src/storage.rs b/crates/consolebook-server/src/storage.rs index 61000b8..bffe087 100644 --- a/crates/consolebook-server/src/storage.rs +++ b/crates/consolebook-server/src/storage.rs @@ -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; @@ -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) @@ -115,7 +114,7 @@ pub async fn open(db_path: &Path) -> Result { } /// 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 { if !db_path.exists() { bail!("database {} does not exist", db_path.display()); @@ -123,23 +122,46 @@ pub async fn open_existing(db_path: &Path) -> Result { 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 { + 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> { + 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); diff --git a/crates/consolebook-server/tests/doctor_read_only.rs b/crates/consolebook-server/tests/doctor_read_only.rs new file mode 100644 index 0000000..798b7eb --- /dev/null +++ b/crates/consolebook-server/tests/doctor_read_only.rs @@ -0,0 +1,267 @@ +//! Diagnostic reads preserve database bytes and observe live WAL state. + +use consolebook_server::data_dir::DataDir; +use consolebook_server::{doctor, storage}; +use sqlx::Connection; +use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection}; + +async fn initialize_stopped(data: &DataDir) { + // A single explicitly closed connection makes WAL cleanup part of fixture + // setup, independent of asynchronous pool returns and maintenance tasks. + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(data.database()) + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal), + ) + .await + .expect("fixture connection"); + storage::MIGRATOR + .run(&mut connection) + .await + .expect("migrate fixture"); + sqlx::query("INSERT INTO instance (id, installation_id, created_at_utc) VALUES (1, 'invented-stopped-id', '2026-09-05T00:00:00Z')") + .execute(&mut connection) + .await + .expect("fixture identity"); + connection.close().await.expect("stop fixture"); + assert!(!data.root().join("consolebook.db-wal").exists()); + assert!(!data.root().join("consolebook.db-shm").exists()); +} + +fn installation() -> (tempfile::TempDir, DataDir) { + let tmp = tempfile::tempdir().expect("scratch directory"); + let data = DataDir::new(tmp.path().join("data")); + data.ensure_layout().expect("layout"); + (tmp, data) +} + +fn assert_healthy(findings: &[doctor::Finding]) { + assert!(!doctor::has_failure(findings), "{findings:?}"); + assert!(findings.iter().any(|f| f.check == "pragma journal_mode" + && f.verdict == doctor::Verdict::Ok + && f.detail.starts_with("wal (observed database mode"))); +} + +#[tokio::test] +async fn diagnostic_connection_cannot_create_a_missing_database() { + let (_tmp, data) = installation(); + assert!(storage::open_diagnostic(&data.database()).await.is_err()); + assert!(!data.database().exists()); + let findings = doctor::run(&data).await; + assert!(doctor::has_failure(&findings)); + assert!(!data.database().exists()); +} + +#[tokio::test] +async fn doctor_reports_delete_mode_without_changing_any_files() { + let (_tmp, data) = installation(); + // A migrated installation gives the remaining diagnostics real tables to + // inspect; changing its mode must be reported, never silently repaired. + initialize_stopped(&data).await; + let mut connection = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(data.database())) + .await + .expect("fixture connection"); + let mode: String = sqlx::query_scalar("PRAGMA journal_mode = DELETE") + .fetch_one(&mut connection) + .await + .expect("misconfigure fixture"); + assert_eq!(mode, "delete"); + connection.close().await.expect("close fixture"); + let before = std::fs::read(data.database()).expect("database bytes"); + + let findings = doctor::run(&data).await; + let failures: Vec<_> = findings + .iter() + .filter(|f| f.verdict == doctor::Verdict::Fail) + .collect(); + assert_eq!(failures.len(), 1, "{findings:?}"); + assert_eq!(failures[0].check, "pragma journal_mode"); + assert!(failures[0].detail.starts_with("expected wal, got delete")); + assert_eq!(before, std::fs::read(data.database()).expect("after")); + assert!(!data.root().join("consolebook.db-wal").exists()); + assert!(!data.root().join("consolebook.db-shm").exists()); + + let pool = storage::open_diagnostic(&data.database()) + .await + .expect("read-only connection"); + let mode: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&pool) + .await + .expect("observed mode"); + assert_eq!(mode, "delete"); + pool.close().await; +} + +#[tokio::test] +async fn diagnostic_connection_refuses_data_schema_and_journal_writes() { + let (_tmp, data) = installation(); + initialize_stopped(&data).await; + let before = std::fs::read(data.database()).expect("before"); + let pool = storage::open_diagnostic(&data.database()) + .await + .expect("diagnostic connection"); + for statement in [ + "UPDATE instance SET installation_id = 'invented-write' WHERE id = 1", + "CREATE TABLE invented_write (id INTEGER)", + "PRAGMA journal_mode = DELETE", + ] { + assert!(sqlx::query(statement).execute(&pool).await.is_err()); + } + pool.close().await; + assert_eq!(before, std::fs::read(data.database()).expect("after")); +} + +#[tokio::test] +async fn doctor_reads_live_wal_commits_and_preserves_database_and_wal_bytes() { + let (_tmp, data) = installation(); + let writer = storage::open(&data.database()).await.expect("initialize"); + // Keep one writer connection and put the next identity exclusively in WAL. + let mut connection = writer.acquire().await.expect("writer connection"); + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&mut *connection) + .await + .expect("checkpoint fixture"); + sqlx::query("UPDATE instance SET installation_id = 'invented-live-wal-id' WHERE id = 1") + .execute(&mut *connection) + .await + .expect("commit to WAL"); + let before = std::fs::read(data.database()).expect("before"); + assert!( + !before + .windows(b"invented-live-wal-id".len()) + .any(|bytes| bytes == b"invented-live-wal-id") + ); + let wal_path = data.root().join("consolebook.db-wal"); + let wal_before = std::fs::read(&wal_path).expect("WAL before"); + assert!(wal_before.len() > 32, "fixture needs committed WAL frames"); + + let findings = doctor::run(&data).await; + assert_healthy(&findings); + assert!( + findings + .iter() + .any(|f| f.check == "instance identity" && f.detail == "invented-live-wal-id") + ); + assert_eq!(before, std::fs::read(data.database()).expect("after")); + assert_eq!(wal_before, std::fs::read(wal_path).expect("WAL after")); + drop(connection); + writer.close().await; +} + +#[tokio::test] +async fn doctor_reads_stopped_wal_installation_without_changing_database_bytes() { + let (_tmp, data) = installation(); + initialize_stopped(&data).await; + assert!(!data.root().join("consolebook.db-wal").exists()); + assert!(!data.root().join("consolebook.db-shm").exists()); + let before = std::fs::read(data.database()).expect("before"); + let findings = doctor::run(&data).await; + assert_healthy(&findings); + assert_eq!(before, std::fs::read(data.database()).expect("after")); + for name in ["foreign_keys", "synchronous", "busy_timeout_ms"] { + assert!(findings.iter().any(|f| f.check == format!("pragma {name}") + && f.detail.contains("diagnostic connection only"))); + } +} + +#[cfg(unix)] +mod permissions { + use super::{initialize_stopped, installation, storage}; + use std::os::unix::fs::PermissionsExt; + + fn run_doctor(data: &consolebook_server::data_dir::DataDir) -> std::process::Output { + // A separate process cannot reuse the fixture writer's writable SHM + // mapping after chmod. Exercise the actual operator-facing command. + std::process::Command::new(env!("CARGO_BIN_EXE_consolebook-server")) + .arg("--data-dir") + .arg(data.root()) + .arg("doctor") + .output() + .expect("doctor process") + } + + // Restore permissions even if an assertion fails, so TempDir can clean up. + struct ReadOnlyDirectory(std::path::PathBuf); + + impl ReadOnlyDirectory { + fn new(path: &std::path::Path) -> Self { + for entry in std::fs::read_dir(path).expect("directory") { + let path = entry.expect("entry").path(); + if path.is_file() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o444)) + .expect("read-only file"); + } + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o555)) + .expect("read-only directory"); + Self(path.to_path_buf()) + } + } + + impl Drop for ReadOnlyDirectory { + fn drop(&mut self) { + std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)) + .expect("restore directory permissions"); + for entry in std::fs::read_dir(&self.0).expect("directory") { + let path = entry.expect("entry").path(); + if path.is_file() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)) + .expect("restore file permissions"); + } + } + } + } + + #[tokio::test] + async fn read_only_directory_without_wal_sidecars_reports_failure() { + let (_tmp, data) = installation(); + initialize_stopped(&data).await; + assert!(!data.root().join("consolebook.db-wal").exists()); + assert!(!data.root().join("consolebook.db-shm").exists()); + let before = std::fs::read(data.database()).expect("before"); + let _permissions = ReadOnlyDirectory::new(data.root()); + // A privileged runner would make chmod-based filesystem proof false. + assert!( + std::fs::File::create(data.root().join("write-probe")).is_err(), + "run permission tests as an unprivileged user" + ); + let output = run_doctor(&data); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(before, std::fs::read(data.database()).expect("after")); + assert!(!data.root().join("consolebook.db-wal").exists()); + assert!(!data.root().join("consolebook.db-shm").exists()); + } + + #[tokio::test] + async fn readable_live_wal_sidecars_allow_diagnosis_in_read_only_directory() { + let (_tmp, data) = installation(); + let writer = storage::open(&data.database()).await.expect("initialize"); + let permissions = ReadOnlyDirectory::new(data.root()); + assert!( + std::fs::File::create(data.root().join("write-probe")).is_err(), + "run permission tests as an unprivileged user" + ); + let paths = [ + data.database(), + data.root().join("consolebook.db-wal"), + data.root().join("consolebook.db-shm"), + ]; + let before: Vec<_> = paths + .iter() + .map(|p| std::fs::read(p).expect("before")) + .collect(); + let output = run_doctor(&data); + assert!(output.status.success(), "{output:?}"); + for (path, bytes) in paths.iter().zip(before) { + assert!( + bytes == std::fs::read(path).expect("after"), + "changed {}", + path.display() + ); + } + drop(permissions); + writer.close().await; + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 066267c..a6b6278 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ source map lives in `docs/development.md`. SQLite is the operational database. -Connections must be created from one explicit options object that enables and verifies: +Writable connections use one explicit options object that enables and verifies: - foreign-key enforcement; - WAL journaling; @@ -56,11 +56,13 @@ Connections must be created from one explicit options object that enables and ve - a bounded busy timeout; and - application-owned migrations. -Startup verifies these invariants and fails closed. ADR 0003 requires -`consolebook doctor` to inspect without changing state. It does not create or -migrate a database, but its current connection path can change a non-WAL -database's journal mode; [#56](https://github.com/FieldmouseWorks/consolebook/issues/56) -tracks restoring the read-only contract. +Startup verifies these invariants and fails closed. `consolebook doctor` uses +a separate read-only connection that observes journal mode without setting it +and never creates, migrates, or writes the database. SQLite may create WAL +sidecars or update shared-memory coordination; diagnosis can fail on read-only +storage without usable sidecars. [ADR 0016](decisions/0016-read-only-diagnostics.md) +defines these limits and distinguishes connection-local PRAGMAs from persisted +WAL mode. ### User interface diff --git a/docs/decisions/0003-sqlite-connection-invariants.md b/docs/decisions/0003-sqlite-connection-invariants.md index 9ed2b37..3008f6f 100644 --- a/docs/decisions/0003-sqlite-connection-invariants.md +++ b/docs/decisions/0003-sqlite-connection-invariants.md @@ -2,6 +2,8 @@ - **Status:** Accepted - **Date:** 2026-08-28 +- **Amended by:** [ADR 0016](0016-read-only-diagnostics.md), which separates + diagnostic connection options and defines WAL-sidecar and PRAGMA scope. ## Context @@ -14,7 +16,7 @@ verified, not assumed. ## Decision -Every connection is created from a single options object +Every writable connection is created from a single options object (`storage::connect_options`) that sets: - `foreign_keys = ON` — referential integrity is enforced by the database, @@ -29,8 +31,9 @@ Every connection is created from a single options object immediately or hanging forever. Startup re-reads the four PRAGMA values and **fails closed** if any does not -hold. `consolebook doctor` reports the same checks without mutating the -installation: it never creates the database and never runs migrations. +hold. `consolebook doctor` reports the same checks through the read-only path +in ADR 0016: it never creates or writes the database and never runs migrations. +SQLite may create WAL sidecars and update shared-memory coordination state. Migrations are application-owned, embedded in the executable (`sqlx::migrate!`), and applied on startup. Diagnostic and backup paths open diff --git a/docs/decisions/0016-read-only-diagnostics.md b/docs/decisions/0016-read-only-diagnostics.md new file mode 100644 index 0000000..94f766b --- /dev/null +++ b/docs/decisions/0016-read-only-diagnostics.md @@ -0,0 +1,85 @@ +# ADR 0016: Read-only SQLite diagnostics + +- **Status:** Accepted +- **Date:** 2026-09-05 +- **Issue:** [#56](https://github.com/FieldmouseWorks/consolebook/issues/56) +- **Amends:** [ADR 0003](0003-sqlite-connection-invariants.md) + +## Context + +`doctor` used the writable backup connection options, which set journal mode +to WAL before checking it. Diagnosing a DELETE-mode database therefore changed +its header and reported the newly applied mode as healthy. Disabling creation +and migrations alone does not make a connection read-only. + +SQLite also uses filesystem state to coordinate WAL readers. A promise that a +live diagnostic never touches any file in the data directory is too broad for +ordinary SQLite locking. We need to distinguish retained database content from +WAL coordination, and connection settings from persisted database settings. + +## Decision + +`storage::open_diagnostic` owns an explicit read-only options path with database +creation disabled and no journal-mode setter. SQLx leaves journal mode unset by +default. Startup and backup continue using their existing writable options; +diagnostics never migrate, repair, checkpoint, or change journal mode. + +The diagnostic pool has one connection. It sets the same connection-local +foreign-key, synchronous, and busy-timeout values as startup and shares +`verify_invariants`, which reads all four values on one acquired connection. +The report labels their scope: + +| PRAGMA | What the diagnostic observes | +| --- | --- | +| `journal_mode` | Database journaling mode; WAL persists across connections. A newly opened non-WAL database normally reports DELETE; this does not recover another connection's transient TRUNCATE, PERSIST, MEMORY, or OFF setting. A non-WAL result fails the expected-WAL check. | +| `foreign_keys` | Enforcement enabled on this diagnostic connection. It cannot inspect another connection's enforcement. | +| `synchronous` | NORMAL on this diagnostic connection. It does not measure another connection's durability setting. | +| `busy_timeout` | 5000 ms on this diagnostic connection. It does not inspect another process's timeout. | + +The read-only SQLite open flag rejects database writes even when the operating +system user has write permission. The diagnostic does not alter existing main +database or WAL content. Normal SQLite locking remains enabled so committed +changes still in a live WAL are visible. + +### WAL sidecars and read-only filesystems + +SQLite may create absent `-wal` and `-shm` sidecars in a writable directory and +update shared-memory reader coordination. These are an explicit exception to +the former literal "never writes to the data directory" description. The +diagnostic does not append transactions, checkpoint, remove sidecars itself, +or rewrite the main database. A live writer may of course change its own files +while diagnosis runs; several diagnostic queries are not one installation-wide +snapshot. + +On read-only storage, WAL diagnosis requires existing readable, usable sidecars. +If SQLite needs to create or recover coordination state and cannot, `doctor` +reports failure and leaves repair to the operator. A cleanly stopped WAL +installation without sidecars can be diagnosed in a writable directory; the +same installation on read-only storage may fail. File readability alone is +not proof that a WAL index is usable. + +Never use `immutable=1`, disable locking, or fall back to a raw file copy to +force success. An installation can be live or retain uncheckpointed WAL +commits; bypassing SQLite's concurrency protocol cannot establish a correct +view. Do not automatically change mode or remove sidecars to accommodate +read-only media. + +These semantics follow SQLite's [read-only WAL rules](https://www.sqlite.org/wal.html#read_only_databases) +and [PRAGMA documentation](https://www.sqlite.org/pragma.html). + +## Consequences and proof + +`doctor` can honestly report a journal mismatch while preserving the database. +Its connection-local checks validate its own configured connection, not the +configuration of a separately running server. No schema, backup format, or +startup durability setting changes. + +`tests/doctor_read_only.rs` covers absent databases, a migrated DELETE-mode +database remaining byte-identical, rejected data/schema/journal writes, +stopped WAL diagnosis, and reading an identity committed only to a live WAL +without changing main database or WAL bytes. Unix permission tests cover a +read-only directory with usable live sidecars and failure without sidecars; +they require an unprivileged runner and assert that writes are denied. These +permission tests are not a mounted read-only filesystem or crash-recovery drill. +Existing operable-shell and backup/restore tests cover the unchanged writable +paths. diff --git a/docs/development.md b/docs/development.md index 177d393..dfcad4b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,7 +12,7 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. | Task | Start in `crates/consolebook-server/src/` | Supporting context | | --- | --- | --- | -| Process, storage, diagnostics | `main.rs`, `data_dir.rs`, `storage.rs`, `doctor.rs` | [Architecture](architecture.md), [ADR 0003](decisions/0003-sqlite-connection-invariants.md) | +| Process, storage, diagnostics | `main.rs`, `data_dir.rs`, `storage.rs`, `doctor.rs` | [Architecture](architecture.md), [ADR 0003](decisions/0003-sqlite-connection-invariants.md), [ADR 0016](decisions/0016-read-only-diagnostics.md) | | Backups and restore | `backup.rs`, `scheduler.rs`, `restore.rs`, `serve_lock.rs` | [ADR 0006](decisions/0006-backup-scheduling-and-restore.md) | | Setup, login, recovery | `setup.rs`, `users.rs`, `sessions.rs`, `secrets.rs` | [ADR 0004](decisions/0004-local-authentication.md) | | Capabilities and assignments | `capabilities.rs`, `assignments.rs`, `draft_access.rs` | [ADR 0010](decisions/0010-service-owned-authorization-boundary.md), [Domain model](domain-model.md) | diff --git a/docs/preview.md b/docs/preview.md index 4df1219..90f184c 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -46,9 +46,12 @@ An unauthenticated HTTPS request should receive `401` with a Basic Auth challenge. A local health response should report database `ok`; neither check proves recovery or a complete user workflow. Logs are in the system journal; first-run logs can contain the short-lived setup code, so inspect privately -and redact before sharing. Avoid `doctor` on retained data pending -[#56](https://github.com/FieldmouseWorks/consolebook/issues/56): its current -connection path can change persisted journal mode while diagnosing it. +and redact before sharing. Before using `doctor` on retained data, confirm the +installed binary includes the [#56](https://github.com/FieldmouseWorks/consolebook/issues/56) +repair: older binaries can change journal mode while diagnosing it. Repository +changes do not update that separately installed binary. +[ADR 0016](decisions/0016-read-only-diagnostics.md) defines the repaired command's +read-only database contract and WAL-sidecar limits. ## Updating the preview