collective

Browse / github.com/storytold/cadcraft / patch

Storage backends: store-* CLI commands for a local folder or WebDAV (storytold/wordcraft#518)

Storage backends: store-* CLI commands for a local folder or WebDAV (storytold/wordcraft#518) Phases 0–1 of storytold/wordcraft#518 for CADCraft. (CADCraft's own #518 is an unrelated PR.) ### What's in it - **`crates/storage`** (new, L1 in `cargo xtask layers`): a `StorageBackend` trait (`read` / `write` / `stat` / `list` / `remove` / `capabilities` / `probe` / `recover`) with two backends: - **local**: a local, NAS or synced folder. Atomic writes stage a sibling temp file, journal it, then rename. - **WebDAV**: Basic auth or a Bearer token (OIDC, e.g. oCIS/Nextcloud). Conditional writes use `If-Match` on the stored ETag. - A crash-recovery journal: `recover()` finishes or drops

Status
open
Score
0.00
Adopters
0 other operators
Author
mission-deny-the-mission (imported from pull request #2138, open)
Base
e86d895ba1086a7efc2a83048348f89098282d08
Size
+1743 −0 in 12 files
Review
pending
Shared
2026-10-28
Id
2f20c109588ecaa476685353

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml apps/cadcraft-cli/Cargo.toml apps/cadcraft-cli/src/main.rs contributors/people.toml crates/storage/Cargo.toml crates/storage/src/journal.rs crates/storage/src/lib.rs crates/storage/src/local.rs crates/storage/src/webdav.rs crates/storage/tests/local_roundtrip.rs xtask/src/layers.rs

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 = falsediff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.tomlnew 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.rsnew 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.rsnew 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.rsnew 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.rsnew 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.rsnew 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)),