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