1#![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
45pub struct RwImage {
47 manifest_path: PathBuf,
48 config: WriteConfig,
49 state: Option<OpenState>,
50 inode_map: HashMap<String, u64>,
53 pending_files: HashMap<String, Vec<u8>>,
55 pending_history: Vec<HistoryEntry>,
57 next_inode: u64,
59}
60
61struct OpenState {
64 blob: MetadataBlob,
65 root_inode: u64,
66 slab_store: Option<SlabStore>,
67}
68
69#[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 pub fn open(path: &Path, config: WriteConfig) -> Result<Self, WriteError> {
97 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 let _ = image.replay_wal_if_present();
165 Ok(image)
166 }
167
168 #[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 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 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 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 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 #[must_use]
286 pub fn pending_changes(&self) -> usize {
287 self.pending_history.len()
288 }
289
290 #[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 #[must_use]
300 pub fn mode(&self) -> &ImageMode {
301 &self.config.mode
302 }
303
304 pub fn commit(&self) -> Result<crate::WriteArtifact, WriteError> {
317 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 let _ = std::fs::remove_file(self.wal_path());
326 Ok(artifact)
327 }
328
329 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 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 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 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 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 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 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 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 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 let _ = std::fs::remove_dir_all(&staging);
488 Ok(())
489 }
490
491 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 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 fn write_wal(&self) -> Result<(), WriteError> {
539 let mut buf: Vec<u8> = Vec::new();
540 buf.extend_from_slice(b"LIMWAL\0\0");
542 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 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 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 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 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
723fn 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
734fn staging_relative(path: &str) -> &str {
737 path.trim_start_matches('/')
738}
739
740fn 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 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
763fn 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 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 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 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 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 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 {
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 image.write_wal().expect("write WAL");
888 assert!(
889 manifest.with_extension("lim.wal").exists() || {
890 let wal = image.wal_path();
892 eprintln!("WAL path: {}", wal.display());
893 wal.exists()
894 }
895 );
896 }
898
899 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 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 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}