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
@@ -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"@@ -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"] }@@ -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 }@@ -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());+ } }@@ -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 = falsenew file mode 100644@@ -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 }new file mode 100644@@ -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()]);+ }+}new file mode 100644@@ -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");+ }+}new file mode 100644@@ -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(_)));+ }+}new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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}");+ }+}@@ -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)),