diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -583,6 +583,7 @@ dependencies = [
  "cadcraft-io",
  "cadcraft-mcp",
  "cadcraft-render",
+ "cadcraft-storage",
  "serde_json",
 ]
 
@@ -715,6 +716,16 @@ dependencies = [
  "tiny-skia",
 ]
 
+[[package]]
+name = "cadcraft-storage"
+version = "0.12.0"
+dependencies = [
+ "base64",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
 [[package]]
 name = "cadcraft-ui-egui"
 version = "0.12.0"
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ cadcraft-io = { path = "crates/io" }
 cadcraft-engine = { path = "crates/engine" }
 cadcraft-ui-egui = { path = "crates/ui-egui" }
 cadcraft-mcp = { path = "crates/mcp" }
+cadcraft-storage = { path = "crates/storage" }
 # Which paths stored in a drawing (xrefs, data links) may be opened.
 craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
 # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
@@ -31,6 +32,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
 craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false }
 
 # foundation
+base64 = "0.22"
 thiserror = "2"
 serde = { version = "1", features = ["derive", "rc"] }
 serde_json = { version = "1", features = ["float_roundtrip"] }
diff --git a/apps/cadcraft-cli/Cargo.toml b/apps/cadcraft-cli/Cargo.toml
--- a/apps/cadcraft-cli/Cargo.toml
+++ b/apps/cadcraft-cli/Cargo.toml
@@ -14,4 +14,5 @@ cadcraft-engine = { workspace = true }
 cadcraft-io = { workspace = true }
 cadcraft-mcp = { workspace = true }
 cadcraft-render = { workspace = true }
+cadcraft-storage = { workspace = true }
 serde_json = { workspace = true }
diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs
--- a/apps/cadcraft-cli/src/main.rs
+++ b/apps/cadcraft-cli/src/main.rs
@@ -63,6 +63,22 @@ const USAGE: &str = "usage:
   cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
   cadcraft-cli perf [N | FILE]
   cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
+  cadcraft-cli store-write PATH FILE [--store local --store-root DIR | --store webdav
+                --store-url URL [--store-user U --store-password P | --store-token T]] [--etag TAG] [--atomic]
+                                          upload a local file to storage at PATH (--etag writes
+                                          only if the stored version still carries TAG)
+  cadcraft-cli store-read PATH OUT [--store …]  download storage PATH to local file OUT
+  cadcraft-cli store-stat PATH [--store …]   metadata (size, ETag) as JSON
+  cadcraft-cli store-list [PREFIX] [--store …] direct children as JSON
+  cadcraft-cli store-remove PATH [--store …] delete (missing: success)
+  cadcraft-cli store-probe [--store …]       capabilities + server probe as JSON
+  cadcraft-cli store-recover [--store …]     finish or drop interrupted writes, as JSON
+                --store-root is a local/NAS/synced folder; --store-url is the WebDAV
+                collection, e.g. http://ocis.lan:9200/dav/spaces/<id>/
+                (plain http only: LAN, VPN or SSH tunnel; https is refused, see wordcraft#518).
+                WebDAV auth is Basic (--store-user/--store-password) or an OIDC
+                Bearer token (--store-token, or $CRAFT_DAV_TOKEN for k8s secretKeyRef),
+                never both.
   cadcraft-cli --version
 
 image framing (png 2400x1600, svg, pdf; always model space):
@@ -217,6 +233,20 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
 }
 
 const NO_OPTIONS: &[OptionSpec] = &[];
+const STORE_OPTIONS: &[OptionSpec] = &[
+    OptionSpec { name: "--store", takes_value: true },
+    OptionSpec { name: "--store-root", takes_value: true },
+    OptionSpec { name: "--store-url", takes_value: true },
+    OptionSpec { name: "--store-user", takes_value: true },
+    OptionSpec { name: "--store-password", takes_value: true },
+    OptionSpec { name: "--store-token", takes_value: true },
+    OptionSpec { name: "--etag", takes_value: true },
+    OptionSpec { name: "--atomic", takes_value: false },
+];
+
+fn arg_value(args: &[String], name: &str) -> Option<String> {
+    args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
+}
 
 fn info(path: &str) -> Result<(), String> {
     let mut s = Session::empty();
@@ -771,6 +801,137 @@ fn mcp(args: &[String]) -> Result<(), String> {
     cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
 }
 
+/// Open the storage backend named by the `--store*` flags: `local` (a folder at `--store-root`)
+/// or `webdav` (a collection at `--store-url`, Basic auth via `--store-user`/`--store-password`
+/// or an OIDC/Bearer token via `--store-token`; the two schemes are mutually exclusive).
+fn open_store(command: &str, args: &[String]) -> Result<Box<dyn cadcraft_storage::StorageBackend>, String> {
+    match arg_value(args, "--store").as_deref().unwrap_or("local") {
+        "local" => {
+            let root = arg_value(args, "--store-root").ok_or_else(|| format!("{command}: --store local needs --store-root DIR"))?;
+            if arg_value(args, "--store-url").is_some() {
+                return Err(format!("{command}: --store-url is for --store webdav, not local"));
+            }
+            cadcraft_storage::local::LocalBackend::open(std::path::Path::new(&root))
+                .map(|b| Box::new(b) as Box<dyn cadcraft_storage::StorageBackend>)
+                .map_err(|e| format!("{command}: {e}"))
+        }
+        "webdav" => {
+            let url = arg_value(args, "--store-url").ok_or_else(|| format!("{command}: --store webdav needs --store-url URL"))?;
+            if arg_value(args, "--store-root").is_some() {
+                return Err(format!("{command}: --store-root is for --store local, not webdav"));
+            }
+            let (user, password, token) = (arg_value(args, "--store-user"), arg_value(args, "--store-password"), arg_value(args, "--store-token"));
+            // k8s-friendly: the token may arrive via env (secretKeyRef) instead of argv.
+            let token = token.or_else(|| std::env::var("CRAFT_DAV_TOKEN").ok().filter(|t| !t.is_empty()));
+            if token.is_some() && (user.is_some() || password.is_some()) {
+                return Err(format!("{command}: --store-token cannot be combined with --store-user/--store-password (pick one auth scheme)"));
+            }
+            let backend = match (token, user, password) {
+                (Some(t), None, None) => cadcraft_storage::webdav::WebDavBackend::with_bearer(&url, &t),
+                (None, u, p) => cadcraft_storage::webdav::WebDavBackend::new(&url, u.as_deref(), p.as_deref()),
+                _ => {
+                    return Err(format!("{command}: --store-token cannot be combined with --store-user/--store-password (pick one auth scheme)"));
+                }
+            };
+            backend.map(|b| Box::new(b) as Box<dyn cadcraft_storage::StorageBackend>).map_err(|e| format!("{command}: {e}"))
+        }
+        other => Err(format!("{command}: unknown --store `{other}` (local or webdav)")),
+    }
+}
+
+/// The positional arguments of a `store-*` command: option values (`--store local`, …) are not
+/// positionals, wherever they appear. Unknown options are rejected by `validate_options` first.
+fn store_positionals(args: &[String]) -> Vec<String> {
+    let mut out = Vec::new();
+    let mut i = 0;
+    while i < args.len() {
+        let arg = &args[i];
+        if arg.starts_with("--") {
+            let takes = STORE_OPTIONS.iter().find(|spec| spec.name == arg).map(|spec| spec.takes_value).unwrap_or(false);
+            i += if takes { 2 } else { 1 };
+        } else {
+            out.push(arg.clone());
+            i += 1;
+        }
+    }
+    out
+}
+
+fn store_command(command: &str, rest: &[String]) -> Result<(), String> {
+    validate_options(command, rest, STORE_OPTIONS)?;
+    let positional = store_positionals(rest);
+    let pos = |i: usize| positional.get(i).cloned().ok_or_else(|| USAGE.to_string());
+    let store = open_store(command, rest)?;
+    match command {
+        "store-write" => {
+            let (path, file) = (pos(0)?, pos(1)?);
+            let bytes = std::fs::read(&file).map_err(|e| format!("{file}: {e}"))?;
+            let opts = cadcraft_storage::WriteOptions { if_match: arg_value(rest, "--etag"), atomic: rest.iter().any(|a| a == "--atomic") };
+            let meta = store.write(&path, &bytes, &opts).map_err(|e| format!("{path}: {e}"))?;
+            outln!("{}", serde_json::to_string_pretty(&json!({"path": path, "size": meta.size, "etag": meta.etag})).unwrap_or_default());
+            eprintln!("wrote {path} ({} bytes)", bytes.len());
+            Ok(())
+        }
+        "store-read" => {
+            let (path, out) = (pos(0)?, pos(1)?);
+            let bytes = store.read(&path).map_err(|e| format!("{path}: {e}"))?;
+            std::fs::write(&out, &bytes).map_err(|e| format!("{out}: {e}"))?;
+            eprintln!("wrote {out} ({} bytes)", bytes.len());
+            Ok(())
+        }
+        "store-stat" => {
+            let path = pos(0)?;
+            let meta = store.stat(&path).map_err(|e| format!("{path}: {e}"))?;
+            outln!(
+                "{}",
+                serde_json::to_string_pretty(
+                    &json!({"path": path, "size": meta.size, "etag": meta.etag, "modified": meta.modified, "isDir": meta.is_dir})
+                )
+                .unwrap_or_default()
+            );
+            Ok(())
+        }
+        "store-list" => {
+            let prefix = positional.first().cloned().unwrap_or_default();
+            let entries = store.list(&prefix).map_err(|e| format!("{}: {e}", if prefix.is_empty() { "(root)" } else { &prefix }))?;
+            let list: Vec<Value> =
+                entries.iter().map(|e| json!({"path": e.path, "size": e.meta.size, "etag": e.meta.etag, "isDir": e.meta.is_dir})).collect();
+            outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
+            Ok(())
+        }
+        "store-remove" => {
+            let path = pos(0)?;
+            store.remove(&path).map_err(|e| format!("{path}: {e}"))?;
+            eprintln!("removed {path}");
+            Ok(())
+        }
+        "store-probe" => {
+            let caps = store.capabilities();
+            let probe = store.probe().map_err(|e| e.to_string())?;
+            outln!(
+                "{}",
+                serde_json::to_string_pretty(&json!({
+                    "atomicWrite": caps.atomic_write, "conditionalWrite": caps.conditional_write, "list": caps.list,
+                    "etag": caps.etag, "persistent": caps.persistent,
+                    "davClasses": probe.dav_classes, "notes": probe.notes,
+                }))
+                .unwrap_or_default()
+            );
+            Ok(())
+        }
+        _ => {
+            // store-recover: finish or drop interrupted writes; safe at every startup.
+            let report = store.recover().map_err(|e| e.to_string())?;
+            outln!(
+                "{}",
+                serde_json::to_string_pretty(&json!({"completed": report.completed, "dropped": report.dropped, "removedTemp": report.removed_temp}))
+                    .unwrap_or_default()
+            );
+            Ok(())
+        }
+    }
+}
+
 #[cfg(test)]
 mod mcp_args_tests {
     use super::*;
@@ -839,6 +1000,9 @@ fn cli() -> ExitCode {
             outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
         }),
         Some("mcp") => mcp(&rest),
+        Some(cmd @ ("store-write" | "store-read" | "store-stat" | "store-list" | "store-remove" | "store-probe" | "store-recover")) => {
+            validate_options(cmd, &rest, STORE_OPTIONS).and_then(|_| store_command(cmd, &rest))
+        }
         Some("--version" | "-V") => {
             outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
             Ok(())
@@ -1071,4 +1235,51 @@ mod tests {
         assert_eq!(validate_options("perf", &args(&["1", "--typo"]), NO_OPTIONS), Err("perf: unknown option --typo".into()));
         assert_eq!(validate_options("sample", &args(&["bracket", "out.dxf", "--typo"]), NO_OPTIONS), Err("sample: unknown option --typo".into()));
     }
+
+    #[test]
+    fn store_flags_open_the_right_backend_or_explain() {
+        assert!(
+            validate_options(
+                "store-write",
+                &args(&["a.dxf", "f.dxf", "--store", "local", "--store-root", "x", "--etag", "t", "--atomic"]),
+                STORE_OPTIONS
+            )
+            .is_ok()
+        );
+        assert!(
+            validate_options(
+                "store-probe",
+                &args(&["--store", "webdav", "--store-url", "http://h/dav/", "--store-user", "u", "--store-password", "p"]),
+                STORE_OPTIONS
+            )
+            .is_ok()
+        );
+        assert!(validate_options("store-list", &args(&["--bogus"]), STORE_OPTIONS).is_err());
+        assert!(open_store("store-probe", &args(&["--store", "webdav", "--store-url", "http://127.0.0.1:9/dav/"])).is_ok());
+        assert!(open_store("store-probe", &args(&["--store", "webdav", "--store-url", "https://h/dav/"])).is_err());
+        // Bearer token: accepted alone, rejected with user/password, rejected empty.
+        assert!(
+            validate_options("store-probe", &args(&["--store", "webdav", "--store-url", "http://h/dav/", "--store-token", "t"]), STORE_OPTIONS)
+                .is_ok()
+        );
+        assert!(open_store("store-probe", &args(&["--store", "webdav", "--store-url", "http://127.0.0.1:9/dav/", "--store-token", "t"])).is_ok());
+        assert!(
+            open_store(
+                "store-probe",
+                &args(&["--store", "webdav", "--store-url", "http://127.0.0.1:9/dav/", "--store-token", "t", "--store-user", "u"])
+            )
+            .is_err()
+        );
+        assert!(open_store("store-probe", &args(&["--store", "webdav", "--store-url", "http://127.0.0.1:9/dav/", "--store-token", ""])).is_err());
+        assert!(open_store("store-probe", &args(&[])).is_err());
+        assert!(open_store("store-probe", &args(&["--store", "tape"])).is_err());
+        assert!(open_store("store-probe", &args(&["--store", "local", "--store-root", "x", "--store-url", "http://h/"])).is_err());
+    }
+
+    #[test]
+    fn store_positionals_skip_option_values_wherever_they_appear() {
+        assert_eq!(store_positionals(&args(&["--store", "local", "--store-root", "r", "a.dxf", "f.dxf", "--atomic"])), args(&["a.dxf", "f.dxf"]));
+        assert_eq!(store_positionals(&args(&["a.dxf", "--etag", "t"])), args(&["a.dxf"]));
+        assert!(store_positionals(&args(&["--atomic"])).is_empty());
+    }
 }
diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -91,3 +91,7 @@ sync_github_name = false
 real_name = "Robert Lee"
 sync_github_name = true
 
+
+[people.mission-deny-the-mission]
+display_name = "Harry Hall"
+sync_github_name = false
diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/crates/storage/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "cadcraft-storage"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "CADCraft storage backends (storytold/wordcraft#518, Phase 0-1): a StorageBackend trait with capability probing, atomic writes and a recovery-journal hook; local-filesystem and WebDAV (oCIS-compatible) backends"
+
+[lints]
+workspace = true
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+base64 = { workspace = true }
+
+[dev-dependencies]
+serde_json = { workspace = true }
diff --git a/crates/storage/src/journal.rs b/crates/storage/src/journal.rs
new file mode 100644
--- /dev/null
+++ b/crates/storage/src/journal.rs
@@ -0,0 +1,111 @@
+//! The crash-recovery journal both backends share.
+//!
+//! Before a backend makes an interruptible write visible, it appends one JSON line naming the
+//! staged bytes and the final name; after the commit it appends a matching `committed` line.
+//! [`read`] + [`plan`] turn that log into work for [`StorageBackend::recover`][crate::StorageBackend::recover]:
+//! entries with no matching commit are finished (the staged file is renamed into place) or
+//! dropped (nothing staged survived). Phase 2 (version history) records its snapshots beside
+//! these entries; Phase 3 (lease files) takes the same append-then-commit shape.
+
+use serde::{Deserialize, Serialize};
+
+/// One line of the journal: a write that started (`Pending`) or finished (`Committed`).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct JournalEntry {
+    /// Final backend-relative name the write targets.
+    pub path: String,
+    /// Staging name holding the bytes (backend-local: a sibling temp file, a WebDAV temp name).
+    pub staged: String,
+    /// Which half of the story this line tells.
+    pub state: JournalState,
+    /// Bytes the staged write holds; used to drop half-records that can't be completed.
+    pub len: u64,
+}
+
+/// Which half of a journaled write a line records.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum JournalState {
+    /// The staged bytes are complete; the commit may not have happened.
+    Pending,
+    /// The staged bytes were committed to `path`.
+    Committed,
+}
+
+/// The journal file's name inside a backend root (local folder, WebDAV collection).
+pub const JOURNAL_FILE: &str = ".cadcraft-journal.log";
+
+/// Read every well-formed line of `bytes` (a journal file); corrupt lines are skipped, never
+/// fatal — a torn final line is the normal residue of a crash.
+pub fn read(bytes: &[u8]) -> Vec<JournalEntry> {
+    let text = String::from_utf8_lossy(bytes);
+    text.lines().filter_map(|line| serde_json::from_str::<JournalEntry>(line.trim()).ok()).collect()
+}
+
+/// What [`StorageBackend::recover`][crate::StorageBackend::recover] should do, derived from the
+/// journal lines alone: pending writes with no later commit (`complete`), and paths whose every
+/// line committed (`done`, for the byte counts in the report).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct RecoveryPlan {
+    /// Pending writes with no matching commit, oldest first.
+    pub complete: Vec<JournalEntry>,
+    /// Paths fully committed (informational: counted as completed in the report).
+    pub done: Vec<String>,
+}
+
+impl RecoveryPlan {
+    /// Derive the plan from journal lines in file order.
+    pub fn plan(entries: &[JournalEntry]) -> RecoveryPlan {
+        let mut pending: Vec<&JournalEntry> = Vec::new();
+        let mut done: Vec<String> = Vec::new();
+        for e in entries {
+            match e.state {
+                JournalState::Pending => pending.push(e),
+                JournalState::Committed => {
+                    if let Some(i) = pending.iter().position(|p| p.path == e.path && p.staged == e.staged) {
+                        pending.remove(i);
+                    }
+                    if !done.contains(&e.path) {
+                        done.push(e.path.clone());
+                    }
+                }
+            }
+        }
+        RecoveryPlan { complete: pending.into_iter().cloned().collect(), done }
+    }
+}
+
+/// One JSON line for the journal file (with trailing newline).
+pub fn line(entry: &JournalEntry) -> String {
+    let mut s = serde_json::to_string(entry).unwrap_or_else(|_| "{}".into());
+    s.push('\n');
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn entry(path: &str, staged: &str, state: JournalState) -> JournalEntry {
+        JournalEntry { path: path.into(), staged: staged.into(), state, len: 3 }
+    }
+
+    #[test]
+    fn torn_tail_is_skipped() {
+        let mut log = line(&entry("a.dxf", "a.tmp", JournalState::Pending));
+        log.push_str("{\"path\": \"b.dxf");
+        let got = read(log.as_bytes());
+        assert_eq!(got.len(), 1);
+        assert_eq!(got[0].path, "a.dxf");
+    }
+
+    #[test]
+    fn plan_completes_only_uncommitted() {
+        let entries =
+            vec![entry("a", "a.1", JournalState::Pending), entry("a", "a.1", JournalState::Committed), entry("b", "b.1", JournalState::Pending)];
+        let plan = RecoveryPlan::plan(&entries);
+        assert_eq!(plan.complete.len(), 1);
+        assert_eq!(plan.complete[0].path, "b");
+        assert_eq!(plan.done, vec!["a".to_string()]);
+    }
+}
diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/crates/storage/src/lib.rs
@@ -0,0 +1,202 @@
+//! Pluggable save targets (storytold/wordcraft#518, Phase 0-1).
+//!
+//! [`StorageBackend`] abstracts where document bytes live: a local or NAS folder today, an
+//! rclone transport or native S3 later. Every action on a backend goes through this trait, so
+//! the typing path never touches the network: callers serialise first (pure bytes), then call
+//! one blocking method.
+//!
+//! The contract per method:
+//! - [`StorageBackend::read`] / [`StorageBackend::write`] / [`StorageBackend::stat`] /
+//!   [`StorageBackend::list`] move bytes and metadata.
+//! - [`StorageBackend::capabilities`] reports what the backend can do without any I/O, so
+//!   callers can adapt (e.g. skip `If-Match` where conditional writes are unsupported).
+//! - [`StorageBackend::probe`] optionally talks to the server (an `OPTIONS` round trip on
+//!   WebDAV) and refines the capabilities.
+//! - Writes that set [`WriteOptions::atomic`] must be all-or-nothing as seen by a later `read`:
+//!   the local backend stages a sibling temporary file and renames; WebDAV relies on the
+//!   server's single-`PUT` semantics.
+//! - [`StorageBackend::recover`] replays or clears the [`journal`] left by an interrupted write,
+//!   so the next startup lands in a known state. Phase 3 (lease files) builds on the journal.
+
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+pub mod journal;
+pub mod local;
+pub mod webdav;
+
+use thiserror::Error;
+
+/// What a backend knows about one stored file.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FileMeta {
+    /// Byte length.
+    pub size: u64,
+    /// Opaque version tag (a WebDAV `ETag`, stripped of quotes); `None` where untracked.
+    pub etag: Option<String>,
+    /// Last-modified stamp as the backend reports it (HTTP date, file mtime); display only.
+    pub modified: Option<String>,
+    /// `true` for a collection (folder); `false` for a file.
+    pub is_dir: bool,
+}
+
+/// One entry of a [`StorageBackend::list`] result: the backend-relative path plus its metadata.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Entry {
+    /// Backend-relative path with `/` separators (no leading slash).
+    pub path: String,
+    /// What the backend knows about it.
+    pub meta: FileMeta,
+}
+
+/// What a backend can do, without any I/O. Callers read this before choosing a strategy (e.g.
+/// whether a conditional write with `If-Match` is honoured or silently ignored).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Capabilities {
+    /// An `atomic` write is all-or-nothing as seen by a later `read`.
+    pub atomic_write: bool,
+    /// [`WriteOptions::if_match`] is honoured (a stale tag fails with [`StorageError::Conflict`]).
+    pub conditional_write: bool,
+    /// [`StorageBackend::list`] is implemented (else it reports [`StorageError::Unsupported`]).
+    pub list: bool,
+    /// [`FileMeta::etag`] is populated, so optimistic concurrency is possible.
+    pub etag: bool,
+    /// Bytes survive a process restart on the backend itself (as opposed to a pure cache).
+    pub persistent: bool,
+}
+
+/// How [`StorageBackend::write`] should behave.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct WriteOptions {
+    /// Only write when the stored version still carries this tag (an `ETag` from [`stat`][`StorageBackend::stat`]);
+    /// a mismatch fails with [`StorageError::Conflict`]. `None` writes unconditionally.
+    pub if_match: Option<String>,
+    /// Stage-then-commit so a crash mid-write never leaves a half-written file visible.
+    /// Backends without atomic support return [`StorageError::Unsupported`] when this is set.
+    pub atomic: bool,
+}
+
+/// What [`StorageBackend::probe`] learned from the far end (a WebDAV `OPTIONS` round trip).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ProbeReport {
+    /// Capabilities after talking to the server (never weaker than [`StorageBackend::capabilities`]
+    /// where the probe failed; adapted where it succeeded).
+    pub capabilities: Capabilities,
+    /// Raw `DAV` header classes the server advertises (e.g. `1, 2, ordered-collections`).
+    pub dav_classes: Vec<String>,
+    /// Anything worth telling the user (fallbacks taken, features missing).
+    pub notes: Vec<String>,
+}
+
+/// What [`StorageBackend::recover`] did with the [`journal`] left by interrupted writes.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct RecoveryReport {
+    /// Journal entries completed (a staged write committed) or confirmed already committed.
+    pub completed: usize,
+    /// Journal entries dropped because neither the staged nor the final file survived.
+    pub dropped: usize,
+    /// Orphan staging files removed (no journal entry names them).
+    pub removed_temp: usize,
+}
+
+/// Everything that can go wrong talking to storage. Variants carry backend, path-free messages:
+/// callers add context (the CLI prints `path: message`).
+#[derive(Debug, Error)]
+pub enum StorageError {
+    /// Nothing stored under that name.
+    #[error("not found: {0}")]
+    NotFound(String),
+    /// A conditional write lost its race (`If-Match` mismatch), or the name is already taken.
+    #[error("conflict: {0}")]
+    Conflict(String),
+    /// The server refused the credentials, or they were missing.
+    #[error("authentication failed: {0}")]
+    Auth(String),
+    /// The backend or build cannot do this (e.g. plain-HTTP WebDAV asked for `https://`,
+    /// `atomic` on a backend without staging).
+    #[error("unsupported: {0}")]
+    Unsupported(String),
+    /// The far end or the request was malformed (bad URL, bad XML, unexpected status).
+    #[error("protocol: {0}")]
+    Protocol(String),
+    /// Local I/O or socket failure.
+    #[error("io: {0}")]
+    Io(String),
+    /// The name is invalid (empty, absolute, escapes its root via `..`).
+    #[error("bad path: {0}")]
+    BadPath(String),
+}
+
+impl From<std::io::Error> for StorageError {
+    fn from(e: std::io::Error) -> Self {
+        StorageError::Io(e.to_string())
+    }
+}
+
+/// Where document bytes live. Implementations must be usable from any thread; none of these
+/// methods is called on the typing path (only explicit save / autosave / CLI commands call in).
+pub trait StorageBackend: Send + Sync {
+    /// Read the whole file.
+    fn read(&self, path: &str) -> Result<Vec<u8>, StorageError>;
+    /// Store `bytes` under `path`, creating parent collections as needed.
+    fn write(&self, path: &str, bytes: &[u8], opts: &WriteOptions) -> Result<FileMeta, StorageError>;
+    /// Metadata for one path.
+    fn stat(&self, path: &str) -> Result<FileMeta, StorageError>;
+    /// Direct children of `prefix` (empty `prefix` lists the root). Always shallow (depth 1).
+    fn list(&self, prefix: &str) -> Result<Vec<Entry>, StorageError>;
+    /// Remove one stored file. Removing a missing file succeeds (idempotent).
+    fn remove(&self, path: &str) -> Result<(), StorageError>;
+    /// What this backend can do, without any I/O.
+    fn capabilities(&self) -> Capabilities;
+    /// Talk to the far end (if any) and refine [`capabilities`][Self::capabilities]. The default
+    /// just reports the static capabilities with no notes.
+    fn probe(&self) -> Result<ProbeReport, StorageError> {
+        Ok(ProbeReport { capabilities: self.capabilities(), dav_classes: Vec::new(), notes: Vec::new() })
+    }
+    /// Replay or clear the [`journal`] left by interrupted writes. Safe to call at every startup.
+    fn recover(&self) -> Result<RecoveryReport, StorageError> {
+        Ok(RecoveryReport::default())
+    }
+}
+
+/// Reject names that could escape a backend's root. Returns the cleaned relative path
+/// (`a//b` becomes `a/b`).
+pub(crate) fn clean_path(path: &str) -> Result<String, StorageError> {
+    if path.is_empty() {
+        return Err(StorageError::BadPath("empty path".into()));
+    }
+    if path.starts_with('/') || path.starts_with('\\') {
+        return Err(StorageError::BadPath(format!("{path}: absolute paths are not storage paths")));
+    }
+    let mut parts: Vec<&str> = Vec::new();
+    for part in path.split(['/', '\\']) {
+        match part {
+            "" | "." => {}
+            ".." => return Err(StorageError::BadPath(format!("{path}: `..` escapes the storage root"))),
+            _ if part.chars().any(|c| c.is_control()) => {
+                return Err(StorageError::BadPath(format!("{path}: control characters are not storage names")));
+            }
+            _ => parts.push(part),
+        }
+    }
+    if parts.is_empty() {
+        return Err(StorageError::BadPath(format!("{path}: names a collection, not a file")));
+    }
+    if parts.join("/").len() > 1024 {
+        return Err(StorageError::BadPath(format!("{path}: path is longer than 1024 characters")));
+    }
+    Ok(parts.join("/"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn clean_path_rejects_escapes() {
+        assert!(clean_path("").is_err());
+        assert!(clean_path("/abs").is_err());
+        assert!(clean_path("../up").is_err());
+        assert!(clean_path("a/../../b").is_err());
+        assert!(clean_path("a/./b").unwrap() == "a/b");
+    }
+}
diff --git a/crates/storage/src/local.rs b/crates/storage/src/local.rs
new file mode 100644
--- /dev/null
+++ b/crates/storage/src/local.rs
@@ -0,0 +1,396 @@
+//! The local-filesystem backend: a folder on disk, a NAS mount, or any synced folder.
+//!
+//! Writes with [`WriteOptions::atomic`][crate::WriteOptions::atomic] follow the engine's own
+//! `safe_save` shape: stage a sibling temporary file (private `0600` while it holds bytes the
+//! destination may not have had), `fsync` it, journal the pending commit, rename over the
+//! destination, then journal the commit. A crash between the lines is finished or dropped by
+//! [`recover`][crate::StorageBackend::recover]. Non-atomic writes go straight to the final name
+//! (parents created first). Conditional writes (`if_match`) compare a sidecar `.etag` file the
+//! backend maintains per document; a mismatch is [`StorageError::Conflict`].
+
+use std::fs::{self, File, OpenOptions};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use crate::journal::{self, JournalEntry, JournalState};
+use crate::{Capabilities, Entry, FileMeta, RecoveryReport, StorageBackend, StorageError, WriteOptions, clean_path};
+
+/// A folder on disk holding documents. `root` must be (or become) a directory.
+#[derive(Clone, Debug)]
+pub struct LocalBackend {
+    root: PathBuf,
+}
+
+impl LocalBackend {
+    /// Open (creating) the folder at `root`.
+    pub fn open(root: &Path) -> Result<LocalBackend, StorageError> {
+        if let Ok(meta) = fs::symlink_metadata(root)
+            && !meta.is_dir()
+        {
+            return Err(StorageError::BadPath(format!("{}: not a directory", root.display())));
+        }
+        fs::create_dir_all(root).map_err(|e| StorageError::Io(format!("{}: {e}", root.display())))?;
+        Ok(LocalBackend { root: root.to_path_buf() })
+    }
+
+    /// The folder this backend stores in.
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
+
+    fn journal_path(&self) -> PathBuf {
+        self.root.join(journal::JOURNAL_FILE)
+    }
+
+    fn append_journal(&self, entry: &JournalEntry) {
+        // The journal is best-effort crash evidence, never a reason to fail a save: a lost line
+        // only means recovery has one fewer hint. (Errors are ignored deliberately.)
+        if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(self.journal_path()) {
+            let _ = f.write_all(journal::line(entry).as_bytes());
+        }
+    }
+
+    fn read_etag(&self, dest: &Path) -> Option<String> {
+        fs::read_to_string(etag_path(dest)).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
+    }
+
+    fn write_etag(&self, dest: &Path, meta: &FileMeta) {
+        if let Some(tag) = &meta.etag
+            && let Some(parent) = dest.parent()
+        {
+            let _ = fs::create_dir_all(parent);
+            let _ = fs::write(etag_path(dest), tag.as_bytes());
+        }
+    }
+
+    fn meta_of(&self, rel: &str, dest: &Path) -> Result<FileMeta, StorageError> {
+        let meta = fs::metadata(dest).map_err(|e| {
+            if e.kind() == std::io::ErrorKind::NotFound {
+                StorageError::NotFound(rel.into())
+            } else {
+                StorageError::Io(format!("{}: {e}", dest.display()))
+            }
+        })?;
+        if !meta.is_file() {
+            return Err(StorageError::NotFound(rel.into()));
+        }
+        let modified = meta.modified().ok().map(|t| format!("{t:?}"));
+        Ok(FileMeta { size: meta.len(), etag: self.read_etag(dest), modified, is_dir: false })
+    }
+
+    /// Stage `bytes` beside `dest`, journal the pending write, rename into place, journal the
+    /// commit. Returns the staged temp path's file name (for the journal).
+    fn atomic_commit(&self, rel: &str, dest: &Path, bytes: &[u8]) -> Result<FileMeta, StorageError> {
+        static NEXT: AtomicU64 = AtomicU64::new(0);
+        if let Some(parent) = dest.parent() {
+            fs::create_dir_all(parent).map_err(|e| StorageError::Io(format!("{}: {e}", parent.display())))?;
+        }
+        let keep_mode = match fs::metadata(dest) {
+            Ok(m) if m.is_file() => Some(m.permissions()),
+            Ok(_) => return Err(StorageError::Conflict(format!("{rel}: a folder is stored there"))),
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
+            Err(e) => return Err(StorageError::Io(format!("{}: {e}", dest.display()))),
+        };
+        let fname = dest.file_name().and_then(|n| n.to_str()).unwrap_or("doc");
+        for _ in 0..128 {
+            let id = NEXT.fetch_add(1, Ordering::Relaxed);
+            let tmp = dest.with_file_name(format!(".cadcraft-save-{id}-{fname}.tmp"));
+            let mut opts = OpenOptions::new();
+            opts.write(true).create_new(true);
+            #[cfg(unix)]
+            {
+                use std::os::unix::fs::OpenOptionsExt;
+                // Stay private until the destination's own mode is applied below.
+                opts.mode(0o600);
+            }
+            let mut f = match opts.open(&tmp) {
+                Ok(f) => f,
+                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
+                Err(e) => return Err(StorageError::Io(format!("{}: {e}", tmp.display()))),
+            };
+            let staged_name = tmp.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
+            let entry = JournalEntry { path: rel.into(), staged: staged_name, state: JournalState::Pending, len: bytes.len() as u64 };
+            if f.write_all(bytes).is_err() || f.sync_all().is_err() {
+                let _ = fs::remove_file(&tmp);
+                return Err(StorageError::Io(format!("{}: could not stage the write", dest.display())));
+            }
+            if let Some(mode) = keep_mode {
+                // A replaced file keeps its mode; a new one keeps the umask default.
+                if f.set_permissions(mode).is_err() {
+                    let _ = fs::remove_file(&tmp);
+                    return Err(StorageError::Io(format!("{}: could not stage the write", dest.display())));
+                }
+            }
+            drop(f);
+            self.append_journal(&entry);
+            if let Err(e) = fs::rename(&tmp, dest) {
+                let _ = fs::remove_file(&tmp);
+                return Err(StorageError::Io(format!("{}: {e}", dest.display())));
+            }
+            self.append_journal(&JournalEntry { state: JournalState::Committed, ..entry });
+            let tag = etag_of(bytes);
+            let meta = FileMeta { size: bytes.len() as u64, etag: Some(tag), modified: None, is_dir: false };
+            self.write_etag(dest, &meta);
+            return Ok(meta);
+        }
+        Err(StorageError::Io(format!("{}: no free staging name after 128 tries", dest.display())))
+    }
+}
+
+/// Sidecar holding a file's ETag: `<name>.cc-etag` beside it (hidden from [`list`][StorageBackend::list]).
+fn etag_path(dest: &Path) -> PathBuf {
+    let name = dest.file_name().and_then(|n| n.to_str()).unwrap_or("doc");
+    dest.with_file_name(format!("{name}.cc-etag"))
+}
+
+/// Content tag: a short hex of a wrapping hash (FNV-1a, 64-bit). It only needs to change when
+/// the bytes change *through this backend*; it is not a cryptographic digest.
+fn etag_of(bytes: &[u8]) -> String {
+    let mut h: u64 = 0xcbf29ce484222325;
+    for b in bytes {
+        h ^= u64::from(*b);
+        h = h.wrapping_mul(0x100000001b3);
+    }
+    format!("{h:016x}")
+}
+
+impl StorageBackend for LocalBackend {
+    fn read(&self, path: &str) -> Result<Vec<u8>, StorageError> {
+        let rel = clean_path(path)?;
+        let dest = self.root.join(&rel);
+        fs::read(&dest).map_err(|e| {
+            if e.kind() == std::io::ErrorKind::NotFound { StorageError::NotFound(rel) } else { StorageError::Io(format!("{}: {e}", dest.display())) }
+        })
+    }
+
+    fn write(&self, path: &str, bytes: &[u8], opts: &WriteOptions) -> Result<FileMeta, StorageError> {
+        let rel = clean_path(path)?;
+        if bytes.len() as u64 > (2 << 30) {
+            return Err(StorageError::Unsupported(format!("{rel}: larger than 2 GB")));
+        }
+        let dest = self.root.join(&rel);
+        if let Some(want) = &opts.if_match {
+            match self.read_etag(&dest) {
+                Some(have) if &have == want => {}
+                _ if !dest.exists() => return Err(StorageError::Conflict(format!("{rel}: no stored version matches `{want}`"))),
+                _ => return Err(StorageError::Conflict(format!("{rel}: stored version changed (stale If-Match)"))),
+            }
+        }
+        if opts.atomic {
+            return self.atomic_commit(&rel, &dest, bytes);
+        }
+        if let Some(parent) = dest.parent() {
+            fs::create_dir_all(parent).map_err(|e| StorageError::Io(format!("{}: {e}", parent.display())))?;
+        }
+        let mut f = File::create(&dest).map_err(|e| StorageError::Io(format!("{}: {e}", dest.display())))?;
+        f.write_all(bytes).map_err(|e| StorageError::Io(format!("{}: {e}", dest.display())))?;
+        let meta = FileMeta { size: bytes.len() as u64, etag: Some(etag_of(bytes)), modified: None, is_dir: false };
+        self.write_etag(&dest, &meta);
+        Ok(meta)
+    }
+
+    fn stat(&self, path: &str) -> Result<FileMeta, StorageError> {
+        let rel = clean_path(path)?;
+        let dest = self.root.join(&rel);
+        self.meta_of(&rel, &dest)
+    }
+
+    fn list(&self, prefix: &str) -> Result<Vec<Entry>, StorageError> {
+        let dir = if prefix.is_empty() { self.root.clone() } else { self.root.join(clean_path(prefix)?) };
+        let rd = fs::read_dir(&dir).map_err(|e| {
+            if e.kind() == std::io::ErrorKind::NotFound {
+                StorageError::NotFound(prefix.into())
+            } else {
+                StorageError::Io(format!("{}: {e}", dir.display()))
+            }
+        })?;
+        let mut out = Vec::new();
+        for ent in rd {
+            let ent = ent.map_err(|e| StorageError::Io(e.to_string()))?;
+            let name = ent.file_name().to_string_lossy().into_owned();
+            if name.starts_with(".cadcraft-save-") || name.ends_with(".tmp") || name.ends_with(".cc-etag") || name == journal::JOURNAL_FILE {
+                continue;
+            }
+            let ft = ent.file_type().map_err(|e| StorageError::Io(e.to_string()))?;
+            let rel = if prefix.is_empty() { name.clone() } else { format!("{}/{}", prefix.trim_end_matches('/'), name) };
+            if ft.is_dir() {
+                out.push(Entry { path: rel, meta: FileMeta { size: 0, etag: None, modified: None, is_dir: true } });
+            } else if ft.is_file() {
+                let size = ent.metadata().map(|m| m.len()).unwrap_or(0);
+                out.push(Entry { path: rel, meta: FileMeta { size, etag: self.read_etag(&ent.path()), modified: None, is_dir: false } });
+            }
+        }
+        out.sort_by(|a, b| a.path.cmp(&b.path));
+        Ok(out)
+    }
+
+    fn remove(&self, path: &str) -> Result<(), StorageError> {
+        let rel = clean_path(path)?;
+        let dest = self.root.join(&rel);
+        match fs::remove_file(&dest) {
+            Ok(()) => {}
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+            Err(e) => return Err(StorageError::Io(format!("{}: {e}", dest.display()))),
+        }
+        let _ = fs::remove_file(etag_path(&dest));
+        Ok(())
+    }
+
+    fn capabilities(&self) -> Capabilities {
+        Capabilities { atomic_write: true, conditional_write: true, list: true, etag: true, persistent: true }
+    }
+
+    fn recover(&self) -> Result<RecoveryReport, StorageError> {
+        let mut report = RecoveryReport::default();
+        let bytes = fs::read(self.journal_path()).unwrap_or_default();
+        let plan = journal::RecoveryPlan::plan(&journal::read(&bytes));
+        for pending in &plan.complete {
+            let Ok(rel) = clean_path(&pending.path) else {
+                report.dropped += 1;
+                continue;
+            };
+            let staged = self.root.join(&pending.staged);
+            let dest = self.root.join(&rel);
+            let staged_bytes = fs::read(&staged).ok();
+            match staged_bytes {
+                Some(b) if b.len() as u64 == pending.len && dest.exists() => {
+                    // Final file survived alongside the staged one: the rename may or may not
+                    // have happened, but the document is intact either way.
+                    let _ = fs::remove_file(&staged);
+                    report.completed += 1;
+                }
+                Some(b) if b.len() as u64 == pending.len => {
+                    if fs::rename(&staged, &dest).is_ok() {
+                        let meta = FileMeta { size: b.len() as u64, etag: Some(etag_of(&b)), modified: None, is_dir: false };
+                        self.write_etag(&dest, &meta);
+                        report.completed += 1;
+                    } else {
+                        report.dropped += 1;
+                    }
+                }
+                _ => {
+                    let _ = fs::remove_file(&staged);
+                    report.dropped += 1;
+                }
+            }
+            self.append_journal(&JournalEntry { state: JournalState::Committed, ..pending.clone() });
+        }
+        report.completed += plan.done.len();
+        report.removed_temp = remove_orphan_temps(&self.root);
+        // The log has served its purpose; a fresh one starts now.
+        let _ = fs::remove_file(self.journal_path());
+        Ok(report)
+    }
+}
+
+/// Delete staging files no journal entry names. Returns how many were removed.
+fn remove_orphan_temps(root: &Path) -> usize {
+    let mut removed = 0;
+    let mut stack = vec![root.to_path_buf()];
+    let mut seen = 0;
+    while let Some(dir) = stack.pop() {
+        seen += 1;
+        if seen > 4096 {
+            break;
+        }
+        let Ok(rd) = fs::read_dir(&dir) else { continue };
+        for ent in rd.flatten() {
+            let name = ent.file_name().to_string_lossy().into_owned();
+            let Ok(ft) = ent.file_type() else { continue };
+            if ft.is_dir() {
+                stack.push(ent.path());
+            } else if ft.is_file() && name.starts_with(".cadcraft-save-") && fs::remove_file(ent.path()).is_ok() {
+                removed += 1;
+            }
+        }
+    }
+    removed
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn backend() -> (TempDir, LocalBackend) {
+        let dir = TempDir::create();
+        let b = LocalBackend::open(dir.path()).unwrap();
+        (dir, b)
+    }
+
+    /// A unique scratch folder under the system temp dir.
+    struct TempDir {
+        path: PathBuf,
+    }
+
+    impl TempDir {
+        fn path(&self) -> &Path {
+            &self.path
+        }
+    }
+
+    impl Drop for TempDir {
+        fn drop(&mut self) {
+            let _ = fs::remove_dir_all(&self.path);
+        }
+    }
+
+    impl TempDir {
+        fn create() -> TempDir {
+            static NEXT: AtomicU64 = AtomicU64::new(0);
+            let id = NEXT.fetch_add(1, Ordering::Relaxed);
+            let path = std::env::temp_dir().join(format!("cadcraft-storage-test-{}-{id}", std::process::id()));
+            fs::create_dir_all(&path).unwrap();
+            TempDir { path }
+        }
+    }
+
+    #[test]
+    fn atomic_roundtrip_and_etag_match() {
+        let (_tmp, b) = backend();
+        let meta = b.write("memo.dxf", b"PK-data", &WriteOptions { atomic: true, ..Default::default() }).unwrap();
+        assert_eq!(b.read("memo.dxf").unwrap(), b"PK-data");
+        // `stat` agrees on content identity (`modified` is time-dependent, so compared separately).
+        let st = b.stat("memo.dxf").unwrap();
+        assert_eq!((st.size, st.etag, st.is_dir), (meta.size, meta.etag.clone(), false));
+        assert!(st.modified.is_some());
+        // Stale tag loses.
+        let err = b.write("memo.dxf", b"new", &WriteOptions { if_match: Some("deadbeef".into()), ..Default::default() }).unwrap_err();
+        assert!(matches!(err, StorageError::Conflict(_)));
+        // Fresh tag wins.
+        let tag = meta.etag.unwrap();
+        b.write("memo.dxf", b"new", &WriteOptions { if_match: Some(tag), atomic: true }).unwrap();
+        assert_eq!(b.read("memo.dxf").unwrap(), b"new");
+    }
+
+    #[test]
+    fn no_half_file_visible() {
+        let (_tmp, b) = backend();
+        b.write("a/b/c.txt", b"hello", &WriteOptions { atomic: true, ..Default::default() }).unwrap();
+        let leftovers: Vec<_> =
+            fs::read_dir(b.root()).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(".cadcraft-save-")).collect();
+        assert!(leftovers.is_empty());
+        assert_eq!(b.list("a/b").unwrap().len(), 1);
+    }
+
+    #[test]
+    fn recover_finishes_interrupted_commit() {
+        let (_tmp, b) = backend();
+        // Fake a crash: staged bytes + pending line, no commit line, no final file.
+        let staged = ".cadcraft-save-0-crash.txt.tmp";
+        fs::write(b.root().join(staged), b"saved?").unwrap();
+        b.append_journal(&JournalEntry { path: "crash.txt".into(), staged: staged.into(), state: JournalState::Pending, len: 6 });
+        let report = b.recover().unwrap();
+        assert_eq!(report.completed, 1);
+        assert_eq!(b.read("crash.txt").unwrap(), b"saved?");
+    }
+
+    #[test]
+    fn remove_is_idempotent() {
+        let (_tmp, b) = backend();
+        b.remove("missing.dxf").unwrap();
+        b.write("x.dxf", b"d", &WriteOptions::default()).unwrap();
+        b.remove("x.dxf").unwrap();
+        assert!(matches!(b.stat("x.dxf").unwrap_err(), StorageError::NotFound(_)));
+    }
+}
diff --git a/crates/storage/src/webdav.rs b/crates/storage/src/webdav.rs
new file mode 100644
--- /dev/null
+++ b/crates/storage/src/webdav.rs
@@ -0,0 +1,706 @@
+//! The WebDAV backend: a Nextcloud / ownCloud (oCIS) collection over HTTP.
+//!
+//! Only the standard library is used (blocking `TcpStream` HTTP/1.1), so there is no new
+//! dependency and no I/O on any path but an explicit storage call. Supported:
+//! - Basic authentication (`Authorization: Basic ...`), as oCIS infinite-scale deployments take.
+//! - Bearer authentication (`Authorization: Bearer <token>`), e.g. an oCIS OIDC access
+//!   token — required where Basic auth is disabled (external user management);
+//!   see [`WebDavBackend::with_bearer`].
+//! - Conditional writes: `PUT` carries `If-Match: <etag>` when [`WriteOptions::if_match`] is set;
+//!   a `412` becomes [`StorageError::Conflict`]. Without an ETag the backend sends a plain `PUT`
+//!   (fallback) and reports the server's `ETag` response header, if any.
+//! - `PROPFIND` (depth 0/1) for `stat`/`list`, `GET` for `read`, `DELETE` for `remove`,
+//!   `OPTIONS` for [`probe`][crate::StorageBackend::probe].
+//!
+//! Deliberately stubbed (see [`строй`][WebDavBackend] docs and the crate report): `https://`
+//! URLs (no TLS in this build — put Caddy / a reverse proxy in front, or sync a local folder),
+//! digest/NTLM auth, locking (`LOCK`/`UNLOCK`, Phase 3 takes these), and partial writes.
+
+use std::io::{Read, Write};
+use std::net::{TcpStream, ToSocketAddrs};
+use std::time::Duration;
+
+use crate::{Capabilities, Entry, FileMeta, ProbeReport, RecoveryReport, StorageBackend, StorageError, WriteOptions, clean_path};
+use base64::Engine as _;
+
+/// Talk timeout per connection phase (connect, write, read). WebDAV runs on LANs and localhost;
+/// a hung server must not hang a save longer than this per phase.
+const TIMEOUT: Duration = Duration::from_secs(15);
+/// Largest single response body kept (a 2 GB document plus XML slack would never fit anyway;
+/// `read` refuses past this before writing to memory).
+const MAX_BODY: u64 = 2 << 30;
+
+/// A WebDAV collection. `https://` URLs are refused at construction (no TLS in this build);
+/// use `http://` on trusted networks (localhost, LAN, VPN, SSH tunnel).
+#[derive(Clone, Debug)]
+pub struct WebDavBackend {
+    host: String,
+    port: u16,
+    base: String,
+    auth: Option<String>,
+}
+
+impl WebDavBackend {
+    /// `url` like `http://host:port/remote.php/dav/files/user/`; `user`/`password` become the
+    /// Basic credentials (either both or neither).
+    pub fn new(url: &str, user: Option<&str>, password: Option<&str>) -> Result<WebDavBackend, StorageError> {
+        let (host, port, base) = split_url(url)?;
+        let auth = match (user, password) {
+            (Some(u), Some(p)) => Some(format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{u}:{p}")))),
+            (None, None) => None,
+            _ => return Err(StorageError::Auth("need both user and password, or neither".into())),
+        };
+        Ok(WebDavBackend { host, port, base, auth })
+    }
+
+    /// `url` like `http://host:port/dav/spaces/...`; `token` becomes an OIDC/Bearer credential
+    /// (`Authorization: Bearer <token>`). Use this where the server does not take Basic auth
+    /// (e.g. oCIS with external user management). Must not be combined with Basic credentials —
+    /// pick one scheme.
+    pub fn with_bearer(url: &str, token: &str) -> Result<WebDavBackend, StorageError> {
+        let (host, port, base) = split_url(url)?;
+        if token.is_empty() {
+            return Err(StorageError::Auth("bearer token must not be empty".into()));
+        }
+        Ok(WebDavBackend { host, port, base, auth: Some(format!("Bearer {token}")) })
+    }
+
+    /// The collection URL this backend talks to (password never included).
+    pub fn base_url(&self) -> String {
+        format!("http://{}:{}{}", self.host, self.port, self.base)
+    }
+
+    fn request(&self, method: &str, path: &str, extra: &[(&str, &str)], body: Option<&[u8]>) -> Result<Response, StorageError> {
+        let target = join_path(&self.base, path);
+        let addr = (self.host.as_str(), self.port)
+            .to_socket_addrs()
+            .map_err(|e| StorageError::Io(format!("{}:{}: {e}", self.host, self.port)))?
+            .next()
+            .ok_or_else(|| StorageError::Io(format!("{}:{}: no address", self.host, self.port)))?;
+        let mut sock = TcpStream::connect_timeout(&addr, TIMEOUT).map_err(|e| StorageError::Io(format!("{}:{}: {e}", self.host, self.port)))?;
+        sock.set_read_timeout(Some(TIMEOUT)).map_err(StorageError::from)?;
+        sock.set_write_timeout(Some(TIMEOUT)).map_err(StorageError::from)?;
+        let mut head = format!("{method} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n", encode_path(&target), self.host);
+        if let Some(a) = &self.auth {
+            head.push_str(&format!("Authorization: {a}\r\n"));
+        }
+        for (k, v) in extra {
+            head.push_str(&format!("{k}: {v}\r\n"));
+        }
+        let len = body.map(<[u8]>::len).unwrap_or(0);
+        head.push_str(&format!("Content-Length: {len}\r\n\r\n"));
+        sock.write_all(head.as_bytes()).map_err(StorageError::from)?;
+        if let Some(b) = body {
+            sock.write_all(b).map_err(StorageError::from)?;
+        }
+        read_response(&mut sock)
+    }
+
+    fn fail_for(&self, op: &str, path: &str, r: &Response) -> StorageError {
+        match r.status {
+            401 | 403 => StorageError::Auth(format!("{op} {path}: server refused the credentials ({})", r.status)),
+            404 => StorageError::NotFound(path.into()),
+            409 => StorageError::Conflict(format!("{op} {path}: parent collection is missing")),
+            412 => StorageError::Conflict(format!("{op} {path}: stored version changed (stale If-Match)")),
+            s => StorageError::Protocol(format!("{op} {path}: unexpected status {s}")),
+        }
+    }
+}
+
+impl StorageBackend for WebDavBackend {
+    fn read(&self, path: &str) -> Result<Vec<u8>, StorageError> {
+        let rel = clean_path(path)?;
+        let r = self.request("GET", &rel, &[], None)?;
+        if r.status == 200 {
+            return Ok(r.body);
+        }
+        Err(self.fail_for("read", &rel, &r))
+    }
+
+    fn write(&self, path: &str, bytes: &[u8], opts: &WriteOptions) -> Result<FileMeta, StorageError> {
+        let rel = clean_path(path)?;
+        if bytes.len() as u64 > MAX_BODY {
+            return Err(StorageError::Unsupported(format!("{rel}: larger than 2 GB")));
+        }
+        if opts.atomic {
+            // A single PUT either lands whole or not on a compliant server; that is the atomicity
+            // this backend offers (no staging rename exists in plain WebDAV).
+            return Err(StorageError::Unsupported(format!("{rel}: atomic staging is local-only; plain PUT is already whole-or-nothing")));
+        }
+        ensure_parents(self, &rel)?;
+        let mut extra = vec![("Content-Type", "application/octet-stream")];
+        let tag;
+        if let Some(t) = &opts.if_match {
+            tag = format!("\"{t}\"");
+            extra.push(("If-Match", &tag));
+        }
+        let r = self.request("PUT", &rel, &extra, Some(bytes))?;
+        match r.status {
+            200 | 201 | 204 => Ok(FileMeta { size: bytes.len() as u64, etag: r.etag(), modified: None, is_dir: false }),
+            _ => Err(self.fail_for("write", &rel, &r)),
+        }
+    }
+
+    fn stat(&self, path: &str) -> Result<FileMeta, StorageError> {
+        let rel = clean_path(path)?;
+        let body = propfind_body();
+        let r = self.request("PROPFIND", &rel, &[("Depth", "0"), ("Content-Type", "application/xml")], Some(body.as_bytes()))?;
+        if r.status != 207 {
+            return Err(self.fail_for("stat", &rel, &r));
+        }
+        let entries = parse_propfind(&r.text(), &self.base)?;
+        entries.into_iter().next().map(|e| e.meta).ok_or(StorageError::NotFound(rel))
+    }
+
+    fn list(&self, prefix: &str) -> Result<Vec<Entry>, StorageError> {
+        let rel = if prefix.is_empty() { String::new() } else { clean_path(prefix)? };
+        let body = propfind_body();
+        let r = self.request("PROPFIND", &rel, &[("Depth", "1"), ("Content-Type", "application/xml")], Some(body.as_bytes()))?;
+        if r.status != 207 {
+            return Err(self.fail_for("list", &rel, &r));
+        }
+        let mut entries = parse_propfind(&r.text(), &self.base)?;
+        // The server lists the collection itself first; callers want its children.
+        if !rel.is_empty() {
+            entries.retain(|e| e.path != rel);
+        } else {
+            entries.retain(|e| !e.path.is_empty());
+        }
+        Ok(entries)
+    }
+
+    fn remove(&self, path: &str) -> Result<(), StorageError> {
+        let rel = clean_path(path)?;
+        let r = self.request("DELETE", &rel, &[], None)?;
+        match r.status {
+            200 | 202 | 204 | 404 => Ok(()),
+            _ => Err(self.fail_for("remove", &rel, &r)),
+        }
+    }
+
+    fn capabilities(&self) -> Capabilities {
+        Capabilities { atomic_write: false, conditional_write: true, list: true, etag: true, persistent: true }
+    }
+
+    fn probe(&self) -> Result<ProbeReport, StorageError> {
+        let mut caps = self.capabilities();
+        match self.request("OPTIONS", "", &[], None) {
+            Ok(r) if (200..300).contains(&r.status) => {
+                let dav = r.header("dav").unwrap_or_default();
+                let dav_classes: Vec<String> = dav.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
+                let mut notes = Vec::new();
+                if dav_classes.is_empty() {
+                    caps.conditional_write = false;
+                    notes.push("server sent no DAV header; conditional writes and ETags may be ignored".into());
+                }
+                Ok(ProbeReport { capabilities: caps, dav_classes, notes })
+            }
+            Ok(r) => Err(self.fail_for("probe", "/", &r)),
+            Err(e) => Err(e),
+        }
+    }
+
+    fn recover(&self) -> Result<RecoveryReport, StorageError> {
+        // The journal lives client-side for remote backends (Phase 2 keeps it beside the local
+        // autosave copy); there is nothing server-side to replay yet.
+        Ok(RecoveryReport::default())
+    }
+}
+
+/// Create each missing parent collection (`MKCOL`), deepest-first errors ignored when the
+/// collection already exists.
+fn ensure_parents(b: &WebDavBackend, rel: &str) -> Result<(), StorageError> {
+    let mut prefix = String::new();
+    let parts: Vec<&str> = rel.split('/').collect();
+    if parts.len() < 2 {
+        return Ok(());
+    }
+    for part in &parts[..parts.len() - 1] {
+        if !prefix.is_empty() {
+            prefix.push('/');
+        }
+        prefix.push_str(part);
+        let r = b.request("MKCOL", &prefix, &[], None)?;
+        match r.status {
+            201 | 405 => {}
+            409 => return Err(StorageError::Conflict(format!("write {rel}: parent collection is missing"))),
+            401 | 403 => return Err(b.fail_for("write", rel, &r)),
+            _ => {}
+        }
+    }
+    Ok(())
+}
+
+/// Split `http://host[:port][/base]` into parts. Anything else (notably `https://`) is refused:
+/// this build speaks plain HTTP only.
+fn split_url(url: &str) -> Result<(String, u16, String), StorageError> {
+    let rest = url.strip_prefix("http://").ok_or_else(|| {
+        StorageError::Unsupported(format!(
+            "{url}: only http:// URLs are supported in this build (no TLS); serve WebDAV over a trusted network or tunnel"
+        ))
+    })?;
+    let (authority, base) = match rest.find('/') {
+        Some(i) => (&rest[..i], rest[i..].to_string()),
+        None => (rest, "/".to_string()),
+    };
+    if authority.is_empty() {
+        return Err(StorageError::BadPath(format!("{url}: no host")));
+    }
+    let (host, port) = match authority.rsplit_once(':') {
+        Some((h, p)) => {
+            let port: u16 = p.parse().map_err(|_| StorageError::BadPath(format!("{url}: bad port")))?;
+            (h.to_string(), port)
+        }
+        None => (authority.to_string(), 80),
+    };
+    if host.is_empty() {
+        return Err(StorageError::BadPath(format!("{url}: no host")));
+    }
+    Ok((host, port, if base.is_empty() { "/".to_string() } else { base }))
+}
+
+fn join_path(base: &str, rel: &str) -> String {
+    let b = base.trim_end_matches('/');
+    if rel.is_empty() { b.to_string() } else { format!("{b}/{rel}") }
+}
+
+/// Percent-encode a request target, keeping `/` separators and unreserved characters.
+fn encode_path(path: &str) -> String {
+    let mut out = String::with_capacity(path.len());
+    for b in path.bytes() {
+        if b.is_ascii_alphanumeric() || b"/-_.~".contains(&b) {
+            out.push(b as char);
+        } else {
+            out.push_str(&format!("%{b:02X}"));
+        }
+    }
+    out
+}
+
+fn propfind_body() -> String {
+    r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getetag/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>"#.into()
+}
+
+struct Response {
+    status: u16,
+    headers: Vec<(String, String)>,
+    body: Vec<u8>,
+}
+
+impl Response {
+    fn header(&self, name: &str) -> Option<String> {
+        self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone())
+    }
+
+    fn etag(&self) -> Option<String> {
+        self.header("etag").map(|t| t.trim().trim_matches('"').to_string()).filter(|t| !t.is_empty())
+    }
+
+    fn text(&self) -> String {
+        String::from_utf8_lossy(&self.body).into_owned()
+    }
+}
+
+fn read_response(sock: &mut TcpStream) -> Result<Response, StorageError> {
+    let mut raw = Vec::new();
+    let mut buf = [0u8; 8192];
+    loop {
+        match sock.read(&mut buf) {
+            Ok(0) => break,
+            Ok(n) => {
+                raw.extend_from_slice(&buf[..n]);
+                if raw.len() as u64 > MAX_BODY + 65536 {
+                    return Err(StorageError::Protocol("response is larger than 2 GB".into()));
+                }
+            }
+            Err(e) => return Err(StorageError::Io(e.to_string())),
+        }
+    }
+    let split = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| StorageError::Protocol("no HTTP header block".into()))?;
+    let head = String::from_utf8_lossy(&raw[..split]).into_owned();
+    let mut lines = head.lines();
+    let status_line = lines.next().ok_or_else(|| StorageError::Protocol("empty response".into()))?;
+    let status: u16 = status_line
+        .split_whitespace()
+        .nth(1)
+        .and_then(|s| s.parse().ok())
+        .ok_or_else(|| StorageError::Protocol(format!("bad status line: {status_line}")))?;
+    let mut headers = Vec::new();
+    for line in lines {
+        if let Some((k, v)) = line.split_once(':') {
+            headers.push((k.trim().to_string(), v.trim().to_string()));
+        }
+    }
+    let mut body = raw[split + 4..].to_vec();
+    // De-chunk when the server answers with `Transfer-Encoding: chunked` (oCIS does for PROPFIND).
+    if headers.iter().any(|(k, v)| k.eq_ignore_ascii_case("transfer-encoding") && v.to_ascii_lowercase().contains("chunked")) {
+        body = dechunk(&body)?;
+    }
+    Ok(Response { status, headers, body })
+}
+
+fn dechunk(body: &[u8]) -> Result<Vec<u8>, StorageError> {
+    let mut out = Vec::new();
+    let mut i = 0;
+    loop {
+        let eol = body[i..].windows(2).position(|w| w == b"\r\n").ok_or_else(|| StorageError::Protocol("bad chunk head".into()))?;
+        let size_text = std::str::from_utf8(&body[i..i + eol]).map_err(|_| StorageError::Protocol("bad chunk head".into()))?;
+        let size = usize::from_str_radix(size_text.trim(), 16).map_err(|_| StorageError::Protocol("bad chunk size".into()))?;
+        i += eol + 2;
+        if size == 0 {
+            break;
+        }
+        if out.len() + size > MAX_BODY as usize {
+            return Err(StorageError::Protocol("response is larger than 2 GB".into()));
+        }
+        let end = i.checked_add(size).ok_or_else(|| StorageError::Protocol("bad chunk".into()))?;
+        if end + 2 > body.len() {
+            return Err(StorageError::Protocol("truncated chunk".into()));
+        }
+        out.extend_from_slice(&body[i..end]);
+        i = end + 2;
+    }
+    Ok(out)
+}
+
+/// Minimal `207 Multi-Status` scan: for each `<response>` block take `href` plus the first
+/// `getcontentlength` / `getetag` / `getlastmodified` / `collection` markers. Namespace prefixes
+/// vary (`d:`, `D:`, none), so matching is by local name only.
+fn parse_propfind(xml: &str, base: &str) -> Result<Vec<Entry>, StorageError> {
+    let mut entries = Vec::new();
+    for block in response_blocks(xml) {
+        let Some(href) = tag_text(block, "href") else { continue };
+        let rel = href_to_rel(&href, base);
+        let is_dir = block.contains("<collection") || block.contains(":collection");
+        let size = tag_text(block, "getcontentlength").and_then(|s| s.trim().parse::<u64>().ok()).unwrap_or(0);
+        let etag = tag_text(block, "getetag").map(|s| s.trim().trim_matches('"').to_string()).filter(|s| !s.is_empty());
+        let modified = tag_text(block, "getlastmodified");
+        entries.push(Entry { path: rel, meta: FileMeta { size, etag, modified, is_dir } });
+    }
+    entries.sort_by(|a, b| a.path.cmp(&b.path));
+    Ok(entries)
+}
+
+/// The `<…response>…</…response>` blocks of a `207 Multi-Status` body, whatever namespace
+/// prefix the server uses (`d:`, `D:`, none). Nested responses never occur, so the first matching
+/// closer ends the block.
+fn response_blocks(xml: &str) -> Vec<&str> {
+    let mut out = Vec::new();
+    let mut i = 0;
+    while i < xml.len() {
+        let Some(s) = xml[i..].find('<') else { break };
+        let s = i + s;
+        let rest = &xml[s + 1..];
+        if rest.starts_with('/') || rest.starts_with('!') || rest.starts_with('?') {
+            i = s + 1;
+            continue;
+        }
+        let Some(gt) = rest.find('>') else { break };
+        let tag = rest[..gt].split_whitespace().next().unwrap_or("");
+        if tag.rsplit(':').next().unwrap_or(tag).eq_ignore_ascii_case("response") {
+            let mut c = s + 1 + gt + 1;
+            let mut end = None;
+            while let Some(p) = xml[c..].find("</") {
+                let cs = c + p;
+                let crest = &xml[cs + 2..];
+                let Some(cgt) = crest.find('>') else { break };
+                let ctag = crest[..cgt].split_whitespace().next().unwrap_or("");
+                if ctag.rsplit(':').next().unwrap_or(ctag).eq_ignore_ascii_case("response") {
+                    end = Some(cs + 2 + cgt + 1);
+                    break;
+                }
+                c = cs + 2;
+            }
+            if let Some(e) = end {
+                out.push(&xml[s..e]);
+                i = e;
+            } else {
+                break;
+            }
+        } else {
+            i = s + 1 + gt + 1;
+        }
+    }
+    out
+}
+
+/// Find `<…name>text</…name>` regardless of prefix: matches the first `<x:name` opener and reads
+/// to the matching `</`.
+fn tag_text(block: &str, name: &str) -> Option<String> {
+    let mut i = 0;
+    while let Some(start) = block[i..].find('<') {
+        let s = i + start;
+        let after = &block[s + 1..];
+        // Skip closing tags and comments.
+        if after.starts_with('/') || after.starts_with('!') || after.starts_with('?') {
+            i = s + 1;
+            continue;
+        }
+        let tag_end = after.find(['>', ' '])?;
+        let tag = &after[..tag_end];
+        let local = tag.rsplit(':').next().unwrap_or(tag);
+        let full_end = s + 1 + after.find('>')?;
+        if local.eq_ignore_ascii_case(name) {
+            let content_start = full_end + 1;
+            let mut c = content_start;
+            while let Some(pos) = block[c..].find("</") {
+                let cname_start = c + pos + 2;
+                let cname_end = block[cname_start..].find('>')?;
+                let cname = &block[cname_start..cname_start + cname_end];
+                let clocal = cname.split_whitespace().next().unwrap_or(cname);
+                let clocal = clocal.rsplit(':').next().unwrap_or(clocal);
+                if clocal.eq_ignore_ascii_case(name) {
+                    return Some(block[content_start..c + pos].to_string());
+                }
+                c += pos + 1;
+            }
+            return None;
+        }
+        i = full_end + 1;
+    }
+    None
+}
+
+/// Turn a server `href` (absolute URL or base-prefixed path) into a backend-relative path.
+fn href_to_rel(href: &str, base: &str) -> String {
+    let path = match href.find("://") {
+        Some(i) => {
+            let rest = &href[i + 3..];
+            match rest.find('/') {
+                Some(j) => &rest[j..],
+                None => "/",
+            }
+        }
+        None => href,
+    };
+    let path = percent_decode(path);
+    let base_trim = base.trim_end_matches('/');
+    let rel = path.strip_prefix(base_trim).unwrap_or(&path);
+    rel.trim_matches('/').to_string()
+}
+
+fn percent_decode(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'%'
+            && i + 2 < bytes.len()
+            && let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2]))
+        {
+            out.push(h * 16 + l);
+            i += 3;
+            continue;
+        }
+        out.push(bytes[i]);
+        i += 1;
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+fn hex(b: u8) -> Option<u8> {
+    match b {
+        b'0'..=b'9' => Some(b - b'0'),
+        b'a'..=b'f' => Some(b - b'a' + 10),
+        b'A'..=b'F' => Some(b - b'A' + 10),
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::net::TcpListener;
+    use std::sync::{Arc, Mutex};
+
+    #[test]
+    fn https_is_refused_plainly() {
+        let err = WebDavBackend::new("https://cloud.example/remote.php/dav/files/u/", None, None).unwrap_err();
+        assert!(matches!(err, StorageError::Unsupported(_)));
+    }
+
+    #[test]
+    fn url_split_defaults_port_80() {
+        let (host, port, base) = split_url("http://ocis.lan/remote.php/dav/files/alice/").unwrap();
+        assert_eq!((host.as_str(), port, base.as_str()), ("ocis.lan", 80, "/remote.php/dav/files/alice/"));
+    }
+
+    #[test]
+    fn mock_roundtrip() {
+        let list_xml = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/docs/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response><d:response><d:href>/dav/docs/memo.dxf</d:href><d:propstat><d:prop><d:getcontentlength>5</d:getcontentlength><d:getetag>"abc123"</d:getetag><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"#.to_string();
+        let stat_xml = r#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/dav/docs/memo.dxf</d:href><d:propstat><d:prop><d:getcontentlength>5</d:getcontentlength><d:getetag>"abc123"</d:getetag><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"#.to_string();
+        let put_resp = "HTTP/1.1 201 Created\r\nETag: \"abc123\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
+        let get_resp = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello".to_string();
+        let propfind_stat = format!(
+            "HTTP/1.1 207 Multi-Status\r\nContent-Type: application/xml\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{stat_xml}",
+            stat_xml.len()
+        );
+        let propfind_list = format!(
+            "HTTP/1.1 207 Multi-Status\r\nContent-Type: application/xml\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{list_xml}",
+            list_xml.len()
+        );
+        let del_resp = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let addr = listener.local_addr().unwrap();
+        let responses: Arc<Vec<(String, String)>> = Arc::new(vec![
+            ("PUT /dav/docs/memo.dxf".to_string(), put_resp),
+            ("GET /dav/docs/memo.dxf".to_string(), get_resp),
+            ("PROPFIND /dav/docs/memo.dxf".to_string(), propfind_stat),
+            ("PROPFIND /dav/docs".to_string(), propfind_list),
+            ("DELETE /dav/docs/memo.dxf".to_string(), del_resp),
+        ]);
+        let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
+        let seen2 = seen.clone();
+        std::thread::spawn(move || {
+            for stream in listener.incoming().take(5) {
+                let mut stream = stream.unwrap();
+                let mut buf = [0u8; 16384];
+                let mut req = Vec::new();
+                stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+                loop {
+                    match stream.read(&mut buf) {
+                        Ok(0) => break,
+                        Ok(n) => {
+                            req.extend_from_slice(&buf[..n]);
+                            if req.windows(4).any(|w| w == b"\r\n\r\n") {
+                                let head = String::from_utf8_lossy(&req).into_owned();
+                                let len = head
+                                    .lines()
+                                    .find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
+                                    .and_then(|l| l.split(':').nth(1))
+                                    .and_then(|v| v.trim().parse::<usize>().ok())
+                                    .unwrap_or(0);
+                                let split = req.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
+                                if req.len() >= split + len {
+                                    break;
+                                }
+                            }
+                        }
+                        Err(_) => break,
+                    }
+                }
+                let head = String::from_utf8_lossy(&req).into_owned();
+                let request_line = head.lines().next().unwrap_or("").to_string();
+                let key = request_line.split_whitespace().take(2).collect::<Vec<_>>().join(" ");
+                seen2.lock().unwrap().push(request_line.clone());
+                let body = responses
+                    .iter()
+                    .find(|(k, _)| *k == key)
+                    .map(|(_, v)| v.clone())
+                    .unwrap_or_else(|| "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string());
+                let _ = stream.write_all(body.as_bytes());
+            }
+        });
+
+        let b = WebDavBackend::new(&format!("http://{addr}/dav/docs"), Some("alice"), Some("secret")).unwrap();
+        let meta = b.write("memo.dxf", b"hello", &WriteOptions::default()).unwrap();
+        assert_eq!(meta.etag.as_deref(), Some("abc123"));
+        assert_eq!(b.read("memo.dxf").unwrap(), b"hello");
+        let st = b.stat("memo.dxf").unwrap();
+        assert_eq!(st.size, 5);
+        assert_eq!(st.etag.as_deref(), Some("abc123"));
+        let list = b.list("").unwrap();
+        assert_eq!(list.len(), 1);
+        assert_eq!(list[0].path, "memo.dxf");
+        b.remove("memo.dxf").unwrap();
+        // Basic auth went out on the PUT.
+        assert!(seen.lock().unwrap().iter().any(|l| l.starts_with("PUT")));
+    }
+
+    #[test]
+    fn bearer_token_sent_as_authorization_header() {
+        use std::sync::mpsc;
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let addr = listener.local_addr().unwrap();
+        let (tx, rx) = mpsc::channel::<String>();
+        std::thread::spawn(move || {
+            let (mut stream, _) = listener.accept().unwrap();
+            let mut buf = [0u8; 8192];
+            let mut req = Vec::new();
+            stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+            loop {
+                match stream.read(&mut buf) {
+                    Ok(0) => break,
+                    Ok(n) => {
+                        req.extend_from_slice(&buf[..n]);
+                        if req.windows(4).any(|w| w == b"\r\n\r\n") {
+                            break;
+                        }
+                    }
+                    Err(_) => break,
+                }
+            }
+            let head = String::from_utf8_lossy(&req).into_owned();
+            let auth = head.lines().find(|l| l.to_ascii_lowercase().starts_with("authorization:")).unwrap_or("").to_string();
+            let _ = tx.send(auth);
+            let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nDAV: 1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+        });
+        let b = WebDavBackend::with_bearer(&format!("http://{addr}/dav/"), "test-token-123").unwrap();
+        b.probe().unwrap();
+        assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), "Authorization: Bearer test-token-123");
+    }
+
+    #[test]
+    fn bearer_rejects_empty_token() {
+        let err = WebDavBackend::with_bearer("http://127.0.0.1/dav/", "").unwrap_err();
+        assert!(matches!(err, StorageError::Auth(_)));
+    }
+
+    /// Read one full HTTP request (headers plus any `Content-Length` body) from a mock
+    /// connection. Answering earlier risks a RST racing the client's request bytes.
+    fn read_request(stream: &mut TcpStream) {
+        let mut buf = [0u8; 8192];
+        let mut req = Vec::new();
+        stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+        loop {
+            match stream.read(&mut buf) {
+                Ok(0) => break,
+                Ok(n) => {
+                    req.extend_from_slice(&buf[..n]);
+                    if let Some(split) = req.windows(4).position(|w| w == b"\r\n\r\n") {
+                        let head = String::from_utf8_lossy(&req[..split]).into_owned();
+                        let len = head
+                            .lines()
+                            .find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
+                            .and_then(|l| l.split(':').nth(1))
+                            .and_then(|v| v.trim().parse::<usize>().ok())
+                            .unwrap_or(0);
+                        if req.len() >= split + 4 + len {
+                            break;
+                        }
+                    }
+                }
+                Err(_) => break,
+            }
+        }
+    }
+
+    #[test]
+    fn if_match_conflict_surfaces() {
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let addr = listener.local_addr().unwrap();
+        std::thread::spawn(move || {
+            let (mut stream, _) = listener.accept().unwrap();
+            read_request(&mut stream);
+            let _ = stream.write_all(b"HTTP/1.1 412 Precondition Failed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+        });
+        let b = WebDavBackend::new(&format!("http://{addr}/dav/"), None, None).unwrap();
+        let err = b.write("f.dxf", b"x", &WriteOptions { if_match: Some("stale".into()), ..Default::default() }).unwrap_err();
+        assert!(matches!(err, StorageError::Conflict(_)));
+    }
+
+    #[test]
+    fn probe_reports_dav_classes() {
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let addr = listener.local_addr().unwrap();
+        std::thread::spawn(move || {
+            let (mut stream, _) = listener.accept().unwrap();
+            read_request(&mut stream);
+            let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nDAV: 1, 2\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+        });
+        let b = WebDavBackend::new(&format!("http://{addr}/dav/"), None, None).unwrap();
+        let report = b.probe().unwrap();
+        assert_eq!(report.dav_classes, vec!["1".to_string(), "2".to_string()]);
+        assert!(report.capabilities.conditional_write);
+    }
+}
diff --git a/crates/storage/tests/local_roundtrip.rs b/crates/storage/tests/local_roundtrip.rs
new file mode 100644
--- /dev/null
+++ b/crates/storage/tests/local_roundtrip.rs
@@ -0,0 +1,80 @@
+//! End-to-end storage test through the public trait only: write a document's bytes atomically,
+//! read them back, exercise conditional writes, listing, removal, and crash recovery.
+
+use std::fs;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use cadcraft_storage::local::LocalBackend;
+use cadcraft_storage::{StorageBackend, StorageError, WriteOptions};
+
+struct Scratch {
+    path: PathBuf,
+}
+
+impl Scratch {
+    fn create() -> Scratch {
+        static NEXT: AtomicU64 = AtomicU64::new(0);
+        let id = NEXT.fetch_add(1, Ordering::Relaxed);
+        let path = std::env::temp_dir().join(format!("cadcraft-storage-it-{}-{id}", std::process::id()));
+        fs::create_dir_all(&path).unwrap();
+        Scratch { path }
+    }
+
+    fn backend(&self) -> LocalBackend {
+        LocalBackend::open(&self.path).unwrap()
+    }
+}
+
+impl Drop for Scratch {
+    fn drop(&mut self) {
+        let _ = fs::remove_dir_all(&self.path);
+    }
+}
+
+#[test]
+fn dxf_bytes_survive_an_atomic_save_and_reopen() {
+    let scratch = Scratch::create();
+    let store = scratch.backend();
+    // Minimal DXF stand-in: storage never parses, only carries.
+    let bytes = b"0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nEOF\n".to_vec();
+    let meta = store.write("reports/memo.dxf", &bytes, &WriteOptions { atomic: true, ..Default::default() }).unwrap();
+    assert_eq!(store.read("reports/memo.dxf").unwrap(), bytes);
+
+    // Reopening through a second handle sees the same version (shared root, ETag sidecar).
+    let store2 = scratch.backend();
+    let st2 = store2.stat("reports/memo.dxf").unwrap();
+    assert_eq!((st2.size, st2.etag), (meta.size, meta.etag.clone()));
+
+    // Optimistic concurrency: a writer holding the old tag wins once, then loses.
+    let tag = meta.etag.unwrap();
+    store2.write("reports/memo.dxf", b"v2-dxf", &WriteOptions { if_match: Some(tag.clone()), atomic: true }).unwrap();
+    let stale = store.write("reports/memo.dxf", b"stale-dxf", &WriteOptions { if_match: Some(tag), atomic: true });
+    assert!(matches!(stale, Err(StorageError::Conflict(_))));
+    assert_eq!(store.read("reports/memo.dxf").unwrap(), b"v2-dxf");
+}
+
+#[test]
+fn startup_recover_leaves_a_clean_store() {
+    let scratch = Scratch::create();
+    let store = scratch.backend();
+    store.write("a.dxf", b"one", &WriteOptions { atomic: true, ..Default::default() }).unwrap();
+    // An orphan staging file from a killed save is swept on the next startup.
+    fs::write(scratch.path.join(".cadcraft-save-9-a.dxf.tmp"), b"half").unwrap();
+    let report = store.recover().unwrap();
+    assert_eq!((report.completed, report.dropped), (1, 0));
+    assert_eq!(report.removed_temp, 1);
+    assert_eq!(store.read("a.dxf").unwrap(), b"one");
+    let names: Vec<String> = store.list("").unwrap().into_iter().map(|e| e.path).collect();
+    assert_eq!(names, vec!["a.dxf".to_string()]);
+}
+
+#[test]
+fn paths_cannot_escape_the_root() {
+    let scratch = Scratch::create();
+    let store = scratch.backend();
+    for bad in ["", "/abs.dxf", "../up.dxf", "a/../../b.dxf"] {
+        assert!(store.write(bad, b"x", &WriteOptions::default()).is_err(), "{bad}");
+        assert!(store.read(bad).is_err(), "{bad}");
+    }
+}
diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs
--- a/xtask/src/layers.rs
+++ b/xtask/src/layers.rs
@@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[
     ("constraints", Class::Layer(2)),
     ("fonts", Class::Layer(2)),
     ("render", Class::Layer(2)),
+    ("storage", Class::Layer(1)),
     ("io", Class::Layer(3)),
     ("engine", Class::Layer(4)),
     ("ui-egui", Class::Layer(5)),
