Skip to main content

limnifs_write/
rw.rs

1//! Read-write image API — LimniFS's key differentiator.
2//!
3//! Unlike SquashFS/DwarFS (read-only), LimniFS images support
4//! incremental updates: add, modify, and delete files without
5//! rebuilding the entire image.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! RwImage
11//!   ├── manifest_path: where the .lim lives
12//!   ├── config: WriteConfig with write_codec + turnover defaults
13//!   ├── state: parsed metadata blob + slab store (populated on open)
14//!   ├── inode_map: path → inode map
15//!   ├── pending_files: path → plaintext for staged writes
16//!   ├── pending_history: operation log (add/update/delete)
17//!   └── next_inode: allocator
18//! ```
19//!
20//! ## Lifecycle
21//!
22//! 1. **Open** (`RwImage::open`): parse manifest, mmap slabs, build
23//!    path index. Pending changes start empty.
24//! 2. **Mutate**: `add_file` / `update_file` / `delete_file` stage
25//!    changes. Files are kept as plaintext in memory.
26//! 3. **Commit**: materialize the live tree (if opened) into a
27//!    workspace `.scratch/` directory, overlay pending changes, and
28//!    rebuild the image with the configured codecs.
29//! 4. **Turnover**: re-build the current live tree with the
30//!    configured (turnover) codecs, discarding pending changes. This
31//!    is the hygiene operation that reclaims unreferenced drops.
32
33#![allow(warnings)]
34
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37
38use limnifs_core::codec;
39use limnifs_core::slab_store::SlabStore;
40use limnifs_core::{ContentHandle, ManifestCursor, MetadataBlob};
41
42use crate::sidecar_name;
43
44use crate::config::{ImageMode, WriteConfig};
45use crate::WriteError;
46
47/// An open read-write LimniFS image.
48pub struct RwImage {
49    manifest_path: PathBuf,
50    config: WriteConfig,
51    state: Option<OpenState>,
52    /// Path → inode number map for fast lookups. Keys are absolute
53    /// POSIX-style paths (with leading `/`).
54    inode_map: HashMap<String, u64>,
55    /// Files staged for write/update. Keys mirror `inode_map`.
56    pending_files: HashMap<String, Vec<u8>>,
57    /// Pending operations since open.
58    pending_history: Vec<HistoryEntry>,
59    /// Next available inode number.
60    next_inode: u64,
61    /// BLAKE3 of the on-disk manifest bytes this handle's WAL entries
62    /// are based on. A WAL whose tag no longer matches the manifest
63    /// is stale (its commit completed) and is discarded instead of
64    /// replayed — replaying it would resurrect a pending inode number
65    /// that the committed blob never had (torn reads for concurrent
66    /// openers in the swap→WAL-discard window).
67    base_manifest_hash: Option<[u8; 32]>,
68}
69
70/// State populated by `RwImage::open` so subsequent `commit` /
71/// `turnover` calls can read the live tree without re-parsing.
72struct OpenState {
73    blob: MetadataBlob,
74    root_inode: u64,
75    slab_store: Option<SlabStore>,
76}
77
78/// A history operation recorded for incremental updates.
79#[derive(Clone, Debug)]
80pub enum HistoryEntry {
81    Add {
82        path: String,
83        inode: u64,
84        size: u64,
85    },
86    Update {
87        path: String,
88        old_inode: u64,
89        new_inode: u64,
90        size: u64,
91    },
92    Delete {
93        path: String,
94        inode: u64,
95    },
96}
97
98impl RwImage {
99    /// Open an existing image for read-write access. Parses the
100    /// manifest, mmaps the slabs, and builds the path index.
101    ///
102    /// # Errors
103    /// Returns [`WriteError`] if the manifest cannot be parsed or
104    /// the slab files cannot be opened.
105    pub fn open(path: &Path, config: WriteConfig) -> Result<Self, WriteError> {
106        // Crash recovery: if a previous commit was interrupted
107        // mid-swap, `<path>.new/` may still exist. The previous
108        // manifest at `path` is intact (atomic swap was incomplete);
109        // the `.new/` directory is garbage. Clean it up before
110        // proceeding so the next commit's `write_artifact` doesn't
111        // trip over a stale directory.
112        cleanup_stale_swap_dir(path);
113
114        let manifest_bytes = std::fs::read(path).map_err(WriteError::Io)?;
115
116        let mut cursor = ManifestCursor::new(&manifest_bytes);
117        let _ = limnifs_core::parse_manifest_header(&mut cursor).map_err(core_to_io)?;
118        let _ = limnifs_core::parse_feature_flags_section(&mut cursor).map_err(core_to_io)?;
119        let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).map_err(core_to_io)?;
120
121        let blob_bytes: Vec<u8> = if let Some(inline) = meta_ref.inline_metadata.as_ref() {
122            inline.clone()
123        } else {
124            let entry = meta_ref.locators.first().ok_or_else(|| {
125                WriteError::Io(std::io::Error::other(
126                    "metadata_reference has neither inline data nor locators",
127                ))
128            })?;
129            let name = sidecar_name(&entry.uri)?;
130            let sidecar = path.parent().unwrap_or_else(|| Path::new(".")).join(name);
131            let wire_bytes = std::fs::read(&sidecar).map_err(WriteError::Io)?;
132            if meta_ref.codec == 0 {
133                wire_bytes
134            } else {
135                codec::decompress(meta_ref.codec, &wire_bytes, meta_ref.uncompressed_len)
136                    .map_err(core_to_io)?
137            }
138        };
139
140        let mut blob_cursor = ManifestCursor::new(&blob_bytes);
141        let blob = limnifs_core::parse_metadata_blob(&mut blob_cursor).map_err(core_to_io)?;
142
143        let slab_index = limnifs_core::parse_slab_index(&mut cursor).map_err(core_to_io)?;
144        let slab_store = if slab_index.is_empty() {
145            None
146        } else {
147            Some(SlabStore::load_mmap(path, &slab_index).map_err(core_to_io)?)
148        };
149
150        let root_inode = blob.root_inode_number().ok_or_else(|| {
151            WriteError::Io(std::io::Error::other(
152                "metadata blob: could not identify a unique root directory inode",
153            ))
154        })?;
155
156        let path_index = blob.build_path_index();
157        let next_inode = blob.inodes.iter().map(|i| i.number).max().unwrap_or(0) + 1;
158
159        let mut image = Self {
160            manifest_path: path.to_path_buf(),
161            base_manifest_hash: Some(limnifs_core::hash_section(&manifest_bytes)),
162            config,
163            state: Some(OpenState {
164                blob,
165                root_inode,
166                slab_store,
167            }),
168            inode_map: path_index,
169            pending_files: HashMap::new(),
170            pending_history: Vec::new(),
171            next_inode,
172        };
173        // Replay WAL if present (crash recovery for pending state).
174        let _ = image.replay_wal_if_present();
175        Ok(image)
176    }
177
178    /// Create a new empty RW image. The first `commit` produces the
179    /// on-disk manifest + slabs.
180    #[must_use]
181    pub fn create_new(path: &Path, config: WriteConfig) -> Self {
182        Self {
183            manifest_path: path.to_path_buf(),
184            config,
185            state: None,
186            inode_map: HashMap::new(),
187            pending_files: HashMap::new(),
188            pending_history: Vec::new(),
189            next_inode: 1,
190            base_manifest_hash: None,
191        }
192    }
193
194    /// Add a new file to the image. The plaintext is staged for the
195    /// next `commit`; no I/O happens until then.
196    ///
197    /// # Errors
198    /// Returns [`WriteError`] only if internal allocation fails.
199    pub fn add_file(&mut self, path: &str, data: &[u8]) -> Result<u64, WriteError> {
200        let inode = self.next_inode;
201        self.next_inode += 1;
202        let key = normalize_path(path);
203        self.pending_files.insert(key.clone(), data.to_vec());
204        self.inode_map.insert(key.clone(), inode);
205        self.pending_history.push(HistoryEntry::Add {
206            path: key,
207            inode,
208            size: data.len() as u64,
209        });
210        Ok(inode)
211    }
212
213    /// Update an existing file. The old inode is marked superseded;
214    /// old drops remain in slabs until the next turnover.
215    ///
216    /// # Errors
217    /// Returns [`WriteError`] if the path doesn't exist.
218    pub fn update_file(&mut self, path: &str, data: &[u8]) -> Result<(), WriteError> {
219        let key = normalize_path(path);
220        let old_inode = *self.inode_map.get(&key).ok_or_else(|| {
221            WriteError::Io(std::io::Error::other(format!("path not found: {path}")))
222        })?;
223        let new_inode = self.next_inode;
224        self.next_inode += 1;
225        self.pending_files.insert(key.clone(), data.to_vec());
226        self.inode_map.insert(key.clone(), new_inode);
227        self.pending_history.push(HistoryEntry::Update {
228            path: key,
229            old_inode,
230            new_inode,
231            size: data.len() as u64,
232        });
233        Ok(())
234    }
235
236    /// Delete a file. The inode is removed from the path index; old
237    /// drops remain until the next turnover.
238    ///
239    /// # Errors
240    /// Returns [`WriteError`] if the path doesn't exist.
241    pub fn delete_file(&mut self, path: &str) -> Result<(), WriteError> {
242        let key = normalize_path(path);
243        let inode = self.inode_map.remove(&key).ok_or_else(|| {
244            WriteError::Io(std::io::Error::other(format!("path not found: {path}")))
245        })?;
246        self.pending_files.remove(&key);
247        self.pending_history
248            .push(HistoryEntry::Delete { path: key, inode });
249        Ok(())
250    }
251
252    /// Read a file's plaintext from the in-memory state. Only
253    /// available for images that have been `open`ed.
254    ///
255    /// # Errors
256    /// Returns [`WriteError`] if the image was not opened, the path
257    /// is unknown, or the slab is missing/corrupt.
258    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, WriteError> {
259        let state = self.state.as_ref().ok_or_else(|| {
260            WriteError::Io(std::io::Error::other("read_file: image was not opened"))
261        })?;
262        let key = normalize_path(path);
263        // Pending content wins: after a WAL replay (crash recovery, or
264        // a reader that opened while a commit was in flight) the
265        // inode map carries pending inode numbers the committed blob
266        // does not have — their bytes live in `pending_files`.
267        if let Some(data) = self.pending_files.get(&key) {
268            return Ok(data.clone());
269        }
270        let inode_num = *self.inode_map.get(&key).ok_or_else(|| {
271            WriteError::Io(std::io::Error::other(format!("path not found: {path}")))
272        })?;
273        let inode = state.blob.inode_by_number(inode_num).ok_or_else(|| {
274            WriteError::Io(std::io::Error::other(format!("inode {inode_num} missing")))
275        })?;
276        match &inode.content_handle {
277            ContentHandle::InlineData(data) => Ok(data.clone()),
278            ContentHandle::SliceMap(slices) => {
279                let store = state.slab_store.as_ref().ok_or_else(|| {
280                    WriteError::Io(std::io::Error::other(
281                        "read_file: slice-backed file but no slab store",
282                    ))
283                })?;
284                let mut out = Vec::new();
285                for slice in slices {
286                    let plaintext = store
287                        .plaintext_for(slice.drop_id.as_bytes())
288                        .ok_or_else(|| {
289                            WriteError::Io(std::io::Error::other("drop not in any slab"))
290                        })?
291                        .map_err(core_to_io)?;
292                    out.extend_from_slice(&plaintext);
293                }
294                Ok(out)
295            }
296            _ => Err(WriteError::Io(std::io::Error::other(
297                "read_file: unsupported content handle",
298            ))),
299        }
300    }
301
302    /// Number of pending (uncommitted) changes.
303    #[must_use]
304    pub fn pending_changes(&self) -> usize {
305        self.pending_history.len()
306    }
307
308    /// Check if auto-turnover should trigger based on the config's
309    /// `turnover_threshold`.
310    #[must_use]
311    pub fn needs_turnover(&self) -> bool {
312        self.config.turnover_threshold > 0
313            && self.pending_history.len() >= self.config.turnover_threshold as usize
314    }
315
316    /// Get the image mode (RO vs RW sub-mode).
317    #[must_use]
318    pub fn mode(&self) -> &ImageMode {
319        &self.config.mode
320    }
321
322    /// Commit pending changes. Materializes the live tree (if any),
323    /// overlays pending writes, rebuilds the image with the
324    /// configured codecs, and writes the new manifest + slabs.
325    ///
326    /// **Crash safety**: writes the WAL with planned operations
327    /// *before* the manifest swap. If the swap is interrupted, the
328    /// WAL survives and is replayed on the next `open`, restoring
329    /// the user's pending writes. On successful swap, the WAL is
330    /// unlinked.
331    ///
332    /// # Errors
333    /// Returns [`WriteError`] on I/O or serialization failure.
334    pub fn commit(&mut self) -> Result<crate::WriteArtifact, WriteError> {
335        // Write the WAL first so a crash mid-swap preserves pending state.
336        self.write_wal()?;
337        let staging = self.staging_dir();
338        self.write_staging_tree(&staging)?;
339        let artifact = crate::write_directory_with_config(&staging, &self.config)?;
340        let _ = std::fs::remove_dir_all(&staging);
341        self.write_artifact(&artifact)?;
342        // Successful swap — discard the WAL and re-tag this handle to
343        // the new manifest so a subsequent update's WAL carries the
344        // right base generation.
345        let _ = std::fs::remove_file(self.wal_path());
346        self.base_manifest_hash = Some(limnifs_core::hash_section(&artifact.bytes));
347        Ok(artifact)
348    }
349
350    /// Turnover: rebuild the current live tree with the configured
351    /// codecs. Pending changes are dropped — this is a hygiene
352    /// operation, not a commit.
353    ///
354    /// # Errors
355    /// Returns [`WriteError`] on I/O or serialization failure.
356    pub fn turnover(&self) -> Result<crate::WriteArtifact, WriteError> {
357        let staging = self.staging_dir();
358        if let Some(state) = &self.state {
359            self.write_live_tree_only(state, &staging)?;
360        } else {
361            let _ = std::fs::remove_dir_all(&staging);
362            std::fs::create_dir_all(&staging).map_err(WriteError::Io)?;
363            self.write_pending_only(&staging)?;
364        }
365        let artifact = crate::write_directory_with_config(&staging, &self.config)?;
366        let _ = std::fs::remove_dir_all(&staging);
367        self.write_artifact(&artifact)?;
368        Ok(artifact)
369    }
370
371    /// Build the staging tree: live tree (if opened) + pending
372    /// changes (adds/updates overwriting live entries, deletes
373    /// removing them).
374    fn write_staging_tree(&self, staging: &Path) -> Result<(), WriteError> {
375        let _ = std::fs::remove_dir_all(staging);
376        std::fs::create_dir_all(staging).map_err(WriteError::Io)?;
377
378        if let Some(state) = &self.state {
379            self.write_live_tree(state, staging)?;
380        }
381
382        // Apply deletes for paths not covered by a subsequent
383        // pending write.
384        for entry in &self.pending_history {
385            if let HistoryEntry::Delete { path, .. } = entry {
386                if !self.pending_files.contains_key(path) {
387                    let _ = std::fs::remove_file(staging.join(staging_relative(path)));
388                }
389            }
390        }
391
392        // Overlay pending writes.
393        for (path, data) in &self.pending_files {
394            let file_path = staging.join(staging_relative(path));
395            if let Some(parent) = file_path.parent() {
396                std::fs::create_dir_all(parent).map_err(WriteError::Io)?;
397            }
398            std::fs::write(&file_path, data).map_err(WriteError::Io)?;
399        }
400        Ok(())
401    }
402
403    fn write_pending_only(&self, staging: &Path) -> Result<(), WriteError> {
404        for (path, data) in &self.pending_files {
405            let file_path = staging.join(staging_relative(path));
406            if let Some(parent) = file_path.parent() {
407                std::fs::create_dir_all(parent).map_err(WriteError::Io)?;
408            }
409            std::fs::write(&file_path, data).map_err(WriteError::Io)?;
410        }
411        Ok(())
412    }
413
414    /// Turnover helper: write the live tree verbatim (no pending
415    /// overlays) to `staging`.
416    fn write_live_tree_only(&self, state: &OpenState, staging: &Path) -> Result<(), WriteError> {
417        let _ = std::fs::remove_dir_all(staging);
418        std::fs::create_dir_all(staging).map_err(WriteError::Io)?;
419        self.write_live_tree(state, staging)?;
420        Ok(())
421    }
422
423    /// Recursively walk the live tree and write each entry under
424    /// `staging`. Delegates to the shared
425    /// [`limnifs_core::live_tree::walk_live_tree`] with a
426    /// [`FilesystemSink`].
427    fn write_live_tree(&self, state: &OpenState, staging: &Path) -> Result<(), WriteError> {
428        let slab_ref: Option<&dyn limnifs_core::slab_source::SlabSource> = state
429            .slab_store
430            .as_ref()
431            .map(|s| s as &dyn limnifs_core::slab_source::SlabSource);
432        let mut sink = limnifs_core::live_tree::FilesystemSink::new(staging, slab_ref);
433        limnifs_core::live_tree::walk_live_tree(&state.blob, state.root_inode, &mut sink)
434            .map_err(core_to_io)
435    }
436
437    /// Persist the produced manifest + slabs to disk, replacing the
438    /// previous files at the same paths.
439    /// Persist the produced manifest + slabs to disk atomically.
440    ///
441    /// Files are written to `<manifest_path>.new/` then renamed into
442    /// place. `rename(2)` is atomic for a single file on POSIX
443    /// filesystems (APFS, ext4, btrfs, xfs); ordering the renames
444    /// sidecar → slabs → manifest means a reader opening the manifest
445    /// always sees a consistent snapshot (referenced slabs already
446    /// exist).
447    ///
448    /// A crash mid-sequence leaves `<manifest_path>.new/` on disk;
449    /// the next `RwImage::open` could detect and clean it up (TODO:
450    /// `06-rw-crash-safety.md`).
451    fn write_artifact(&self, artifact: &crate::WriteArtifact) -> Result<(), WriteError> {
452        let parent = self
453            .manifest_path
454            .parent()
455            .unwrap_or_else(|| Path::new("."))
456            .to_path_buf();
457        // Unique staging name: a concurrent RwImage::open must never
458        // delete a LIVE writer's staging (that was a real race —
459        // readers ran cleanup_stale_swap_dir on `<name>.new` while a
460        // commit was mid-flight). Age-based stale cleanup in open()
461        // only touches directories older than STALE_SWAP_AGE.
462        let staging = parent.join(format!(
463            "{}.new-{}-{}",
464            self.manifest_path
465                .file_name()
466                .and_then(std::ffi::OsStr::to_str)
467                .unwrap_or("image.lim"),
468            std::process::id(),
469            std::time::SystemTime::now()
470                .duration_since(std::time::UNIX_EPOCH)
471                .map(|d| d.as_nanos())
472                .unwrap_or(0),
473        ));
474        let _ = std::fs::remove_dir_all(&staging);
475        std::fs::create_dir_all(&staging).map_err(WriteError::Io)?;
476
477        // Write all files into staging first.
478        let manifest_name = self
479            .manifest_path
480            .file_name()
481            .map(std::ffi::OsString::from)
482            .unwrap_or_else(|| std::ffi::OsString::from("image.lim"));
483        let manifest_staging = staging.join(&manifest_name);
484        std::fs::write(&manifest_staging, &artifact.bytes).map_err(WriteError::Io)?;
485
486        let mut slab_names: Vec<std::ffi::OsString> = Vec::new();
487        for slab in &artifact.slabs {
488            // Locators are content-derived in the writer — a commit
489            // never overwrites a slab file a live manifest references.
490            let name = sidecar_name(&slab.locator)?;
491            let os_name = std::ffi::OsString::from(name);
492            std::fs::write(staging.join(&os_name), &slab.bytes).map_err(WriteError::Io)?;
493            slab_names.push(os_name);
494        }
495        let sidecar_name: Option<std::ffi::OsString> =
496            if let Some(sidecar) = &artifact.metadata_sidecar {
497                let name = sidecar_name(&sidecar.locator)?;
498                let os_name = std::ffi::OsString::from(name);
499                std::fs::write(staging.join(&os_name), &sidecar.bytes).map_err(WriteError::Io)?;
500                Some(os_name)
501            } else {
502                None
503            };
504
505        // Rename into place: sidecar → slabs → manifest. The manifest
506        // is last so a reader never sees a manifest that references
507        // missing slabs.
508        if let Some(name) = &sidecar_name {
509            rename_or_fallback(staging.join(name), parent.join(name))?;
510        }
511        for name in &slab_names {
512            rename_or_fallback(staging.join(name), parent.join(name))?;
513        }
514        rename_or_fallback(manifest_staging, self.manifest_path.clone())?;
515
516        // Cleanup staging directory (now empty).
517        let _ = std::fs::remove_dir_all(&staging);
518        Ok(())
519    }
520
521    /// Pick a workspace-local scratch directory for staging. Walks up
522    /// from the manifest path to find a `Cargo.toml` with
523    /// `[workspace]`; falls back to the manifest's parent directory.
524    fn staging_dir(&self) -> PathBuf {
525        let nonce = format!("{}-{}", std::process::id(), self.next_inode);
526        let mut cur = self
527            .manifest_path
528            .parent()
529            .unwrap_or_else(|| Path::new("."))
530            .to_path_buf();
531        loop {
532            if cur.join("Cargo.toml").is_file() {
533                if std::fs::read_to_string(cur.join("Cargo.toml"))
534                    .map(|s| s.contains("[workspace]"))
535                    .unwrap_or(false)
536                {
537                    return cur.join(".scratch").join(format!("limnifs-rw-{nonce}"));
538                }
539            }
540            if !cur.pop() {
541                break;
542            }
543        }
544        self.manifest_path
545            .parent()
546            .unwrap_or_else(|| Path::new("."))
547            .join(".scratch")
548            .join(format!("limnifs-rw-{nonce}"))
549    }
550
551    /// Path to the write-ahead log: `<manifest_path>.wal`.
552    fn wal_path(&self) -> PathBuf {
553        let parent = self
554            .manifest_path
555            .parent()
556            .unwrap_or_else(|| Path::new("."));
557        let mut name = self
558            .manifest_path
559            .file_name()
560            .map(std::ffi::OsString::from)
561            .unwrap_or_else(|| std::ffi::OsString::from("image.lim"));
562        name.push(".wal");
563        parent.join(name)
564    }
565
566    /// Write the WAL atomically. Records every pending op so a crash
567    /// mid-swap can be recovered on next `open`.
568    fn write_wal(&self) -> Result<(), WriteError> {
569        let mut buf: Vec<u8> = Vec::new();
570        // Header: magic + base-manifest tag.
571        buf.extend_from_slice(b"LIMWAL\0\0");
572        buf.extend_from_slice(&self.base_manifest_hash.unwrap_or([0u8; 32]));
573        // pending_files.
574        buf.extend_from_slice(&(self.pending_files.len() as u32).to_le_bytes());
575        for (path, data) in &self.pending_files {
576            write_path_str(&mut buf, path);
577            buf.extend_from_slice(&(data.len() as u64).to_le_bytes());
578            buf.extend_from_slice(data);
579        }
580        // pending_history.
581        buf.extend_from_slice(&(self.pending_history.len() as u32).to_le_bytes());
582        for entry in &self.pending_history {
583            match entry {
584                HistoryEntry::Add { path, .. } => {
585                    buf.push(1);
586                    write_path_str(&mut buf, path);
587                }
588                HistoryEntry::Update { path, .. } => {
589                    buf.push(2);
590                    write_path_str(&mut buf, path);
591                }
592                HistoryEntry::Delete { path, .. } => {
593                    buf.push(3);
594                    write_path_str(&mut buf, path);
595                }
596            }
597        }
598        // Write to temp file, then rename (atomic on POSIX).
599        let wal_tmp = self.wal_path().with_extension("wal.tmp");
600        std::fs::write(&wal_tmp, &buf).map_err(WriteError::Io)?;
601        std::fs::rename(&wal_tmp, self.wal_path()).map_err(WriteError::Io)?;
602        Ok(())
603    }
604
605    /// If `<manifest_path>.wal` exists, parse and replay pending
606    /// operations into the in-memory state. Returns the count of
607    /// replayed entries (0 if no WAL exists). Best-effort: corrupt
608    /// WAL is silently discarded with a stderr warning.
609    fn replay_wal_if_present(&mut self) -> usize {
610        let wal_path = self.wal_path();
611        let Ok(bytes) = std::fs::read(&wal_path) else {
612            return 0;
613        };
614        if bytes.len() < 40 || &bytes[..8] != b"LIMWAL\0\0" {
615            let _ = std::fs::remove_file(&wal_path);
616            return 0;
617        }
618        // Stale-WAL gate: the WAL belongs to the manifest generation
619        // recorded in its tag. If the on-disk manifest differs, the
620        // WAL's commit already completed — replaying would corrupt
621        // the inode map (torn reads). Discard.
622        let mut tag = [0u8; 32];
623        tag.copy_from_slice(&bytes[8..40]);
624        let current = std::fs::read(&self.manifest_path)
625            .map(|b| limnifs_core::hash_section(&b))
626            .ok();
627        if current != Some(tag) {
628            let _ = std::fs::remove_file(&wal_path);
629            return 0;
630        }
631        let mut cursor = WalCursor {
632            bytes: &bytes,
633            pos: 40,
634        };
635        let files_count = match cursor.read_u32_le() {
636            Ok(n) => n as usize,
637            Err(_) => {
638                let _ = std::fs::remove_file(&wal_path);
639                return 0;
640            }
641        };
642        for _ in 0..files_count {
643            let path = match cursor.read_path_str() {
644                Ok(p) => p,
645                Err(_) => break,
646            };
647            let len = match cursor.read_u64_le() {
648                Ok(n) => n as usize,
649                Err(_) => break,
650            };
651            let data = match cursor.read_bytes(len) {
652                Ok(d) => d.to_vec(),
653                Err(_) => break,
654            };
655            self.pending_files.insert(path, data);
656        }
657        let hist_count = match cursor.read_u32_le() {
658            Ok(n) => n as usize,
659            Err(_) => 0,
660        };
661        let mut replayed = 0;
662        for _ in 0..hist_count {
663            let op = match cursor.read_u8() {
664                Ok(b) => b,
665                Err(_) => break,
666            };
667            let path = match cursor.read_path_str() {
668                Ok(p) => p,
669                Err(_) => break,
670            };
671            match op {
672                1 => {
673                    let inode = self.next_inode;
674                    self.next_inode += 1;
675                    let size = self
676                        .pending_files
677                        .get(&path)
678                        .map(|v| v.len() as u64)
679                        .unwrap_or(0);
680                    self.inode_map.insert(path.clone(), inode);
681                    self.pending_history
682                        .push(HistoryEntry::Add { path, inode, size });
683                }
684                2 => {
685                    let old_inode = self.inode_map.get(&path).copied().unwrap_or(0);
686                    let new_inode = self.next_inode;
687                    self.next_inode += 1;
688                    let size = self
689                        .pending_files
690                        .get(&path)
691                        .map(|v| v.len() as u64)
692                        .unwrap_or(0);
693                    self.inode_map.insert(path.clone(), new_inode);
694                    self.pending_history.push(HistoryEntry::Update {
695                        path,
696                        old_inode,
697                        new_inode,
698                        size,
699                    });
700                }
701                3 => {
702                    let inode = self.inode_map.remove(&path).unwrap_or(0);
703                    self.pending_files.remove(&path);
704                    self.pending_history
705                        .push(HistoryEntry::Delete { path, inode });
706                }
707                _ => break,
708            }
709            replayed += 1;
710        }
711        // WAL replayed — discard so subsequent opens don't double-replay.
712        let _ = std::fs::remove_file(&wal_path);
713        replayed
714    }
715}
716
717fn write_path_str(out: &mut Vec<u8>, s: &str) {
718    let bytes = s.as_bytes();
719    out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
720    out.extend_from_slice(bytes);
721}
722
723struct WalCursor<'a> {
724    bytes: &'a [u8],
725    pos: usize,
726}
727
728impl<'a> WalCursor<'a> {
729    fn read_u8(&mut self) -> Result<u8, ()> {
730        let b = *self.bytes.get(self.pos).ok_or(())?;
731        self.pos += 1;
732        Ok(b)
733    }
734    fn read_u32_le(&mut self) -> Result<u32, ()> {
735        if self.pos + 4 > self.bytes.len() {
736            return Err(());
737        }
738        let mut arr = [0u8; 4];
739        arr.copy_from_slice(&self.bytes[self.pos..self.pos + 4]);
740        self.pos += 4;
741        Ok(u32::from_le_bytes(arr))
742    }
743    fn read_u64_le(&mut self) -> Result<u64, ()> {
744        if self.pos + 8 > self.bytes.len() {
745            return Err(());
746        }
747        let mut arr = [0u8; 8];
748        arr.copy_from_slice(&self.bytes[self.pos..self.pos + 8]);
749        self.pos += 8;
750        Ok(u64::from_le_bytes(arr))
751    }
752    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], ()> {
753        if self.pos + len > self.bytes.len() {
754            return Err(());
755        }
756        let slice = &self.bytes[self.pos..self.pos + len];
757        self.pos += len;
758        Ok(slice)
759    }
760    fn read_path_str(&mut self) -> Result<String, ()> {
761        let len = self.read_u32_le()? as usize;
762        let bytes = self.read_bytes(len)?;
763        std::str::from_utf8(bytes).map(String::from).map_err(|_| ())
764    }
765}
766
767/// Normalize a user-supplied path to a leading-`/` form so it
768/// matches `MetadataBlob::build_path_index` keys.
769fn normalize_path(path: &str) -> String {
770    let trimmed = path.trim_matches('/');
771    if trimmed.is_empty() {
772        "/".to_string()
773    } else {
774        format!("/{trimmed}")
775    }
776}
777
778/// Strip the leading `/` so a key can be safely joined onto a
779/// staging root.
780fn staging_relative(path: &str) -> &str {
781    path.trim_start_matches('/')
782}
783
784/// `rename(2)` is atomic on POSIX filesystems when source and
785/// destination are on the same filesystem. If they're not (e.g.
786/// `/tmp` → `/`), `rename` fails with `EXDEV` — fall back to
787/// `write` + `remove` so we still get the final state, just without
788/// the cross-reader atomicity guarantee.
789fn rename_or_fallback(from: PathBuf, to: PathBuf) -> Result<(), WriteError> {
790    match std::fs::rename(&from, &to) {
791        Ok(()) => Ok(()),
792        Err(e) if e.raw_os_error() == Some(18) => {
793            // EXDEV: cross-device rename. Fall back.
794            let bytes = std::fs::read(&from).map_err(WriteError::Io)?;
795            std::fs::write(&to, &bytes).map_err(WriteError::Io)?;
796            let _ = std::fs::remove_file(&from);
797            Ok(())
798        }
799        Err(e) => Err(WriteError::Io(e)),
800    }
801}
802
803fn core_to_io(e: limnifs_core::CoreError) -> WriteError {
804    WriteError::Io(std::io::Error::other(format!("{e}")))
805}
806
807/// Detect and remove a stale `<path>.new/` directory left behind by
808/// an interrupted commit. The previous manifest at `path` is intact
809/// (atomic swap is incomplete by construction — `write_artifact`
810/// renames the manifest last); the `.new/` is garbage.
811///
812/// Logs nothing on success; silently ignores missing directory. If
813/// the directory exists but cannot be removed (e.g. permissions),
814/// the next commit's `write_artifact` will fail with a clearer
815/// error when it tries to recreate the directory.
816/// Staging directories older than this are considered abandoned
817/// (crashed commits) and removed by `RwImage::open`. Live commits
818/// finish in milliseconds, so a live writer's staging is never
819/// old enough to be collected.
820const STALE_SWAP_AGE: std::time::Duration = std::time::Duration::from_secs(30);
821
822fn cleanup_stale_swap_dir(path: &Path) {
823    let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
824        return;
825    };
826    let parent = path.parent().unwrap_or_else(|| Path::new("."));
827    // Legacy exact name plus unique-suffixed live scheme; only remove
828    // entries older than STALE_SWAP_AGE so a concurrent commit's
829    // staging is never touched.
830    let Ok(entries) = std::fs::read_dir(parent) else {
831        return;
832    };
833    for entry in entries.flatten() {
834        let file_name = entry.file_name();
835        let Some(fname) = file_name.to_str() else {
836            continue;
837        };
838        if fname == format!("{name}.new") {
839            // Legacy staging (pre unique-suffix): no live writer can
840            // be using this name anymore.
841            let _ = std::fs::remove_dir_all(entry.path());
842            continue;
843        }
844        if let Some(rest) = fname.strip_prefix(&format!("{name}.new-")) {
845            // Unique-suffix staging: only remove when abandoned.
846            let mtime_old = entry
847                .metadata()
848                .and_then(|m| m.modified())
849                .ok()
850                .and_then(|t| t.elapsed().ok())
851                .is_some_and(|age| age > STALE_SWAP_AGE);
852            if mtime_old {
853                let _ = std::fs::remove_dir_all(entry.path());
854            }
855            let _ = rest;
856        }
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::profile;
864
865    #[test]
866    fn open_cleans_up_stale_new_directory() {
867        // Simulate a crashed previous commit: image exists, plus a
868        // stale <image>.new/ directory. RwImage::open must remove
869        // the stale directory so the next commit doesn't trip.
870        let workdir = std::env::temp_dir().join(format!(
871            "limnifs-crash-recovery-{}-{}",
872            std::process::id(),
873            std::time::SystemTime::now()
874                .duration_since(std::time::UNIX_EPOCH)
875                .map(|d| d.as_nanos() as u64)
876                .unwrap_or(0),
877        ));
878        let _ = std::fs::remove_dir_all(&workdir);
879        std::fs::create_dir_all(&workdir).expect("mkdir");
880
881        // Write a minimal valid image.
882        std::fs::write(workdir.join("data.txt"), b"alpha").expect("src");
883        let manifest = workdir.join("image.lim");
884        let artifact =
885            crate::write_directory_with_config(&workdir, &profile::balanced()).expect("write");
886        std::fs::write(&manifest, &artifact.bytes).expect("manifest");
887        for slab in &artifact.slabs {
888            let name = sidecar_name(&slab.locator).expect("slab locator");
889            std::fs::write(workdir.join(name), &slab.bytes).expect("slab");
890        }
891        if let Some(sidecar) = &artifact.metadata_sidecar {
892            let name = sidecar_name(&sidecar.locator).expect("sidecar locator");
893            std::fs::write(workdir.join(name), &sidecar.bytes).expect("sidecar");
894        }
895
896        // Simulate a crash: create <image>.new/ with garbage.
897        let stale = workdir.join("image.lim.new");
898        std::fs::create_dir_all(&stale).expect("mkdir stale");
899        std::fs::write(stale.join("partial.lim"), b"garbage from crashed commit").expect("garbage");
900        assert!(stale.is_dir(), "stale dir exists before open");
901
902        // Open should clean it up.
903        let _image = RwImage::open(&manifest, profile::balanced()).expect("open");
904        assert!(!stale.exists(), "stale dir removed by open");
905
906        let _ = std::fs::remove_dir_all(&workdir);
907    }
908
909    #[test]
910    fn wal_round_trip_recovers_pending_state_after_simulated_crash() {
911        // 1. Build a base image.
912        // 2. Open it, add a file, update another, delete a third
913        //    (this populates pending_files/pending_history).
914        // 3. Call commit() — but simulate a crash by manually
915        //    keeping the WAL around after the swap (i.e., we don't
916        //    unlink it). Actually, simpler: call commit() which
917        //    writes the WAL and runs the swap; then re-create the
918        //    WAL by writing it ourselves with the same pending state.
919        // 4. Open again — WAL replay should restore pending state.
920        //
921        // The simplest faithful simulation: open → mutate → drop the
922        // image without commit → manually call write_wal on a fresh
923        // image pointing at the same manifest.
924        let workdir = std::env::temp_dir().join(format!(
925            "limnifs-wal-rt-{}-{}",
926            std::process::id(),
927            std::time::SystemTime::now()
928                .duration_since(std::time::UNIX_EPOCH)
929                .map(|d| d.as_nanos() as u64)
930                .unwrap_or(0),
931        ));
932        let _ = std::fs::remove_dir_all(&workdir);
933        std::fs::create_dir_all(&workdir).expect("mkdir");
934        std::fs::write(workdir.join("a.txt"), b"alpha").expect("seed a");
935        std::fs::write(workdir.join("b.txt"), b"beta").expect("seed b");
936        let manifest = workdir.join("image.lim");
937        let artifact =
938            crate::write_directory_with_config(&workdir, &profile::balanced()).expect("write");
939        std::fs::write(&manifest, &artifact.bytes).expect("manifest");
940        for slab in &artifact.slabs {
941            let name = sidecar_name(&slab.locator).expect("slab locator");
942            std::fs::write(workdir.join(name), &slab.bytes).expect("slab");
943        }
944        if let Some(sidecar) = &artifact.metadata_sidecar {
945            let name = sidecar_name(&sidecar.locator).expect("sidecar locator");
946            std::fs::write(workdir.join(name), &sidecar.bytes).expect("sidecar");
947        }
948
949        // Mutate but don't commit (simulates crash before swap).
950        {
951            let mut image = RwImage::open(&manifest, profile::balanced()).expect("open");
952            image.add_file("c.txt", b"gamma").expect("add c");
953            image.update_file("a.txt", b"alpha2").expect("update a");
954            image.delete_file("b.txt").expect("delete b");
955            assert_eq!(image.pending_changes(), 3);
956            // Write WAL without running swap (simulates crash between
957            // WAL write and successful swap).
958            image.write_wal().expect("write WAL");
959            assert!(
960                manifest.with_extension("lim.wal").exists() || {
961                    // Some platforms the file_name handling differs; check via wal_path.
962                    let wal = image.wal_path();
963                    eprintln!("WAL path: {}", wal.display());
964                    wal.exists()
965                }
966            );
967            // Drop without calling commit. The image is unchanged on disk.
968        }
969
970        // Reopen — WAL should replay and restore pending state.
971        let image = RwImage::open(&manifest, profile::balanced()).expect("reopen");
972        assert_eq!(
973            image.pending_changes(),
974            3,
975            "WAL should have replayed 3 pending ops"
976        );
977        let _ = std::fs::remove_dir_all(&workdir);
978    }
979
980    #[test]
981    fn rw_image_create_and_add() {
982        let config = profile::balanced();
983        let mut image = RwImage::create_new(Path::new("/tmp/test.lim"), config);
984        let inode = image.add_file("hello.txt", b"hello world").expect("add");
985        assert_eq!(inode, 1);
986        assert_eq!(image.pending_changes(), 1);
987    }
988
989    #[test]
990    fn rw_image_update_and_delete() {
991        let config = profile::balanced();
992        let mut image = RwImage::create_new(Path::new("/tmp/test.lim"), config);
993        image.add_file("file.txt", b"original").expect("add");
994        image.update_file("file.txt", b"updated").expect("update");
995        assert_eq!(image.pending_changes(), 2);
996        image.delete_file("file.txt").expect("delete");
997        assert_eq!(image.pending_changes(), 3);
998    }
999
1000    #[test]
1001    fn rw_image_update_nonexistent_fails() {
1002        let config = profile::balanced();
1003        let mut image = RwImage::create_new(Path::new("/tmp/test.lim"), config);
1004        assert!(image.update_file("nope.txt", b"data").is_err());
1005    }
1006
1007    #[test]
1008    fn rw_image_needs_turnover() {
1009        let mut config = profile::balanced();
1010        config.turnover_threshold = 3;
1011        let mut image = RwImage::create_new(Path::new("/tmp/test.lim"), config);
1012        image.add_file("a", b"data").expect("add");
1013        image.add_file("b", b"data").expect("add");
1014        assert!(!image.needs_turnover());
1015        image.add_file("c", b"data").expect("add");
1016        assert!(image.needs_turnover());
1017    }
1018
1019    /// Helper: write `files` into a fresh staging dir, build an
1020    /// image with the given config, and return the manifest path.
1021    fn write_initial(files: &[(&str, &[u8])], config: &WriteConfig) -> PathBuf {
1022        let staging = std::env::temp_dir().join(format!(
1023            "limnifs-rw-test-init-{}-{}",
1024            std::process::id(),
1025            rand_u64()
1026        ));
1027        let _ = std::fs::remove_dir_all(&staging);
1028        std::fs::create_dir_all(&staging).expect("mkdir staging");
1029        for (name, data) in files {
1030            let path = staging.join(name);
1031            if let Some(parent) = path.parent() {
1032                std::fs::create_dir_all(parent).expect("mkdir parent");
1033            }
1034            std::fs::write(&path, data).expect("write file");
1035        }
1036        let manifest = staging.join("image.lim");
1037        let artifact = crate::write_directory_with_config(&staging, config).expect("write");
1038        std::fs::write(&manifest, &artifact.bytes).expect("write manifest");
1039        for slab in &artifact.slabs {
1040            let name = sidecar_name(&slab.locator).expect("locator");
1041            std::fs::write(staging.join(name), &slab.bytes).expect("write slab");
1042        }
1043        if let Some(sidecar) = &artifact.metadata_sidecar {
1044            let name = sidecar_name(&sidecar.locator).expect("locator");
1045            std::fs::write(staging.join(name), &sidecar.bytes).expect("write sidecar");
1046        }
1047        manifest
1048    }
1049
1050    /// Tiny PRNG to avoid pulling the `rand` crate just for a
1051    /// non-colliding nonce in tests.
1052    fn rand_u64() -> u64 {
1053        use std::cell::Cell;
1054        use std::time::{SystemTime, UNIX_EPOCH};
1055        thread_local!(static SEED: Cell<u64> = {
1056            let nanos = SystemTime::now()
1057                .duration_since(UNIX_EPOCH)
1058                .map(|d| d.as_nanos() as u64)
1059                .unwrap_or(0);
1060            Cell::new(nanos ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
1061        });
1062        SEED.with(|s| {
1063            let mut x = s.get();
1064            x ^= x << 13;
1065            x ^= x >> 7;
1066            x ^= x << 17;
1067            s.set(x);
1068            x
1069        })
1070    }
1071
1072    #[test]
1073    fn rw_image_open_round_trip() {
1074        let config = profile::balanced();
1075        let manifest = write_initial(
1076            &[("hello.txt", b"hello world"), ("dir/note.txt", b"nested")],
1077            &config,
1078        );
1079        let image = RwImage::open(&manifest, profile::balanced()).expect("open");
1080        assert_eq!(
1081            image.read_file("hello.txt").expect("read hello"),
1082            b"hello world"
1083        );
1084        assert_eq!(
1085            image.read_file("dir/note.txt").expect("read note"),
1086            b"nested"
1087        );
1088    }
1089
1090    #[test]
1091    fn rw_image_commit_adds_file() {
1092        let config = profile::balanced();
1093        let manifest = write_initial(&[("a.txt", b"alpha")], &config);
1094
1095        let mut image = RwImage::open(&manifest, profile::balanced()).expect("open");
1096        image.add_file("b.txt", b"beta").expect("add");
1097        let _ = image.commit().expect("commit");
1098
1099        let reread = RwImage::open(&manifest, profile::balanced()).expect("reopen");
1100        assert_eq!(reread.read_file("a.txt").expect("read a"), b"alpha");
1101        assert_eq!(reread.read_file("b.txt").expect("read b"), b"beta");
1102    }
1103
1104    #[test]
1105    fn concurrent_readers_never_observe_torn_state_during_commit() {
1106        // IMPL-4 (TODO.remaining): a second thread opening the image
1107        // during a commit must never observe an inconsistent snapshot
1108        // — either the old complete image or the new complete one.
1109        // The write_artifact ordering (sidecar → slabs → manifest)
1110        // plus per-file rename(2) atomicity guarantees this.
1111        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1112        use std::sync::Arc;
1113
1114        let config = profile::balanced();
1115        let manifest = write_initial(&[("a.txt", b"gen0")], &config);
1116
1117        let stop = Arc::new(AtomicBool::new(false));
1118        let opens = Arc::new(AtomicUsize::new(0));
1119        let torn = Arc::new(AtomicUsize::new(0));
1120
1121        let mut handles = Vec::new();
1122        for _ in 0..4 {
1123            let m = manifest.clone();
1124            let stop = Arc::clone(&stop);
1125            let opens = Arc::clone(&opens);
1126            let torn = Arc::clone(&torn);
1127            handles.push(std::thread::spawn(move || {
1128                while !stop.load(Ordering::Relaxed) {
1129                    match RwImage::open(&m, profile::balanced()) {
1130                        Ok(image) => {
1131                            opens.fetch_add(1, Ordering::Relaxed);
1132                            // Either generation is valid; anything else is torn.
1133                            match image.read_file("a.txt") {
1134                                Ok(bytes) if bytes == b"gen0" => {}
1135                                Ok(bytes) if bytes == b"genN" => {}
1136                                Ok(bytes) => {
1137                                    eprintln!("torn read: {:?}", String::from_utf8_lossy(&bytes));
1138                                    torn.fetch_add(1, Ordering::Relaxed);
1139                                }
1140                                Err(e) => {
1141                                    eprintln!("torn open->read: {e}");
1142                                    torn.fetch_add(1, Ordering::Relaxed);
1143                                }
1144                            }
1145                        }
1146                        Err(e) => {
1147                            eprintln!("torn open: {e}");
1148                            torn.fetch_add(1, Ordering::Relaxed);
1149                        }
1150                    }
1151                }
1152            }));
1153        }
1154
1155        // Commit several generations while readers loop.
1156        {
1157            let mut image = RwImage::open(&manifest, profile::balanced()).expect("open writer");
1158            image.update_file("a.txt", b"genN").expect("update");
1159            image.commit().expect("commit during readers");
1160        }
1161        std::thread::sleep(std::time::Duration::from_millis(50));
1162        {
1163            let mut image = RwImage::open(&manifest, profile::balanced()).expect("reopen");
1164            image.update_file("a.txt", b"genN").expect("update 2");
1165            image.commit().expect("commit 2");
1166        }
1167
1168        stop.store(true, Ordering::Relaxed);
1169        for h in handles {
1170            h.join().expect("reader thread");
1171        }
1172        assert_eq!(
1173            torn.load(Ordering::Relaxed),
1174            0,
1175            "no reader saw a torn snapshot"
1176        );
1177        assert!(
1178            opens.load(Ordering::Relaxed) > 0,
1179            "readers actually opened the image"
1180        );
1181    }
1182
1183    #[test]
1184    fn rw_image_commit_updates_and_deletes() {
1185        let config = profile::balanced();
1186        let manifest = write_initial(&[("a.txt", b"alpha"), ("b.txt", b"beta")], &config);
1187
1188        let mut image = RwImage::open(&manifest, profile::balanced()).expect("open");
1189        image.update_file("a.txt", b"alpha2").expect("update");
1190        image.delete_file("b.txt").expect("delete");
1191        let _ = image.commit().expect("commit");
1192
1193        let reread = RwImage::open(&manifest, profile::balanced()).expect("reopen");
1194        assert_eq!(reread.read_file("a.txt").expect("read a"), b"alpha2");
1195        assert!(reread.read_file("b.txt").is_err(), "b.txt must be gone");
1196    }
1197
1198    #[test]
1199    fn rw_image_turnover_preserves_tree() {
1200        let config = profile::max_write();
1201        let manifest = write_initial(
1202            &[("hello.txt", b"hello world"), ("dir/note.txt", b"nested")],
1203            &config,
1204        );
1205
1206        let image = RwImage::open(&manifest, profile::max_write()).expect("open");
1207        let _ = image.turnover().expect("turnover");
1208
1209        let reread = RwImage::open(&manifest, profile::max_write()).expect("reopen");
1210        assert_eq!(
1211            reread.read_file("hello.txt").expect("read hello"),
1212            b"hello world"
1213        );
1214        assert_eq!(
1215            reread.read_file("dir/note.txt").expect("read note"),
1216            b"nested"
1217        );
1218    }
1219}