public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Kefu Chai <k.chai@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH pve-cluster 05/15] pmxcfs-rs: add pmxcfs-memdb crate
Date: Tue,  6 Jan 2026 22:24:29 +0800	[thread overview]
Message-ID: <20260106142440.2368585-6-k.chai@proxmox.com> (raw)
In-Reply-To: <20260106142440.2368585-1-k.chai@proxmox.com>

Add in-memory database with SQLite persistence:
- MemDb: Main database handle (thread-safe via Arc)
- TreeEntry: File/directory entries with metadata
- SQLite schema version 5 (C-compatible)
- Plugin system (6 functional + 4 link plugins)
- Resource locking with timeout-based expiration
- Version tracking and checksumming
- Index encoding/decoding for cluster synchronization

This crate depends only on pmxcfs-api-types and external
libraries (rusqlite, sha2, bincode). It provides the core
storage layer used by the distributed file system.

Includes comprehensive unit tests for:
- CRUD operations on files and directories
- Lock acquisition and expiration
- SQLite persistence and recovery
- Index encoding/decoding for sync
- Tree entry application

Signed-off-by: Kefu Chai <k.chai@proxmox.com>
---
 src/pmxcfs-rs/Cargo.toml                      |    1 +
 src/pmxcfs-rs/pmxcfs-memdb/Cargo.toml         |   42 +
 src/pmxcfs-rs/pmxcfs-memdb/README.md          |  220 ++
 src/pmxcfs-rs/pmxcfs-memdb/src/database.rs    | 2227 +++++++++++++++++
 src/pmxcfs-rs/pmxcfs-memdb/src/index.rs       |  814 ++++++
 src/pmxcfs-rs/pmxcfs-memdb/src/lib.rs         |   26 +
 src/pmxcfs-rs/pmxcfs-memdb/src/locks.rs       |  286 +++
 src/pmxcfs-rs/pmxcfs-memdb/src/sync.rs        |  249 ++
 src/pmxcfs-rs/pmxcfs-memdb/src/traits.rs      |  101 +
 src/pmxcfs-rs/pmxcfs-memdb/src/types.rs       |  325 +++
 src/pmxcfs-rs/pmxcfs-memdb/src/vmlist.rs      |  189 ++
 .../pmxcfs-memdb/tests/checksum_test.rs       |  158 ++
 .../tests/sync_integration_tests.rs           |  394 +++
 13 files changed, 5032 insertions(+)
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/Cargo.toml
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/README.md
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/database.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/index.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/lib.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/locks.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/sync.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/traits.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/types.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/src/vmlist.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/tests/checksum_test.rs
 create mode 100644 src/pmxcfs-rs/pmxcfs-memdb/tests/sync_integration_tests.rs

diff --git a/src/pmxcfs-rs/Cargo.toml b/src/pmxcfs-rs/Cargo.toml
index dd36c81f..2e41ac93 100644
--- a/src/pmxcfs-rs/Cargo.toml
+++ b/src/pmxcfs-rs/Cargo.toml
@@ -5,6 +5,7 @@ members = [
     "pmxcfs-config",     # Configuration management
     "pmxcfs-logger",     # Cluster log with ring buffer and deduplication
     "pmxcfs-rrd",        # RRD (Round-Robin Database) persistence
+    "pmxcfs-memdb",      # In-memory database with SQLite persistence
 ]
 resolver = "2"
 
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/Cargo.toml b/src/pmxcfs-rs/pmxcfs-memdb/Cargo.toml
new file mode 100644
index 00000000..409b87ce
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/Cargo.toml
@@ -0,0 +1,42 @@
+[package]
+name = "pmxcfs-memdb"
+description = "In-memory database with SQLite persistence for pmxcfs"
+
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+# Error handling
+anyhow.workspace = true
+
+# Database
+rusqlite = { version = "0.30", features = ["bundled"] }
+
+# Concurrency primitives
+parking_lot.workspace = true
+
+# System integration
+libc.workspace = true
+
+# Cryptography (for checksums)
+sha2.workspace = true
+bytes.workspace = true
+
+# Serialization
+serde.workspace = true
+bincode.workspace = true
+
+# Logging
+tracing.workspace = true
+
+# pmxcfs types
+pmxcfs-api-types = { path = "../pmxcfs-api-types" }
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/README.md b/src/pmxcfs-rs/pmxcfs-memdb/README.md
new file mode 100644
index 00000000..172e7351
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/README.md
@@ -0,0 +1,220 @@
+# pmxcfs-memdb
+
+**In-Memory Database** with SQLite persistence for pmxcfs cluster filesystem.
+
+This crate provides a thread-safe, cluster-synchronized in-memory database that serves as the backend storage for the Proxmox cluster filesystem. All filesystem operations (read, write, create, delete) are performed on in-memory structures with SQLite providing durable persistence.
+
+## Overview
+
+The MemDb is the core data structure that stores all cluster configuration files in memory for fast access while maintaining durability through SQLite. Changes are synchronized across the cluster using the DFSM protocol.
+
+### Key Features
+
+- **In-memory tree structure**: All filesystem entries cached in memory
+- **SQLite persistence**: Durable storage with ACID guarantees
+- **Cluster synchronization**: State replication via DFSM (pmxcfs-dfsm crate)
+- **Version tracking**: Monotonically increasing version numbers for conflict detection
+- **Resource locking**: File-level locks with timeout-based expiration
+- **Thread-safe**: All operations protected by mutex
+- **Size limits**: Enforces max file size (1 MiB) and total filesystem size (128 MiB)
+
+## Architecture
+
+### Module Structure
+
+| Module | Purpose | C Equivalent |
+|--------|---------|--------------|
+| `database.rs` | Core MemDb struct and CRUD operations | `memdb.c` (main functions) |
+| `types.rs` | TreeEntry, LockInfo, constants | `memdb.h:38-51, 71-74` |
+| `locks.rs` | Resource locking functionality | `memdb.c:memdb_lock_*` |
+| `sync.rs` | State serialization for cluster sync | `memdb.c:memdb_encode_index` |
+| `index.rs` | Index comparison for DFSM updates | `memdb.c:memdb_index_*` |
+
+## C to Rust Mapping
+
+### Data Structures
+
+| C Type | Rust Type | Notes |
+|--------|-----------|-------|
+| `memdb_t` | `MemDb` | Main database handle (Clone-able via Arc) |
+| `memdb_tree_entry_t` | `TreeEntry` | File/directory entry |
+| `memdb_index_t` | `MemDbIndex` | Serialized state for sync |
+| `memdb_index_extry_t` | `IndexEntry` | Single index entry |
+| `memdb_lock_info_t` | `LockInfo` | Lock metadata |
+| `db_backend_t` | `Connection` | SQLite backend (rusqlite) |
+| `GHashTable *index` | `HashMap<u64, TreeEntry>` | Inode index |
+| `GHashTable *locks` | `HashMap<String, LockInfo>` | Lock table |
+| `GMutex mutex` | `Mutex` | Thread synchronization |
+
+### Core Functions
+
+#### Database Lifecycle
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_open()` | `MemDb::open()` | database.rs |
+| `memdb_close()` | (Drop trait) | Automatic |
+| `memdb_checkpoint()` | (implicit in writes) | Auto-commit |
+
+#### File Operations
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_read()` | `MemDb::read()` | database.rs |
+| `memdb_write()` | `MemDb::write()` | database.rs |
+| `memdb_create()` | `MemDb::create()` | database.rs |
+| `memdb_delete()` | `MemDb::delete()` | database.rs |
+| `memdb_mkdir()` | `MemDb::create()` (with DT_DIR) | database.rs |
+| `memdb_rename()` | `MemDb::rename()` | database.rs |
+| `memdb_mtime()` | (included in write) | database.rs |
+
+#### Directory Operations
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_readdir()` | `MemDb::readdir()` | database.rs |
+| `memdb_dirlist_free()` | (automatic) | Rust's Vec drops automatically |
+
+#### Metadata Operations
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_getattr()` | `MemDb::lookup_path()` | database.rs |
+| `memdb_statfs()` | `MemDb::statfs()` | database.rs |
+
+#### Tree Entry Functions
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_tree_entry_new()` | `TreeEntry { ... }` | Struct initialization |
+| `memdb_tree_entry_copy()` | `.clone()` | Automatic (derive Clone) |
+| `memdb_tree_entry_free()` | (Drop trait) | Automatic |
+| `tree_entry_debug()` | `{:?}` format | Automatic (derive Debug) |
+| `memdb_tree_entry_csum()` | `TreeEntry::compute_checksum()` | types.rs |
+
+#### Lock Operations
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_lock_expired()` | `MemDb::is_lock_expired()` | locks.rs |
+| `memdb_update_locks()` | `MemDb::update_locks()` | locks.rs |
+
+#### Index/Sync Operations
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_encode_index()` | `MemDb::get_index()` | sync.rs |
+| `memdb_index_copy()` | `.clone()` | Automatic (derive Clone) |
+| `memdb_compute_checksum()` | `MemDb::compute_checksum()` | sync.rs |
+| `bdb_backend_commit_update()` | `MemDb::apply_tree_entry()` | database.rs |
+
+#### State Synchronization
+
+| C Function | Rust Equivalent | Location |
+|-----------|-----------------|----------|
+| `memdb_recreate_vmlist()` | (handled by status crate) | External |
+| (implicit) | `MemDb::replace_all_entries()` | database.rs |
+
+### SQLite Backend
+
+**C Version (database.c):**
+- Direct SQLite3 C API
+- Manual statement preparation
+- Explicit transaction management
+- Manual memory management
+
+**Rust Version (database.rs):**
+- `rusqlite` crate for type-safe SQLite access
+
+## Database Schema
+
+The SQLite schema stores all filesystem entries with metadata:
+- `inode = 1` is always the root directory
+- `parent = 0` for root, otherwise parent directory's inode
+- `version` increments on each modification (monotonic)
+- `writer` is the node ID that made the change
+- `mtime` is seconds since UNIX epoch
+- `data` is NULL for directories, BLOB for files
+
+## TreeEntry Wire Format
+
+For cluster synchronization (DFSM Update messages), TreeEntry uses C-compatible serialization that is byte-compatible with C's implementation.
+
+## Key Differences from C Implementation
+
+### Thread Safety
+
+**C Version:**
+- Single `GMutex` protects entire memdb_t
+- Callback-based access from qb_loop (single-threaded)
+
+**Rust Version:**
+- Mutex for each data structure (index, tree, locks, conn)
+- More granular locking
+- Can be shared across tokio tasks
+
+### Data Structures
+
+**C Version:**
+- `GHashTable` (GLib) for index and tree
+- Recursive tree structure with pointers
+
+**Rust Version:**
+- `HashMap` from std
+- Flat structure: `HashMap<u64, HashMap<String, u64>>` for tree
+- Separate `HashMap<u64, TreeEntry>` for index
+- No recursive pointers (eliminates cycles)
+
+### SQLite Integration
+
+**C Version (database.c):**
+- Direct SQLite3 C API
+
+**Rust Version (database.rs):**
+- `rusqlite` crate for type-safe SQLite access
+
+## Constants
+
+| Constant | Value | Purpose |
+|----------|-------|---------|
+| `MEMDB_MAX_FILE_SIZE` | 1 MiB | Maximum file size (matches C) |
+| `MEMDB_MAX_FSSIZE` | 128 MiB | Maximum total filesystem size |
+| `MEMDB_MAX_INODES` | 256k | Maximum number of files/dirs |
+| `MEMDB_BLOCKSIZE` | 4096 | Block size for statfs |
+| `LOCK_TIMEOUT` | 120 sec | Lock expiration timeout |
+| `DT_DIR` | 4 | Directory type (matches POSIX) |
+| `DT_REG` | 8 | Regular file type (matches POSIX) |
+
+## Known Issues / TODOs
+
+### Missing Features
+
+- [ ] **vmlist regeneration**: `memdb_recreate_vmlist()` not implemented (handled by status crate's `scan_vmlist()`)
+
+### Behavioral Differences (Benign)
+
+- **Lock storage**: C reads from filesystem at startup, Rust does the same but implementation differs
+- **Index encoding**: Rust uses `Vec<IndexEntry>` instead of flexible array member
+- **Checksum algorithm**: Same (SHA-256) but implementation differs (ring vs OpenSSL)
+
+### Compatibility
+
+- **Database format**: 100% compatible with C version (same SQLite schema)
+- **Wire format**: TreeEntry serialization matches C byte-for-byte
+- **Constants**: All limits match C version exactly
+
+## References
+
+### C Implementation
+- `src/pmxcfs/memdb.c` / `memdb.h` - In-memory database
+- `src/pmxcfs/database.c` - SQLite backend
+
+### Related Crates
+- **pmxcfs-dfsm**: Uses MemDb for cluster synchronization
+- **pmxcfs-api-types**: Message types for FUSE operations
+- **pmxcfs**: Main daemon and FUSE integration
+
+### External Dependencies
+- **rusqlite**: SQLite bindings
+- **parking_lot**: Fast mutex implementation
+- **sha2**: SHA-256 checksums
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/database.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/database.rs
new file mode 100644
index 00000000..ee280683
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/database.rs
@@ -0,0 +1,2227 @@
+/// Core MemDb implementation - in-memory database with SQLite persistence
+use anyhow::{Context, Result};
+use parking_lot::Mutex;
+use rusqlite::{Connection, params};
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use super::types::LockInfo;
+use super::types::{
+    DT_DIR, DT_REG, LOCK_DIR_PATH, LoadDbResult, MEMDB_MAX_FILE_SIZE, ROOT_INODE, TreeEntry,
+    VERSION_FILENAME,
+};
+
+/// In-memory database with SQLite persistence
+#[derive(Clone)]
+pub struct MemDb {
+    pub(super) inner: Arc<MemDbInner>,
+}
+
+pub(super) struct MemDbInner {
+    /// SQLite connection for persistence (wrapped in Mutex for thread-safety)
+    pub(super) conn: Mutex<Connection>,
+
+    /// In-memory index of all entries (inode -> TreeEntry)
+    /// This is a cache of the database for fast lookups
+    pub(super) index: Mutex<HashMap<u64, TreeEntry>>,
+
+    /// In-memory tree structure (parent inode -> children)
+    pub(super) tree: Mutex<HashMap<u64, HashMap<String, u64>>>,
+
+    /// Root entry
+    pub(super) root_inode: u64,
+
+    /// Current version (incremented on each write)
+    pub(super) version: AtomicU64,
+
+    /// Resource locks (path -> LockInfo)
+    pub(super) locks: Mutex<HashMap<String, LockInfo>>,
+}
+
+// Manually implement Send and Sync for MemDb
+// This is safe because we protect the Connection with a Mutex
+unsafe impl Send for MemDbInner {}
+unsafe impl Sync for MemDbInner {}
+
+impl MemDb {
+    pub fn open(path: &Path, create: bool) -> Result<Self> {
+        let conn = Connection::open(path)?;
+
+        if create {
+            Self::init_schema(&conn)?;
+        }
+
+        let (index, tree, root_inode, version) = Self::load_from_db(&conn)?;
+
+        let memdb = Self {
+            inner: Arc::new(MemDbInner {
+                conn: Mutex::new(conn),
+                index: Mutex::new(index),
+                tree: Mutex::new(tree),
+                root_inode,
+                version: AtomicU64::new(version),
+                locks: Mutex::new(HashMap::new()),
+            }),
+        };
+
+        memdb.update_locks();
+
+        Ok(memdb)
+    }
+
+    fn init_schema(conn: &Connection) -> Result<()> {
+        conn.execute_batch(
+            r#"
+            CREATE TABLE tree (
+                inode INTEGER PRIMARY KEY,
+                parent INTEGER NOT NULL,
+                version INTEGER NOT NULL,
+                writer INTEGER NOT NULL,
+                mtime INTEGER NOT NULL,
+                type INTEGER NOT NULL,
+                name TEXT NOT NULL,
+                data BLOB,
+                size INTEGER NOT NULL
+            );
+
+            CREATE INDEX tree_parent_idx ON tree(parent, name);
+
+            CREATE TABLE config (
+                name TEXT PRIMARY KEY,
+                value TEXT
+            );
+            "#,
+        )?;
+
+        // Create root metadata entry as inode ROOT_INODE with name "__version__"
+        // Matching C implementation: root inode is NEVER in database as a regular entry
+        // Root metadata is stored as inode ROOT_INODE with special name "__version__"
+        let now = SystemTime::now()
+            .duration_since(SystemTime::UNIX_EPOCH)?
+            .as_secs() as u32;
+
+        conn.execute(
+            "INSERT INTO tree (inode, parent, version, writer, mtime, type, name, data, size) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+            params![ROOT_INODE, ROOT_INODE, 1, 0, now, DT_REG, VERSION_FILENAME, None::<Vec<u8>>, 0],
+        )?;
+
+        Ok(())
+    }
+
+    fn load_from_db(conn: &Connection) -> Result<LoadDbResult> {
+        let mut index = HashMap::new();
+        let mut tree: HashMap<u64, HashMap<String, u64>> = HashMap::new();
+        let mut max_version = 0u64;
+
+        let mut stmt = conn.prepare(
+            "SELECT inode, parent, version, writer, mtime, type, name, data, size FROM tree",
+        )?;
+        let rows = stmt.query_map([], |row| {
+            let inode: u64 = row.get(0)?;
+            let parent: u64 = row.get(1)?;
+            let version: u64 = row.get(2)?;
+            let writer: u32 = row.get(3)?;
+            let mtime: u32 = row.get(4)?;
+            let entry_type: u8 = row.get(5)?;
+            let name: String = row.get(6)?;
+            let data: Option<Vec<u8>> = row.get(7)?;
+            let size: i64 = row.get(8)?;
+
+            Ok(TreeEntry {
+                inode,
+                parent,
+                version,
+                writer,
+                mtime,
+                size: size as usize,
+                entry_type,
+                name,
+                data: data.unwrap_or_default(),
+            })
+        })?;
+
+        // Create root entry in memory first (matching C implementation in database.c:559-567)
+        // Root is NEVER stored in database, only its metadata via inode ROOT_INODE
+        let now = SystemTime::now()
+            .duration_since(SystemTime::UNIX_EPOCH)?
+            .as_secs() as u32;
+        let mut root = TreeEntry {
+            inode: ROOT_INODE,
+            parent: ROOT_INODE, // Root's parent is itself
+            version: 0,         // Will be populated from __version__ entry
+            writer: 0,
+            mtime: now,
+            size: 0,
+            entry_type: DT_DIR,
+            name: String::new(),
+            data: Vec::new(),
+        };
+
+        for row in rows {
+            let entry = row?;
+
+            // Handle __version__ entry (inode ROOT_INODE) - populate root metadata (C: database.c:372-382)
+            if entry.inode == ROOT_INODE {
+                if entry.name == VERSION_FILENAME {
+                    tracing::debug!(
+                        "Loading root metadata from __version__: version={}, writer={}, mtime={}",
+                        entry.version,
+                        entry.writer,
+                        entry.mtime
+                    );
+                    root.version = entry.version;
+                    root.writer = entry.writer;
+                    root.mtime = entry.mtime;
+                    if entry.version > max_version {
+                        max_version = entry.version;
+                    }
+                } else {
+                    tracing::warn!("Ignoring inode 0 with unexpected name: {}", entry.name);
+                }
+                continue; // Don't add __version__ to index
+            }
+
+            // Track max version from all entries
+            if entry.version > max_version {
+                max_version = entry.version;
+            }
+
+            // Add to tree structure
+            tree.entry(entry.parent)
+                .or_default()
+                .insert(entry.name.clone(), entry.inode);
+
+            // If this is a directory, ensure it has an entry in the tree map
+            if entry.is_dir() {
+                tree.entry(entry.inode).or_default();
+            }
+
+            // Add to index
+            index.insert(entry.inode, entry);
+        }
+
+        // If root version is still 0, set it to 1 (new database)
+        if root.version == 0 {
+            root.version = 1;
+            max_version = 1;
+            tracing::debug!("No __version__ entry found, initializing root with version 1");
+        }
+
+        // Add root to index and ensure it has a tree entry (use entry() to not overwrite children!)
+        index.insert(ROOT_INODE, root);
+        tree.entry(ROOT_INODE).or_default();
+
+        Ok((index, tree, ROOT_INODE, max_version))
+    }
+
+    pub fn get_entry_by_inode(&self, inode: u64) -> Option<TreeEntry> {
+        let index = self.inner.index.lock();
+        index.get(&inode).cloned()
+    }
+
+    /// Increment global version and synchronize root entry version
+    ///
+    /// CRITICAL: The C implementation uses root->version as the index version.
+    /// We must keep the root entry's version synchronized with the global version counter
+    /// to ensure C nodes can verify the index after applying updates.
+    ///
+    /// This function acquires the index lock and database connection lock internally,
+    /// so it must NOT be called while holding either lock.
+    fn increment_version(&self) -> Result<u64> {
+        let new_version = self.inner.version.fetch_add(1, Ordering::SeqCst) + 1;
+
+        // Update root entry version in memory and database
+        {
+            let mut index = self.inner.index.lock();
+            if let Some(root_entry) = index.get_mut(&self.inner.root_inode) {
+                root_entry.version = new_version;
+            }
+            drop(index);  // Release lock before DB access
+        }
+
+        // Persist to database (outside index lock to avoid deadlock)
+        {
+            let conn = self.inner.conn.lock();
+            conn.execute(
+                "UPDATE tree SET version = ? WHERE inode = ?",
+                rusqlite::params![new_version as i64, self.inner.root_inode as i64],
+            )
+            .context("Failed to update root version in database")?;
+        }
+
+        Ok(new_version)
+    }
+
+    /// Get the __version__ entry for sending updates to C nodes
+    ///
+    /// The __version__ entry (inode ROOT_INODE) stores root metadata in the database
+    /// but is not kept in the in-memory index. This method queries it directly
+    /// from the database to send as an UPDATE message to C nodes.
+    pub fn get_version_entry(&self) -> anyhow::Result<TreeEntry> {
+        let index = self.inner.index.lock();
+        let root_entry = index
+            .get(&self.inner.root_inode)
+            .ok_or_else(|| anyhow::anyhow!("Root entry not found"))?;
+
+        // Create a __version__ entry matching C's format
+        // This is what C expects to receive as inode ROOT_INODE
+        Ok(TreeEntry {
+            inode: ROOT_INODE, // __version__ is always inode ROOT_INODE in database/wire format
+            parent: ROOT_INODE, // Root's parent is itself
+            version: root_entry.version,
+            writer: root_entry.writer,
+            mtime: root_entry.mtime,
+            size: 0,
+            entry_type: DT_REG,
+            name: VERSION_FILENAME.to_string(),
+            data: Vec::new(),
+        })
+    }
+
+    pub fn lookup_path(&self, path: &str) -> Option<TreeEntry> {
+        let index = self.inner.index.lock();
+        let tree = self.inner.tree.lock();
+
+        if path.is_empty() || path == "/" || path == "." {
+            return index.get(&self.inner.root_inode).cloned();
+        }
+
+        let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+        let mut current_inode = self.inner.root_inode;
+
+        for part in parts {
+            let children = tree.get(&current_inode)?;
+            current_inode = *children.get(part)?;
+        }
+
+        index.get(&current_inode).cloned()
+    }
+
+    /// Split a path into parent directory and basename
+    ///
+    /// Paths should be absolute (starting with `/`). While the implementation
+    /// handles relative paths for C compatibility, all new code should use absolute paths.
+    fn split_path(path: &str) -> (String, String) {
+        debug_assert!(
+            path.starts_with('/') || path.is_empty(),
+            "Path should be absolute (start with /), got: {path}"
+        );
+
+        let path = path.trim_end_matches('/');
+
+        if let Some(pos) = path.rfind('/') {
+            let dirname = if pos == 0 { "/" } else { &path[..pos] };
+            let basename = &path[pos + 1..];
+            (dirname.to_string(), basename.to_string())
+        } else {
+            ("/".to_string(), path.to_string())
+        }
+    }
+
+    pub fn exists(&self, path: &str) -> Result<bool> {
+        Ok(self.lookup_path(path).is_some())
+    }
+
+    pub fn read(&self, path: &str, offset: u64, size: usize) -> Result<Vec<u8>> {
+        let entry = self
+            .lookup_path(path)
+            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
+
+        if entry.is_dir() {
+            return Err(anyhow::anyhow!("Cannot read directory: {path}"));
+        }
+
+        let offset = offset as usize;
+        if offset >= entry.data.len() {
+            return Ok(Vec::new());
+        }
+
+        let end = std::cmp::min(offset + size, entry.data.len());
+        Ok(entry.data[offset..end].to_vec())
+    }
+
+    /// Helper to update __version__ entry in database
+    ///
+    /// This is called for EVERY write operation to keep root metadata synchronized
+    /// (matching C behavior in database.c:275-278)
+    fn update_version_entry(
+        conn: &rusqlite::Connection,
+        version: u64,
+        writer: u32,
+        mtime: u32,
+    ) -> Result<()> {
+        conn.execute(
+            "UPDATE tree SET version = ?1, writer = ?2, mtime = ?3 WHERE inode = ?4",
+            params![version, writer, mtime, ROOT_INODE],
+        )?;
+        Ok(())
+    }
+
+    /// Helper to update root entry in index
+    ///
+    /// Keeps the in-memory root entry synchronized with database __version__
+    fn update_root_metadata(
+        index: &mut HashMap<u64, TreeEntry>,
+        root_inode: u64,
+        version: u64,
+        writer: u32,
+        mtime: u32,
+    ) {
+        if let Some(root_entry) = index.get_mut(&root_inode) {
+            root_entry.version = version;
+            root_entry.writer = writer;
+            root_entry.mtime = mtime;
+        }
+    }
+
+    pub fn create(&self, path: &str, mode: u32, mtime: u32) -> Result<()> {
+        if self.exists(path)? {
+            return Err(anyhow::anyhow!("File already exists: {path}"));
+        }
+
+        let (parent_path, basename) = Self::split_path(path);
+
+        let parent_entry = self
+            .lookup_path(&parent_path)
+            .ok_or_else(|| anyhow::anyhow!("Parent directory not found: {parent_path}"))?;
+
+        if !parent_entry.is_dir() {
+            return Err(anyhow::anyhow!("Parent is not a directory: {parent_path}"));
+        }
+
+        let entry_type = if mode & libc::S_IFDIR != 0 {
+            DT_DIR
+        } else {
+            DT_REG
+        };
+
+        // CRITICAL: Increment version FIRST, then assign inode = version
+        // This matches C's behavior: te->inode = memdb->root->version
+        // (see src/pmxcfs/memdb.c:760)
+        let version = self.increment_version()?;
+        let new_inode = version;  // Inode equals version number (C compatibility)
+
+        let entry = TreeEntry {
+            inode: new_inode,
+            parent: parent_entry.inode,
+            version,
+            writer: 0, // Local operations always use writer 0 (matching C)
+            mtime,
+            size: 0,
+            entry_type,
+            name: basename.clone(),
+            data: Vec::new(),
+        };
+
+        {
+            let conn = self.inner.conn.lock();
+            let tx = conn.unchecked_transaction()?;
+
+            tx.execute(
+                "INSERT INTO tree (inode, parent, version, writer, mtime, type, name, data, size) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+                params![
+                    entry.inode,
+                    entry.parent,
+                    entry.version,
+                    entry.writer,
+                    entry.mtime,
+                    entry.entry_type,
+                    entry.name,
+                    if entry.is_dir() { None::<Vec<u8>> } else { Some(entry.data.clone()) },
+                    entry.size
+                ],
+            )?;
+
+            // CRITICAL: Update __version__ entry (matching C in database.c:275-278)
+            Self::update_version_entry(&tx, entry.version, entry.writer, entry.mtime)?;
+
+            tx.commit()?;
+        }
+
+        {
+            let mut index = self.inner.index.lock();
+            let mut tree = self.inner.tree.lock();
+
+            index.insert(new_inode, entry.clone());
+            Self::update_root_metadata(
+                &mut index,
+                self.inner.root_inode,
+                entry.version,
+                entry.writer,
+                entry.mtime,
+            );
+
+            tree.entry(parent_entry.inode)
+                .or_default()
+                .insert(basename, new_inode);
+
+            if entry.is_dir() {
+                tree.insert(new_inode, HashMap::new());
+            }
+        }
+
+        // If this is a directory in priv/lock/, register it in the lock table
+        if entry.is_dir() && parent_path == LOCK_DIR_PATH {
+            let csum = entry.compute_checksum();
+            let _ = self.lock_expired(path, &csum);
+            tracing::debug!("Registered lock directory: {}", path);
+        }
+
+        Ok(())
+    }
+
+    pub fn write(
+        &self,
+        path: &str,
+        offset: u64,
+        mtime: u32,
+        data: &[u8],
+        truncate: bool,
+    ) -> Result<usize> {
+        let mut entry = self
+            .lookup_path(path)
+            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
+
+        if entry.is_dir() {
+            return Err(anyhow::anyhow!("Cannot write to directory: {path}"));
+        }
+
+        // Truncate before writing if requested (matches C implementation behavior)
+        if truncate {
+            entry.data.clear();
+        }
+
+        // Check size limit
+        let new_size = std::cmp::max(entry.data.len(), (offset as usize) + data.len());
+
+        if new_size > MEMDB_MAX_FILE_SIZE {
+            return Err(anyhow::anyhow!(
+                "File size exceeds maximum: {MEMDB_MAX_FILE_SIZE}"
+            ));
+        }
+
+        // Extend if necessary
+        let offset = offset as usize;
+        if offset + data.len() > entry.data.len() {
+            entry.data.resize(offset + data.len(), 0);
+        }
+
+        // Write data
+        entry.data[offset..offset + data.len()].copy_from_slice(data);
+        entry.size = entry.data.len();
+        entry.mtime = mtime;
+        entry.writer = 0; // Local operations always use writer 0 (matching C)
+
+        // Increment version
+        let version = self.increment_version()?;
+        entry.version = version;
+
+        // Update database
+        {
+            let conn = self.inner.conn.lock();
+            let tx = conn.unchecked_transaction()?;
+
+            tx.execute(
+                "UPDATE tree SET version = ?1, writer = ?2, mtime = ?3, size = ?4, data = ?5 WHERE inode = ?6",
+                params![
+                    entry.version,
+                    entry.writer,
+                    entry.mtime,
+                    entry.size,
+                    &entry.data,
+                    entry.inode
+                ],
+            )?;
+
+            // CRITICAL: Update __version__ entry (matching C in database.c:275-278)
+            Self::update_version_entry(&tx, entry.version, entry.writer, entry.mtime)?;
+
+            tx.commit()?;
+        }
+
+        // Update in-memory index
+        {
+            let mut index = self.inner.index.lock();
+            index.insert(entry.inode, entry.clone());
+            Self::update_root_metadata(
+                &mut index,
+                self.inner.root_inode,
+                entry.version,
+                entry.writer,
+                entry.mtime,
+            );
+        }
+
+        Ok(data.len())
+    }
+
+    /// Update modification time of a file or directory
+    ///
+    /// This implements the C version's `memdb_mtime` function (memdb.c:860-932)
+    /// with full lock protection semantics for directories in `priv/lock/`.
+    ///
+    /// # Lock Protection
+    ///
+    /// For lock directories (`priv/lock/*`), this function enforces:
+    /// 1. Only the same writer (node ID) can update the lock
+    /// 2. Only newer mtime values are accepted (to prevent replay attacks)
+    /// 3. Lock cache is refreshed after successful update
+    ///
+    /// # Arguments
+    ///
+    /// * `path` - Path to the file/directory
+    /// * `writer` - Writer ID (node ID in cluster)
+    /// * `mtime` - New modification time (seconds since UNIX epoch)
+    pub fn set_mtime(&self, path: &str, writer: u32, mtime: u32) -> Result<()> {
+        let mut entry = self
+            .lookup_path(path)
+            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
+
+        // Don't allow updating root
+        if entry.inode == self.inner.root_inode {
+            return Err(anyhow::anyhow!("Cannot update root directory"));
+        }
+
+        // Check if this is a lock directory (matching C logic in memdb.c:882)
+        let (parent_path, _) = Self::split_path(path);
+        let is_lock = parent_path.trim_start_matches('/') == LOCK_DIR_PATH && entry.is_dir();
+
+        if is_lock {
+            // Lock protection: Only allow newer mtime (C: memdb.c:886-889)
+            // This prevents replay attacks and ensures lock renewal works correctly
+            if mtime < entry.mtime {
+                tracing::warn!(
+                    "Rejecting mtime update for lock '{}': {} < {} (locked)",
+                    path,
+                    mtime,
+                    entry.mtime
+                );
+                return Err(anyhow::anyhow!(
+                    "Cannot set older mtime on locked directory (dir is locked)"
+                ));
+            }
+
+            // Lock protection: Only same writer can update (C: memdb.c:890-894)
+            // This prevents lock hijacking from other nodes
+            if entry.writer != writer {
+                tracing::warn!(
+                    "Rejecting mtime update for lock '{}': writer {} != {} (wrong owner)",
+                    path,
+                    writer,
+                    entry.writer
+                );
+                return Err(anyhow::anyhow!(
+                    "Lock owned by different writer (cannot hijack lock)"
+                ));
+            }
+
+            tracing::debug!(
+                "Updating lock directory: {} (mtime: {} -> {})",
+                path,
+                entry.mtime,
+                mtime
+            );
+        }
+
+        // Increment version
+        let version = self.increment_version()?;
+
+        // Update entry
+        entry.version = version;
+        entry.writer = writer;
+        entry.mtime = mtime;
+
+        // Update database
+        {
+            let conn = self.inner.conn.lock();
+            conn.execute(
+                "UPDATE tree SET version = ?1, writer = ?2, mtime = ?3 WHERE inode = ?4",
+                params![entry.version, entry.writer, entry.mtime, entry.inode],
+            )?;
+        }
+
+        // Update in-memory index
+        {
+            let mut index = self.inner.index.lock();
+            index.insert(entry.inode, entry.clone());
+        }
+
+        // Refresh lock cache if this is a lock directory (C: memdb.c:924-929)
+        // Remove old entry and insert new one with updated checksum
+        if is_lock {
+            let mut locks = self.inner.locks.lock();
+            locks.remove(path);
+
+            let csum = entry.compute_checksum();
+            let now = SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap_or_default()
+                .as_secs();
+
+            locks.insert(path.to_string(), LockInfo { ltime: now, csum });
+
+            tracing::debug!("Refreshed lock cache for: {}", path);
+        }
+
+        Ok(())
+    }
+
+    pub fn readdir(&self, path: &str) -> Result<Vec<TreeEntry>> {
+        let entry = self
+            .lookup_path(path)
+            .ok_or_else(|| anyhow::anyhow!("Directory not found: {path}"))?;
+
+        if !entry.is_dir() {
+            return Err(anyhow::anyhow!("Not a directory: {path}"));
+        }
+
+        let tree = self.inner.tree.lock();
+        let index = self.inner.index.lock();
+
+        let children = tree
+            .get(&entry.inode)
+            .ok_or_else(|| anyhow::anyhow!("Directory structure corrupted"))?;
+
+        let mut entries = Vec::new();
+        for child_inode in children.values() {
+            if let Some(child) = index.get(child_inode) {
+                entries.push(child.clone());
+            }
+        }
+
+        Ok(entries)
+    }
+
+    pub fn delete(&self, path: &str) -> Result<()> {
+        let entry = self
+            .lookup_path(path)
+            .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
+
+        // Don't allow deleting root
+        if entry.inode == self.inner.root_inode {
+            return Err(anyhow::anyhow!("Cannot delete root directory"));
+        }
+
+        // If directory, check if empty
+        if entry.is_dir() {
+            let tree = self.inner.tree.lock();
+            if let Some(children) = tree.get(&entry.inode)
+                && !children.is_empty()
+            {
+                return Err(anyhow::anyhow!("Directory not empty: {path}"));
+            }
+        }
+
+        // Delete from database
+        {
+            let conn = self.inner.conn.lock();
+            conn.execute("DELETE FROM tree WHERE inode = ?1", params![entry.inode])?;
+        }
+
+        // Update in-memory structures
+        {
+            let mut index = self.inner.index.lock();
+            let mut tree = self.inner.tree.lock();
+
+            // Remove from index
+            index.remove(&entry.inode);
+
+            // Remove from parent's children
+            if let Some(parent_children) = tree.get_mut(&entry.parent) {
+                parent_children.remove(&entry.name);
+            }
+
+            // Remove from tree if directory
+            if entry.is_dir() {
+                tree.remove(&entry.inode);
+            }
+        }
+
+        // Clean up lock cache for directories (matching C behavior in memdb.c:1235)
+        // This prevents stale lock cache entries and memory leaks
+        if entry.is_dir() {
+            let mut locks = self.inner.locks.lock();
+            locks.remove(path);
+            tracing::debug!("Removed lock cache entry for deleted directory: {}", path);
+        }
+
+        Ok(())
+    }
+
+    pub fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
+        let mut entry = self
+            .lookup_path(old_path)
+            .ok_or_else(|| anyhow::anyhow!("Source not found: {old_path}"))?;
+
+        if entry.inode == self.inner.root_inode {
+            return Err(anyhow::anyhow!("Cannot rename root directory"));
+        }
+
+        if self.exists(new_path)? {
+            return Err(anyhow::anyhow!("Destination already exists: {new_path}"));
+        }
+
+        let (new_parent_path, new_basename) = Self::split_path(new_path);
+
+        let new_parent_entry = self
+            .lookup_path(&new_parent_path)
+            .ok_or_else(|| anyhow::anyhow!("New parent directory not found: {new_parent_path}"))?;
+
+        if !new_parent_entry.is_dir() {
+            return Err(anyhow::anyhow!(
+                "New parent is not a directory: {new_parent_path}"
+            ));
+        }
+
+        let old_parent = entry.parent;
+        let old_name = entry.name.clone();
+
+        entry.parent = new_parent_entry.inode;
+        entry.name = new_basename.clone();
+
+        let version = self.increment_version()?;
+        entry.version = version;
+
+        // Update database
+        {
+            let conn = self.inner.conn.lock();
+            let tx = conn.unchecked_transaction()?;
+
+            tx.execute(
+                "UPDATE tree SET parent = ?1, name = ?2, version = ?3 WHERE inode = ?4",
+                params![entry.parent, entry.name, entry.version, entry.inode],
+            )?;
+
+            // CRITICAL: Update __version__ entry (matching C in database.c:275-278)
+            Self::update_version_entry(&tx, entry.version, entry.writer, entry.mtime)?;
+
+            tx.commit()?;
+        }
+
+        {
+            let mut index = self.inner.index.lock();
+            let mut tree = self.inner.tree.lock();
+
+            index.insert(entry.inode, entry.clone());
+            Self::update_root_metadata(
+                &mut index,
+                self.inner.root_inode,
+                entry.version,
+                entry.writer,
+                entry.mtime,
+            );
+
+            if let Some(old_parent_children) = tree.get_mut(&old_parent) {
+                old_parent_children.remove(&old_name);
+            }
+
+            tree.entry(new_parent_entry.inode)
+                .or_default()
+                .insert(new_basename, entry.inode);
+        }
+
+        Ok(())
+    }
+
+    pub fn get_all_entries(&self) -> Result<Vec<TreeEntry>> {
+        let index = self.inner.index.lock();
+        let entries: Vec<TreeEntry> = index.values().cloned().collect();
+        Ok(entries)
+    }
+
+    pub fn get_version(&self) -> u64 {
+        self.inner.version.load(Ordering::SeqCst)
+    }
+
+    /// Replace all entries (for full state synchronization)
+    pub fn replace_all_entries(&self, entries: Vec<TreeEntry>) -> Result<()> {
+        tracing::info!(
+            "Replacing all database entries with {} new entries",
+            entries.len()
+        );
+
+        let conn = self.inner.conn.lock();
+        let tx = conn.unchecked_transaction()?;
+
+        tx.execute("DELETE FROM tree", [])?;
+
+        let max_version = entries.iter().map(|e| e.version).max().unwrap_or(0);
+
+        for entry in &entries {
+            tx.execute(
+                "INSERT INTO tree (inode, parent, version, writer, mtime, type, name, data, size) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+                params![
+                    entry.inode,
+                    entry.parent,
+                    entry.version,
+                    entry.writer,
+                    entry.mtime,
+                    entry.entry_type,
+                    entry.name,
+                    if entry.is_dir() { None::<Vec<u8>> } else { Some(entry.data.clone()) },
+                    entry.size
+                ],
+            )?;
+        }
+
+        tx.commit()?;
+        drop(conn);
+
+        let mut index = self.inner.index.lock();
+        let mut tree = self.inner.tree.lock();
+
+        index.clear();
+        tree.clear();
+
+        for entry in entries {
+            tree.entry(entry.parent)
+                .or_default()
+                .insert(entry.name.clone(), entry.inode);
+
+            if entry.is_dir() {
+                tree.entry(entry.inode).or_default();
+            }
+
+            index.insert(entry.inode, entry);
+        }
+
+        self.inner.version.store(max_version, Ordering::SeqCst);
+
+        tracing::info!(
+            "Database state replaced successfully, version now: {}",
+            max_version
+        );
+        Ok(())
+    }
+
+    /// Apply a single TreeEntry during incremental synchronization
+    ///
+    /// This is used when receiving Update messages from the leader.
+    /// It directly inserts or updates the entry in the database without
+    /// going through the path-based API.
+    pub fn apply_tree_entry(&self, entry: TreeEntry) -> Result<()> {
+        tracing::debug!(
+            "Applying TreeEntry: inode={}, parent={}, name='{}', version={}",
+            entry.inode,
+            entry.parent,
+            entry.name,
+            entry.version
+        );
+
+        // Begin transaction for atomicity
+        let conn = self.inner.conn.lock();
+        let tx = conn.unchecked_transaction()?;
+
+        // Handle root inode specially (inode 0 is __version__)
+        let db_name = if entry.inode == self.inner.root_inode {
+            VERSION_FILENAME
+        } else {
+            entry.name.as_str()
+        };
+
+        // Insert or replace the entry in database
+        tx.execute(
+            "INSERT OR REPLACE INTO tree (inode, parent, version, writer, mtime, type, name, data, size) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+            params![
+                entry.inode,
+                entry.parent,
+                entry.version,
+                entry.writer,
+                entry.mtime,
+                entry.entry_type,
+                db_name,
+                if entry.is_dir() { None::<Vec<u8>> } else { Some(entry.data.clone()) },
+                entry.size
+            ],
+        )?;
+
+        // CRITICAL: Update __version__ entry with the same metadata (matching C in database.c:275-278)
+        // Only do this if we're not already writing __version__ itself
+        if entry.inode != ROOT_INODE {
+            Self::update_version_entry(&tx, entry.version, entry.writer, entry.mtime)?;
+        }
+
+        tx.commit()?;
+        drop(conn);
+
+        // Update in-memory structures
+        let mut index = self.inner.index.lock();
+        let mut tree = self.inner.tree.lock();
+
+        // Check if this entry already exists
+        let old_entry = index.get(&entry.inode).cloned();
+
+        // If entry exists with different parent or name, update tree structure
+        if let Some(old) = old_entry {
+            if old.parent != entry.parent || old.name != entry.name {
+                // Remove from old parent's children
+                if let Some(old_parent_children) = tree.get_mut(&old.parent) {
+                    old_parent_children.remove(&old.name);
+                }
+
+                // Add to new parent's children
+                tree.entry(entry.parent)
+                    .or_default()
+                    .insert(entry.name.clone(), entry.inode);
+            }
+        } else {
+            // New entry - add to parent's children
+            tree.entry(entry.parent)
+                .or_default()
+                .insert(entry.name.clone(), entry.inode);
+        }
+
+        // If this is a directory, ensure it has an entry in the tree map
+        if entry.is_dir() {
+            tree.entry(entry.inode).or_default();
+        }
+
+        // Update index
+        index.insert(entry.inode, entry.clone());
+
+        // Update root entry's metadata to match __version__ (if we wrote a non-root entry)
+        if entry.inode != self.inner.root_inode {
+            Self::update_root_metadata(
+                &mut index,
+                self.inner.root_inode,
+                entry.version,
+                entry.writer,
+                entry.mtime,
+            );
+            tracing::debug!(
+                version = entry.version,
+                writer = entry.writer,
+                mtime = entry.mtime,
+                "Updated root entry metadata"
+            );
+        }
+
+        // Update version counter if this entry has a higher version
+        self.inner
+            .version
+            .fetch_max(entry.version, Ordering::SeqCst);
+
+        tracing::debug!("TreeEntry applied successfully");
+        Ok(())
+    }
+
+    /// **TEST ONLY**: Manually set lock timestamp for testing expiration behavior
+    ///
+    /// This method is exposed for testing purposes only to simulate lock expiration
+    /// without waiting the full 120 seconds. Do not use in production code.
+    #[cfg(test)]
+    pub fn test_set_lock_timestamp(&self, path: &str, timestamp_secs: u64) {
+        let mut locks = self.inner.locks.lock();
+        if let Some(lock_info) = locks.get_mut(path) {
+            lock_info.ltime = timestamp_secs;
+        }
+    }
+}
+
+// ============================================================================
+// Trait Implementation for Dependency Injection
+// ============================================================================
+
+impl crate::traits::MemDbOps for MemDb {
+    fn create(&self, path: &str, mode: u32, mtime: u32) -> Result<()> {
+        self.create(path, mode, mtime)
+    }
+
+    fn read(&self, path: &str, offset: u64, size: usize) -> Result<Vec<u8>> {
+        self.read(path, offset, size)
+    }
+
+    fn write(
+        &self,
+        path: &str,
+        offset: u64,
+        mtime: u32,
+        data: &[u8],
+        truncate: bool,
+    ) -> Result<usize> {
+        self.write(path, offset, mtime, data, truncate)
+    }
+
+    fn delete(&self, path: &str) -> Result<()> {
+        self.delete(path)
+    }
+
+    fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
+        self.rename(old_path, new_path)
+    }
+
+    fn exists(&self, path: &str) -> Result<bool> {
+        self.exists(path)
+    }
+
+    fn readdir(&self, path: &str) -> Result<Vec<crate::types::TreeEntry>> {
+        self.readdir(path)
+    }
+
+    fn set_mtime(&self, path: &str, writer: u32, mtime: u32) -> Result<()> {
+        self.set_mtime(path, writer, mtime)
+    }
+
+    fn lookup_path(&self, path: &str) -> Option<crate::types::TreeEntry> {
+        self.lookup_path(path)
+    }
+
+    fn get_entry_by_inode(&self, inode: u64) -> Option<crate::types::TreeEntry> {
+        self.get_entry_by_inode(inode)
+    }
+
+    fn acquire_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()> {
+        self.acquire_lock(path, csum)
+    }
+
+    fn release_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()> {
+        self.release_lock(path, csum)
+    }
+
+    fn is_locked(&self, path: &str) -> bool {
+        self.is_locked(path)
+    }
+
+    fn lock_expired(&self, path: &str, csum: &[u8; 32]) -> bool {
+        self.lock_expired(path, csum)
+    }
+
+    fn get_version(&self) -> u64 {
+        self.get_version()
+    }
+
+    fn get_all_entries(&self) -> Result<Vec<crate::types::TreeEntry>> {
+        self.get_all_entries()
+    }
+
+    fn replace_all_entries(&self, entries: Vec<crate::types::TreeEntry>) -> Result<()> {
+        self.replace_all_entries(entries)
+    }
+
+    fn apply_tree_entry(&self, entry: crate::types::TreeEntry) -> Result<()> {
+        self.apply_tree_entry(entry)
+    }
+
+    fn encode_database(&self) -> Result<Vec<u8>> {
+        self.encode_database()
+    }
+
+    fn compute_database_checksum(&self) -> Result<[u8; 32]> {
+        self.compute_database_checksum()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    //! Unit tests for MemDb database operations
+    //!
+    //! This test module provides comprehensive coverage for:
+    //! - Basic CRUD operations (create, read, write, delete, rename)
+    //! - Lock management (acquisition, release, expiration, contention)
+    //! - Checksum operations
+    //! - Persistence verification
+    //! - Error handling and edge cases
+    //! - Security (path traversal, type mismatches)
+    //!
+    //! ## Test Organization
+    //!
+    //! Tests are organized into several categories:
+    //! - **Basic Operations**: File and directory CRUD
+    //! - **Lock Management**: Lock lifecycle, expiration, renewal
+    //! - **Error Handling**: Path validation, type checking, duplicates
+    //! - **Edge Cases**: Empty paths, sparse files, boundary conditions
+    //!
+    //! ## Lock Expiration Testing
+    //!
+    //! Lock timeout is 120 seconds. Tests use `test_set_lock_timestamp()` helper
+    //! to simulate time passage without waiting 120 actual seconds.
+
+    use super::*;
+    use std::thread::sleep;
+    use std::time::{Duration, SystemTime, UNIX_EPOCH};
+    use tempfile::TempDir;
+
+    #[test]
+    fn test_lock_expiration() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+        let path = "/priv/lock/test-resource";
+        let csum = [42u8; 32];
+
+        // Create lock directory structure
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Acquire lock
+        db.acquire_lock(path, &csum)?;
+        assert!(db.is_locked(path), "Lock should be active");
+        assert!(
+            !db.lock_expired(path, &csum),
+            "Lock should not be expired initially"
+        );
+
+        // Wait a short time (should still not be expired)
+        sleep(Duration::from_secs(2));
+        assert!(
+            db.is_locked(path),
+            "Lock should still be active after 2 seconds"
+        );
+        assert!(
+            !db.lock_expired(path, &csum),
+            "Lock should not be expired after 2 seconds"
+        );
+
+        // Manually set lock timestamp to simulate expiration (testing internal behavior)
+        // Note: In C implementation, LOCK_TIMEOUT is 120 seconds (memdb.h:27)
+        // Set ltime to 121 seconds ago (past LOCK_TIMEOUT of 120 seconds)
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        db.test_set_lock_timestamp(path, now_secs - 121);
+
+        // Now the lock should be expired
+        assert!(
+            db.lock_expired(path, &csum),
+            "Lock should be expired after 121 seconds"
+        );
+
+        // is_locked() should also return false for expired locks
+        assert!(
+            !db.is_locked(path),
+            "is_locked() should return false for expired locks"
+        );
+
+        // Test checksum mismatch resets timeout
+        let different_csum = [99u8; 32];
+        assert!(
+            !db.lock_expired(path, &different_csum),
+            "lock_expired() with different checksum should reset timeout and return false"
+        );
+
+        // After checksum mismatch, lock should be active again (with new checksum)
+        assert!(
+            db.is_locked(path),
+            "Lock should be active after checksum reset"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_memdb_file_size_limit() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        // Create database
+        let db = MemDb::open(&db_path, true)?;
+
+        // Create a file
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        db.create("/test.bin", libc::S_IFREG, now)?;
+
+        // Try to write exactly 1MB (should succeed)
+        let data_1mb = vec![0u8; 1024 * 1024];
+        let result = db.write("/test.bin", 0, now, &data_1mb, false);
+        assert!(result.is_ok(), "1MB file should be accepted");
+
+        // Try to write 1MB + 1 byte (should fail)
+        let data_too_large = vec![0u8; 1024 * 1024 + 1];
+        db.create("/test2.bin", libc::S_IFREG, now)?;
+        let result = db.write("/test2.bin", 0, now, &data_too_large, false);
+        assert!(result.is_err(), "File larger than 1MB should be rejected");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_memdb_basic_operations() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        // Create database
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Test directory creation
+        db.create("/testdir", libc::S_IFDIR, now)?;
+        assert!(db.exists("/testdir")?, "Directory should exist");
+
+        // Test file creation
+        db.create("/testdir/file.txt", libc::S_IFREG, now)?;
+        assert!(db.exists("/testdir/file.txt")?, "File should exist");
+
+        // Test write
+        let data = b"Hello, pmxcfs!";
+        db.write("/testdir/file.txt", 0, now, data, false)?;
+
+        // Test read
+        let read_data = db.read("/testdir/file.txt", 0, 1024)?;
+        assert_eq!(&read_data[..], data, "Read data should match written data");
+
+        // Test readdir
+        let entries = db.readdir("/testdir")?;
+        assert_eq!(entries.len(), 1, "Directory should have 1 entry");
+        assert_eq!(entries[0].name, "file.txt");
+
+        // Test rename
+        db.rename("/testdir/file.txt", "/testdir/renamed.txt")?;
+        assert!(
+            !db.exists("/testdir/file.txt")?,
+            "Old path should not exist"
+        );
+        assert!(db.exists("/testdir/renamed.txt")?, "New path should exist");
+
+        // Test delete
+        db.delete("/testdir/renamed.txt")?;
+        assert!(
+            !db.exists("/testdir/renamed.txt")?,
+            "Deleted file should not exist"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_management() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create parent directory and resource
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        let path = "/priv/lock/resource";
+        let csum1 = [1u8; 32];
+        let csum2 = [2u8; 32];
+
+        // Create the lock file
+        db.create(path, libc::S_IFREG, now)?;
+
+        // Test lock acquisition
+        assert!(!db.is_locked(path), "Path should not be locked initially");
+
+        db.acquire_lock(path, &csum1)?;
+        assert!(
+            db.is_locked(path),
+            "Path should be locked after acquisition"
+        );
+
+        // Test lock contention
+        let result = db.acquire_lock(path, &csum2);
+        assert!(result.is_err(), "Lock with different checksum should fail");
+
+        // Test lock refresh (same checksum)
+        let result = db.acquire_lock(path, &csum1);
+        assert!(
+            result.is_ok(),
+            "Lock refresh with same checksum should succeed"
+        );
+
+        // Test lock release
+        db.release_lock(path, &csum1)?;
+        assert!(
+            !db.is_locked(path),
+            "Path should not be locked after release"
+        );
+
+        // Test release non-existent lock
+        let result = db.release_lock(path, &csum1);
+        assert!(result.is_err(), "Releasing non-existent lock should fail");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_checksum_operations() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create some test data
+        db.create("/file1.txt", libc::S_IFREG, now)?;
+        db.write("/file1.txt", 0, now, b"test data 1", false)?;
+
+        db.create("/file2.txt", libc::S_IFREG, now)?;
+        db.write("/file2.txt", 0, now, b"test data 2", false)?;
+
+        // Test database encoding
+        let encoded = db.encode_database()?;
+        assert!(!encoded.is_empty(), "Encoded database should not be empty");
+
+        // Test database checksum
+        let checksum1 = db.compute_database_checksum()?;
+        assert_ne!(checksum1, [0u8; 32], "Checksum should not be all zeros");
+
+        // Compute checksum again - should be the same
+        let checksum2 = db.compute_database_checksum()?;
+        assert_eq!(checksum1, checksum2, "Checksum should be deterministic");
+
+        // Modify database and verify checksum changes
+        db.write("/file1.txt", 0, now, b"modified data", false)?;
+        let checksum3 = db.compute_database_checksum()?;
+        assert_ne!(
+            checksum1, checksum3,
+            "Checksum should change after modification"
+        );
+
+        // Test entry checksum
+        if let Some(entry) = db.lookup_path("/file1.txt") {
+            let entry_csum = entry.compute_checksum();
+            assert_ne!(
+                entry_csum, [0u8; 32],
+                "Entry checksum should not be all zeros"
+            );
+        } else {
+            panic!("File should exist");
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_cache_cleanup_on_delete() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create a lock directory
+        db.create("/priv/lock/testlock", libc::S_IFDIR, now)?;
+
+        // Verify lock directory exists
+        assert!(db.exists("/priv/lock/testlock")?);
+
+        // Delete the lock directory
+        db.delete("/priv/lock/testlock")?;
+
+        // Verify lock directory is deleted
+        assert!(!db.exists("/priv/lock/testlock")?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_protection_same_writer() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create a lock directory
+        db.create("/priv/lock/mylock", libc::S_IFDIR, now)?;
+
+        // Get the actual writer ID from the created lock
+        let entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        let writer_id = entry.writer;
+
+        // Same writer (node 1) should be able to update mtime
+        let new_mtime = now + 10;
+        let result = db.set_mtime("/priv/lock/mylock", writer_id, new_mtime);
+        assert!(
+            result.is_ok(),
+            "Same writer should be able to update lock mtime"
+        );
+
+        // Verify mtime was updated
+        let updated_entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        assert_eq!(updated_entry.mtime, new_mtime);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_protection_different_writer() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create a lock directory
+        db.create("/priv/lock/mylock", libc::S_IFDIR, now)?;
+
+        // Get the current writer ID
+        let entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        let original_writer = entry.writer;
+
+        // Try to update from different writer (simulating another node trying to steal lock)
+        let different_writer = original_writer + 1;
+        let new_mtime = now + 10;
+        let result = db.set_mtime("/priv/lock/mylock", different_writer, new_mtime);
+
+        // Should fail - cannot hijack lock from different writer
+        assert!(
+            result.is_err(),
+            "Different writer should NOT be able to hijack lock"
+        );
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("Lock owned by different writer"),
+            "Error should indicate lock ownership conflict"
+        );
+
+        // Verify mtime was NOT updated
+        let unchanged_entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        assert_eq!(unchanged_entry.mtime, now, "Mtime should not have changed");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_protection_older_mtime() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create a lock directory
+        db.create("/priv/lock/mylock", libc::S_IFDIR, now)?;
+
+        let entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        let writer_id = entry.writer;
+
+        // Try to set an older mtime (replay attack simulation)
+        let older_mtime = now - 10;
+        let result = db.set_mtime("/priv/lock/mylock", writer_id, older_mtime);
+
+        // Should fail - cannot set older mtime
+        assert!(result.is_err(), "Cannot set older mtime on lock");
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("Cannot set older mtime"),
+            "Error should indicate mtime protection"
+        );
+
+        // Verify mtime was NOT changed
+        let unchanged_entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        assert_eq!(unchanged_entry.mtime, now, "Mtime should not have changed");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_protection_newer_mtime() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create a lock directory
+        db.create("/priv/lock/mylock", libc::S_IFDIR, now)?;
+
+        let entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        let writer_id = entry.writer;
+
+        // Set a newer mtime (normal lock refresh)
+        let newer_mtime = now + 60;
+        let result = db.set_mtime("/priv/lock/mylock", writer_id, newer_mtime);
+
+        // Should succeed
+        assert!(result.is_ok(), "Should be able to set newer mtime on lock");
+
+        // Verify mtime was updated
+        let updated_entry = db.lookup_path("/priv/lock/mylock").unwrap();
+        assert_eq!(updated_entry.mtime, newer_mtime, "Mtime should be updated");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_regular_file_mtime_update() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create a regular file
+        db.create("/testfile.txt", 0, now)?;
+
+        let entry = db.lookup_path("/testfile.txt").unwrap();
+        let writer_id = entry.writer;
+
+        // Should be able to set both older and newer mtime on regular files
+        let older_mtime = now - 10;
+        let result = db.set_mtime("/testfile.txt", writer_id, older_mtime);
+        assert!(result.is_ok(), "Regular files should allow older mtime");
+
+        let newer_mtime = now + 10;
+        let result = db.set_mtime("/testfile.txt", writer_id, newer_mtime);
+        assert!(result.is_ok(), "Regular files should allow newer mtime");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_lifecycle_with_cache() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Setup: Create priv/lock directory
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Step 1: Create lock
+        db.create("/priv/lock/lifecycle_lock", libc::S_IFDIR, now)?;
+        assert!(db.exists("/priv/lock/lifecycle_lock")?);
+
+        let entry = db.lookup_path("/priv/lock/lifecycle_lock").unwrap();
+        let writer_id = entry.writer;
+
+        // Step 2: Refresh lock multiple times (simulate lock renewals)
+        for i in 1..=5 {
+            let refresh_mtime = now + (i * 30); // Refresh every 30 seconds
+            let result = db.set_mtime("/priv/lock/lifecycle_lock", writer_id, refresh_mtime);
+            assert!(result.is_ok(), "Lock refresh #{i} should succeed");
+
+            // Verify mtime was updated
+            let refreshed_entry = db.lookup_path("/priv/lock/lifecycle_lock").unwrap();
+            assert_eq!(refreshed_entry.mtime, refresh_mtime);
+        }
+
+        // Step 3: Delete lock (release)
+        db.delete("/priv/lock/lifecycle_lock")?;
+        assert!(!db.exists("/priv/lock/lifecycle_lock")?);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_renewal_before_expiration() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+        let path = "/priv/lock/renewal-test";
+        let csum = [55u8; 32];
+
+        // Create lock directory structure
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Acquire initial lock
+        db.acquire_lock(path, &csum)?;
+        assert!(db.is_locked(path), "Lock should be active");
+
+        // Simulate time passing (119 seconds - just before expiration)
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        db.test_set_lock_timestamp(path, now_secs - 119);
+
+        // Lock should still be valid (not yet expired)
+        assert!(
+            !db.lock_expired(path, &csum),
+            "Lock should not be expired at 119 seconds"
+        );
+        assert!(
+            db.is_locked(path),
+            "is_locked() should return true before expiration"
+        );
+
+        // Renew the lock by acquiring again with same checksum
+        db.acquire_lock(path, &csum)?;
+
+        // After renewal, lock should definitely not be expired
+        assert!(
+            !db.lock_expired(path, &csum),
+            "Lock should not be expired after renewal"
+        );
+        assert!(
+            db.is_locked(path),
+            "Lock should still be active after renewal"
+        );
+
+        // Now simulate expiration time (121 seconds from renewal)
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        db.test_set_lock_timestamp(path, now_secs - 121);
+
+        // Lock should now be expired
+        assert!(
+            db.lock_expired(path, &csum),
+            "Lock should be expired after 121 seconds without renewal"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_acquire_lock_after_expiration() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+        let path = "/priv/lock/reacquire-test";
+        let csum1 = [11u8; 32];
+        let csum2 = [22u8; 32];
+
+        // Create lock directory structure
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Acquire initial lock with csum1
+        db.acquire_lock(path, &csum1)?;
+        assert!(db.is_locked(path), "Lock should be active");
+
+        // Simulate lock expiration (121 seconds)
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        db.test_set_lock_timestamp(path, now_secs - 121);
+
+        // Verify lock is expired
+        assert!(db.lock_expired(path, &csum1), "Lock should be expired");
+        assert!(
+            !db.is_locked(path),
+            "is_locked() should return false for expired lock"
+        );
+
+        // A different process should be able to acquire the expired lock
+        let result = db.acquire_lock(path, &csum2);
+        assert!(
+            result.is_ok(),
+            "Should be able to acquire expired lock with different checksum"
+        );
+
+        // Lock should now be active with new checksum
+        assert!(
+            db.is_locked(path),
+            "Lock should be active with new checksum"
+        );
+        assert!(
+            !db.lock_expired(path, &csum2),
+            "New lock should not be expired"
+        );
+
+        // Old checksum should fail to check expiration (checksum mismatch)
+        assert!(
+            !db.lock_expired(path, &csum1),
+            "lock_expired() with old checksum should reset timeout and return false"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_multiple_locks_expiring() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create lock directory structure
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Create three locks
+        let locks = [
+            ("/priv/lock/lock1", [1u8; 32]),
+            ("/priv/lock/lock2", [2u8; 32]),
+            ("/priv/lock/lock3", [3u8; 32]),
+        ];
+
+        // Acquire all locks
+        for (path, csum) in &locks {
+            db.acquire_lock(path, csum)?;
+            assert!(db.is_locked(path), "Lock {path} should be active");
+        }
+
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+
+        // Set different expiration times
+        // lock1: 121 seconds ago (expired)
+        // lock2: 119 seconds ago (not expired)
+        // lock3: 121 seconds ago (expired)
+        db.test_set_lock_timestamp(locks[0].0, now_secs - 121);
+        db.test_set_lock_timestamp(locks[1].0, now_secs - 119);
+        db.test_set_lock_timestamp(locks[2].0, now_secs - 121);
+
+        // Check expiration states
+        assert!(
+            db.lock_expired(locks[0].0, &locks[0].1),
+            "lock1 should be expired"
+        );
+        assert!(
+            !db.lock_expired(locks[1].0, &locks[1].1),
+            "lock2 should not be expired"
+        );
+        assert!(
+            db.lock_expired(locks[2].0, &locks[2].1),
+            "lock3 should be expired"
+        );
+
+        // Check is_locked states
+        assert!(
+            !db.is_locked(locks[0].0),
+            "lock1 is_locked should return false"
+        );
+        assert!(
+            db.is_locked(locks[1].0),
+            "lock2 is_locked should return true"
+        );
+        assert!(
+            !db.is_locked(locks[2].0),
+            "lock3 is_locked should return false"
+        );
+
+        // Re-acquire expired locks with different checksums
+        let new_csum1 = [11u8; 32];
+        let new_csum3 = [33u8; 32];
+
+        assert!(
+            db.acquire_lock(locks[0].0, &new_csum1).is_ok(),
+            "Should be able to re-acquire expired lock1"
+        );
+        assert!(
+            db.acquire_lock(locks[2].0, &new_csum3).is_ok(),
+            "Should be able to re-acquire expired lock3"
+        );
+
+        // Verify all locks are now active
+        assert!(db.is_locked(locks[0].0), "lock1 should be active again");
+        assert!(db.is_locked(locks[1].0), "lock2 should still be active");
+        assert!(db.is_locked(locks[2].0), "lock3 should be active again");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_lock_expiration_boundary() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+        let path = "/priv/lock/boundary-test";
+        let csum = [77u8; 32];
+
+        // Create lock directory structure
+        db.create("/priv", libc::S_IFDIR, now)?;
+        db.create("/priv/lock", libc::S_IFDIR, now)?;
+
+        // Acquire lock
+        db.acquire_lock(path, &csum)?;
+
+        let now_secs = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+
+        // Test exact boundary: 120 seconds (LOCK_TIMEOUT)
+        db.test_set_lock_timestamp(path, now_secs - 120);
+        assert!(
+            !db.lock_expired(path, &csum),
+            "Lock should NOT be expired at exactly 120 seconds (boundary)"
+        );
+        assert!(
+            db.is_locked(path),
+            "Lock should still be considered active at 120 seconds"
+        );
+
+        // Test 121 seconds (just past timeout)
+        db.test_set_lock_timestamp(path, now_secs - 121);
+        assert!(
+            db.lock_expired(path, &csum),
+            "Lock SHOULD be expired at 121 seconds"
+        );
+        assert!(
+            !db.is_locked(path),
+            "Lock should not be considered active at 121 seconds"
+        );
+
+        Ok(())
+    }
+
+    // ===== Error Handling Tests =====
+
+    #[test]
+    fn test_invalid_path_traversal() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Test path traversal attempts
+        let invalid_paths = vec![
+            "/../etc/passwd",            // Absolute path traversal
+            "/test/../../../etc/passwd", // Multiple parent references
+            "//etc//passwd",             // Double slashes
+            "/test/./file",              // Current directory reference
+        ];
+
+        for invalid_path in invalid_paths {
+            // Attempt to create with invalid path
+            let result = db.create(invalid_path, libc::S_IFREG, now);
+            // Note: Current implementation may not reject all these - this documents behavior
+            // In production, path validation should be added
+            if let Err(e) = result {
+                assert!(
+                    e.to_string().contains("Invalid") || e.to_string().contains("not found"),
+                    "Invalid path '{invalid_path}' should produce appropriate error: {e}"
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_operations_on_nonexistent_paths() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Try to read non-existent file
+        let result = db.read("/nonexistent.txt", 0, 100);
+        assert!(result.is_err(), "Reading non-existent file should fail");
+
+        // Try to write to non-existent file
+        let result = db.write("/nonexistent.txt", 0, now, b"data", false);
+        assert!(result.is_err(), "Writing to non-existent file should fail");
+
+        // Try to delete non-existent file
+        let result = db.delete("/nonexistent.txt");
+        assert!(result.is_err(), "Deleting non-existent file should fail");
+
+        // Try to rename non-existent file
+        let result = db.rename("/nonexistent.txt", "/new.txt");
+        assert!(result.is_err(), "Renaming non-existent file should fail");
+
+        // Try to check if non-existent file is locked
+        assert!(
+            !db.is_locked("/nonexistent.txt"),
+            "Non-existent file should not be locked"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_file_type_mismatches() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create a directory
+        db.create("/testdir", libc::S_IFDIR, now)?;
+
+        // Try to write to a directory (should fail)
+        let result = db.write("/testdir", 0, now, b"data", false);
+        assert!(result.is_err(), "Writing to a directory should fail");
+
+        // Try to read from a directory (readdir should work, but read should fail)
+        let result = db.read("/testdir", 0, 100);
+        assert!(result.is_err(), "Reading from a directory should fail");
+
+        // Create a file
+        db.create("/testfile.txt", libc::S_IFREG, now)?;
+
+        // Try to readdir on a file (should fail)
+        let result = db.readdir("/testfile.txt");
+        assert!(result.is_err(), "Readdir on a file should fail");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_duplicate_creation() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create a file
+        db.create("/duplicate.txt", libc::S_IFREG, now)?;
+
+        // Try to create the same file again
+        let result = db.create("/duplicate.txt", libc::S_IFREG, now);
+        assert!(result.is_err(), "Creating duplicate file should fail");
+
+        // Create a directory
+        db.create("/dupdir", libc::S_IFDIR, now)?;
+
+        // Try to create the same directory again
+        let result = db.create("/dupdir", libc::S_IFDIR, now);
+        assert!(result.is_err(), "Creating duplicate directory should fail");
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_rename_target_exists() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create source and target files
+        db.create("/source.txt", libc::S_IFREG, now)?;
+        db.write("/source.txt", 0, now, b"source data", false)?;
+
+        db.create("/target.txt", libc::S_IFREG, now)?;
+        db.write("/target.txt", 0, now, b"target data", false)?;
+
+        // Try to rename source to existing target (should fail)
+        let result = db.rename("/source.txt", "/target.txt");
+        assert!(result.is_err(), "Renaming to existing target should fail");
+        assert!(
+            result.unwrap_err().to_string().contains("already exists"),
+            "Error should indicate target already exists"
+        );
+
+        // Source should still exist
+        assert!(
+            db.exists("/source.txt")?,
+            "Source should still exist after failed rename"
+        );
+
+        // Target should still exist with original data
+        assert!(db.exists("/target.txt")?, "Target should still exist");
+        let data = db.read("/target.txt", 0, 100)?;
+        assert_eq!(
+            &data[..],
+            b"target data",
+            "Target should have original data"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_delete_nonempty_directory() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create a directory with a file
+        db.create("/parent", libc::S_IFDIR, now)?;
+        db.create("/parent/child.txt", libc::S_IFREG, now)?;
+
+        // Try to delete non-empty directory
+        let result = db.delete("/parent");
+        // Note: Current behavior may vary - document expected behavior
+        if let Err(e) = result {
+            assert!(
+                e.to_string().contains("not empty") || e.to_string().contains("ENOTEMPTY"),
+                "Deleting non-empty directory should produce appropriate error: {e}"
+            );
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_write_offset_beyond_file_size() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create a file with some data
+        db.create("/offset-test.txt", libc::S_IFREG, now)?;
+        db.write("/offset-test.txt", 0, now, b"hello", false)?;
+
+        // Write at offset beyond current file size (sparse file)
+        let result = db.write("/offset-test.txt", 100, now, b"world", false);
+
+        // Check if sparse writes are supported
+        if result.is_ok() {
+            let data = db.read("/offset-test.txt", 0, 200)?;
+            // Should have zeros between offset 5 and 100
+            assert_eq!(&data[0..5], b"hello", "Initial data should be preserved");
+            assert_eq!(
+                &data[100..105],
+                b"world",
+                "Data at offset should be written"
+            );
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_empty_path_handling() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+        let db = MemDb::open(&db_path, true)?;
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Test empty path for create (should be rejected)
+        let result = db.create("", libc::S_IFREG, now);
+        assert!(result.is_err(), "Empty path should be rejected for create");
+
+        // Note: exists("") behavior is implementation-specific (may return true for root)
+        // so we don't test it here
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_database_persistence() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create database and write data
+        {
+            let db = MemDb::open(&db_path, true)?;
+            db.create("/persistent.txt", libc::S_IFREG, now)?;
+            db.write("/persistent.txt", 0, now, b"persistent data", false)?;
+        }
+
+        // Reopen database and verify data persists
+        {
+            let db = MemDb::open(&db_path, false)?;
+            assert!(
+                db.exists("/persistent.txt")?,
+                "File should persist across reopens"
+            );
+
+            let data = db.read("/persistent.txt", 0, 1024)?;
+            assert_eq!(&data[..], b"persistent data", "Data should persist");
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_persistence_with_multiple_files() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create database with multiple files
+        {
+            let db = MemDb::open(&db_path, true)?;
+
+            // Create directory
+            db.create("/config", libc::S_IFDIR, now)?;
+
+            // Create files in root
+            db.create("/file1.txt", libc::S_IFREG, now)?;
+            db.write("/file1.txt", 0, now, b"content 1", false)?;
+
+            // Create files in directory
+            db.create("/config/file2.txt", libc::S_IFREG, now)?;
+            db.write("/config/file2.txt", 0, now, b"content 2", false)?;
+        }
+
+        // Reopen and verify all data persists
+        {
+            let db = MemDb::open(&db_path, false)?;
+
+            assert!(db.exists("/config")?, "Directory should persist");
+            assert!(db.exists("/file1.txt")?, "File 1 should persist");
+            assert!(db.exists("/config/file2.txt")?, "File 2 should persist");
+
+            let data1 = db.read("/file1.txt", 0, 1024)?;
+            assert_eq!(&data1[..], b"content 1", "File 1 content should persist");
+
+            let data2 = db.read("/config/file2.txt", 0, 1024)?;
+            assert_eq!(&data2[..], b"content 2", "File 2 content should persist");
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_persistence_after_updates() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let db_path = temp_dir.path().join("test.db");
+
+        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+
+        // Create database and write initial data
+        {
+            let db = MemDb::open(&db_path, true)?;
+            db.create("/mutable.txt", libc::S_IFREG, now)?;
+            db.write("/mutable.txt", 0, now, b"initial", false)?;
+        }
+
+        // Reopen and update data
+        {
+            let db = MemDb::open(&db_path, false)?;
+            db.write("/mutable.txt", 0, now + 1, b"updated", false)?;
+        }
+
+        // Reopen again and verify updated data persists
+        {
+            let db = MemDb::open(&db_path, false)?;
+            let data = db.read("/mutable.txt", 0, 1024)?;
+            assert_eq!(&data[..], b"updated", "Updated data should persist");
+        }
+
+        Ok(())
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/index.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/index.rs
new file mode 100644
index 00000000..5bf9c102
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/index.rs
@@ -0,0 +1,814 @@
+/// MemDB Index structures for C-compatible state synchronization
+///
+/// This module implements the memdb_index_t format used by the C implementation
+/// for efficient state comparison during cluster synchronization.
+use anyhow::Result;
+use sha2::{Digest, Sha256};
+
+/// Index entry matching C's memdb_index_extry_t
+///
+/// Wire format (40 bytes):
+/// ```c
+/// typedef struct {
+///     guint64 inode;      // 8 bytes
+///     char digest[32];    // 32 bytes (SHA256)
+/// } memdb_index_extry_t;
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IndexEntry {
+    pub inode: u64,
+    pub digest: [u8; 32],
+}
+
+impl IndexEntry {
+    pub fn serialize(&self) -> Vec<u8> {
+        let mut data = Vec::with_capacity(40);
+        data.extend_from_slice(&self.inode.to_le_bytes());
+        data.extend_from_slice(&self.digest);
+        data
+    }
+
+    pub fn deserialize(data: &[u8]) -> Result<Self> {
+        if data.len() < 40 {
+            anyhow::bail!("IndexEntry too short: {} bytes (need 40)", data.len());
+        }
+
+        let inode = u64::from_le_bytes(data[0..8].try_into().unwrap());
+        let mut digest = [0u8; 32];
+        digest.copy_from_slice(&data[8..40]);
+
+        Ok(Self { inode, digest })
+    }
+}
+
+/// MemDB index matching C's memdb_index_t
+///
+/// Wire format header (24 bytes) + entries:
+/// ```c
+/// typedef struct {
+///     guint64 version;        // 8 bytes
+///     guint64 last_inode;     // 8 bytes
+///     guint32 writer;         // 4 bytes
+///     guint32 mtime;          // 4 bytes
+///     guint32 size;           // 4 bytes (number of entries)
+///     guint32 bytes;          // 4 bytes (total bytes allocated)
+///     memdb_index_extry_t entries[];  // variable length
+/// } memdb_index_t;
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MemDbIndex {
+    pub version: u64,
+    pub last_inode: u64,
+    pub writer: u32,
+    pub mtime: u32,
+    pub size: u32,  // number of entries
+    pub bytes: u32, // total bytes (24 + size * 40)
+    pub entries: Vec<IndexEntry>,
+}
+
+impl MemDbIndex {
+    /// Create a new index from entries
+    ///
+    /// Entries are automatically sorted by inode for efficient comparison
+    /// and to match C implementation behavior.
+    pub fn new(
+        version: u64,
+        last_inode: u64,
+        writer: u32,
+        mtime: u32,
+        mut entries: Vec<IndexEntry>,
+    ) -> Self {
+        // Sort entries by inode (matching C implementation)
+        entries.sort_by_key(|e| e.inode);
+
+        let size = entries.len() as u32;
+        let bytes = 32 + size * 40; // header (32) + entries
+
+        Self {
+            version,
+            last_inode,
+            writer,
+            mtime,
+            size,
+            bytes,
+            entries,
+        }
+    }
+
+    /// Serialize to C-compatible wire format
+    pub fn serialize(&self) -> Vec<u8> {
+        let mut data = Vec::with_capacity(self.bytes as usize);
+
+        // Header (32 bytes)
+        data.extend_from_slice(&self.version.to_le_bytes());
+        data.extend_from_slice(&self.last_inode.to_le_bytes());
+        data.extend_from_slice(&self.writer.to_le_bytes());
+        data.extend_from_slice(&self.mtime.to_le_bytes());
+        data.extend_from_slice(&self.size.to_le_bytes());
+        data.extend_from_slice(&self.bytes.to_le_bytes());
+
+        // Entries (40 bytes each)
+        for entry in &self.entries {
+            data.extend_from_slice(&entry.serialize());
+        }
+
+        data
+    }
+
+    /// Deserialize from C-compatible wire format
+    pub fn deserialize(data: &[u8]) -> Result<Self> {
+        if data.len() < 32 {
+            anyhow::bail!(
+                "MemDbIndex too short: {} bytes (need at least 32)",
+                data.len()
+            );
+        }
+
+        // Parse header
+        let version = u64::from_le_bytes(data[0..8].try_into().unwrap());
+        let last_inode = u64::from_le_bytes(data[8..16].try_into().unwrap());
+        let writer = u32::from_le_bytes(data[16..20].try_into().unwrap());
+        let mtime = u32::from_le_bytes(data[20..24].try_into().unwrap());
+        let size = u32::from_le_bytes(data[24..28].try_into().unwrap());
+        let bytes = u32::from_le_bytes(data[28..32].try_into().unwrap());
+
+        // Validate size
+        let expected_bytes = 32 + size * 40;
+        if bytes != expected_bytes {
+            anyhow::bail!("MemDbIndex bytes mismatch: got {bytes}, expected {expected_bytes}");
+        }
+
+        if data.len() < bytes as usize {
+            anyhow::bail!(
+                "MemDbIndex data too short: {} bytes (need {})",
+                data.len(),
+                bytes
+            );
+        }
+
+        // Parse entries
+        let mut entries = Vec::with_capacity(size as usize);
+        let mut offset = 32;
+        for _ in 0..size {
+            let entry = IndexEntry::deserialize(&data[offset..offset + 40])?;
+            entries.push(entry);
+            offset += 40;
+        }
+
+        Ok(Self {
+            version,
+            last_inode,
+            writer,
+            mtime,
+            size,
+            bytes,
+            entries,
+        })
+    }
+
+    /// Compute SHA256 digest of a tree entry for the index
+    ///
+    /// Matches C's memdb_encode_index() digest computation (memdb.c:1497-1507)
+    /// CRITICAL: Order and fields must match exactly:
+    ///   1. version, 2. writer, 3. mtime, 4. size, 5. type, 6. parent, 7. name, 8. data
+    ///
+    /// NOTE: inode is NOT included in the digest (only used as the index key)
+    #[allow(clippy::too_many_arguments)]
+    pub fn compute_entry_digest(
+        _inode: u64, // Not included in digest, only for signature compatibility
+        parent: u64,
+        version: u64,
+        writer: u32,
+        mtime: u32,
+        size: usize,
+        entry_type: u8,
+        name: &str,
+        data: &[u8],
+    ) -> [u8; 32] {
+        let mut hasher = Sha256::new();
+
+        // Hash entry metadata in C's exact order (memdb.c:1497-1503)
+        hasher.update(version.to_le_bytes());
+        hasher.update(writer.to_le_bytes());
+        hasher.update(mtime.to_le_bytes());
+        hasher.update((size as u32).to_le_bytes()); // C uses u32 for te->size
+        hasher.update([entry_type]);
+        hasher.update(parent.to_le_bytes());
+        hasher.update(name.as_bytes());
+
+        // Hash data only for regular files with non-zero size (memdb.c:1505-1507)
+        if entry_type == 8 /* DT_REG */ && size > 0 {
+            hasher.update(data);
+        }
+
+        hasher.finalize().into()
+    }
+}
+
+/// Implement comparison for MemDbIndex
+///
+/// Matches C's dcdb_choose_leader_with_highest_index() logic:
+/// - If same version, higher mtime wins
+/// - If different version, higher version wins
+impl PartialOrd for MemDbIndex {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for MemDbIndex {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        // First compare by version (higher version wins)
+        // Then by mtime (higher mtime wins) if versions are equal
+        self.version
+            .cmp(&other.version)
+            .then_with(|| self.mtime.cmp(&other.mtime))
+    }
+}
+
+impl MemDbIndex {
+    /// Find entries that differ from another index
+    ///
+    /// Returns the set of inodes that need to be sent as updates.
+    /// Matches C's dcdb_create_and_send_updates() comparison logic.
+    pub fn find_differences(&self, other: &MemDbIndex) -> Vec<u64> {
+        let mut differences = Vec::new();
+
+        // Walk through master index, comparing with slave
+        let mut j = 0; // slave position
+
+        for i in 0..self.entries.len() {
+            let master_entry = &self.entries[i];
+            let inode = master_entry.inode;
+
+            // Advance slave pointer to matching or higher inode
+            while j < other.entries.len() && other.entries[j].inode < inode {
+                j += 1;
+            }
+
+            // Check if entries match
+            if j < other.entries.len() {
+                let slave_entry = &other.entries[j];
+                if slave_entry.inode == inode && slave_entry.digest == master_entry.digest {
+                    // Entries match - skip
+                    continue;
+                }
+            }
+
+            // Entry differs or missing - needs update
+            differences.push(inode);
+        }
+
+        differences
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    //! Unit tests for index serialization and synchronization
+    //!
+    //! This test module covers:
+    //! - Index serialization/deserialization (round-trip verification)
+    //! - Leader election logic (version-based, mtime tiebreaker)
+    //! - Difference detection (finding sync deltas between indices)
+    //! - TreeEntry serialization (files, directories, empty files)
+    //! - Digest computation (determinism, sorted entries)
+    //! - Large index handling (100+ entry stress tests)
+    //!
+    //! ## Serialization Format
+    //!
+    //! - IndexEntry: 40 bytes (8-byte inode + 32-byte digest)
+    //! - MemDbIndex: Header (version) + entries
+    //! - TreeEntry: Type-specific format (regular file, directory, symlink)
+    //!
+    //! ## Leader Election
+    //!
+    //! Leader election follows these rules:
+    //! 1. Higher version wins
+    //! 2. If versions equal, higher mtime wins
+    //! 3. If both equal, indices are considered equal
+
+    use super::*;
+
+    #[test]
+    fn test_index_entry_roundtrip() {
+        let entry = IndexEntry {
+            inode: 0x123456789ABCDEF0,
+            digest: [42u8; 32],
+        };
+
+        let serialized = entry.serialize();
+        assert_eq!(serialized.len(), 40);
+
+        let deserialized = IndexEntry::deserialize(&serialized).unwrap();
+        assert_eq!(deserialized, entry);
+    }
+
+    #[test]
+    fn test_memdb_index_roundtrip() {
+        let entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [1u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [2u8; 32],
+            },
+        ];
+
+        let index = MemDbIndex::new(100, 1000, 1, 123456, entries);
+
+        let serialized = index.serialize();
+        assert_eq!(serialized.len(), 32 + 2 * 40);
+
+        let deserialized = MemDbIndex::deserialize(&serialized).unwrap();
+        assert_eq!(deserialized.version, 100);
+        assert_eq!(deserialized.last_inode, 1000);
+        assert_eq!(deserialized.size, 2);
+        assert_eq!(deserialized.entries.len(), 2);
+    }
+
+    #[test]
+    fn test_index_comparison() {
+        let idx1 = MemDbIndex::new(100, 0, 1, 1000, vec![]);
+        let idx2 = MemDbIndex::new(100, 0, 1, 2000, vec![]);
+        let idx3 = MemDbIndex::new(101, 0, 1, 500, vec![]);
+
+        // Same version, lower mtime
+        assert!(idx1 < idx2);
+        assert_eq!(idx1.cmp(&idx2), std::cmp::Ordering::Less);
+
+        // Same version, higher mtime
+        assert!(idx2 > idx1);
+        assert_eq!(idx2.cmp(&idx1), std::cmp::Ordering::Greater);
+
+        // Higher version wins even with lower mtime
+        assert!(idx3 > idx2);
+        assert_eq!(idx3.cmp(&idx2), std::cmp::Ordering::Greater);
+
+        // Test equality
+        let idx4 = MemDbIndex::new(100, 0, 1, 1000, vec![]);
+        assert_eq!(idx1, idx4);
+        assert_eq!(idx1.cmp(&idx4), std::cmp::Ordering::Equal);
+    }
+
+    #[test]
+    fn test_find_differences() {
+        let master_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [1u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [2u8; 32],
+            },
+            IndexEntry {
+                inode: 3,
+                digest: [3u8; 32],
+            },
+        ];
+
+        let slave_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [1u8; 32], // same
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [99u8; 32], // different digest
+            },
+            // missing inode 3
+        ];
+
+        let master = MemDbIndex::new(100, 3, 1, 1000, master_entries);
+        let slave = MemDbIndex::new(100, 2, 1, 900, slave_entries);
+
+        let diffs = master.find_differences(&slave);
+        assert_eq!(diffs, vec![2, 3]); // inode 2 changed, inode 3 missing
+    }
+
+    // ========== Tests moved from sync_tests.rs ==========
+
+    #[test]
+    fn test_memdb_index_serialization() {
+        // Create a simple index with a few entries
+        let entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+            IndexEntry {
+                inode: 3,
+                digest: [2u8; 32],
+            },
+        ];
+
+        let index = MemDbIndex::new(
+            100,   // version
+            3,     // last_inode
+            1,     // writer
+            12345, // mtime
+            entries,
+        );
+
+        // Serialize
+        let serialized = index.serialize();
+
+        // Expected size: 32-byte header + 3 * 40-byte entries = 152 bytes
+        assert_eq!(serialized.len(), 32 + 3 * 40);
+        assert_eq!(serialized.len(), index.bytes as usize);
+
+        // Deserialize
+        let deserialized = MemDbIndex::deserialize(&serialized).expect("Failed to deserialize");
+
+        // Verify all fields match
+        assert_eq!(deserialized.version, index.version);
+        assert_eq!(deserialized.last_inode, index.last_inode);
+        assert_eq!(deserialized.writer, index.writer);
+        assert_eq!(deserialized.mtime, index.mtime);
+        assert_eq!(deserialized.size, index.size);
+        assert_eq!(deserialized.bytes, index.bytes);
+        assert_eq!(deserialized.entries.len(), index.entries.len());
+
+        for (i, (orig, deser)) in index
+            .entries
+            .iter()
+            .zip(deserialized.entries.iter())
+            .enumerate()
+        {
+            assert_eq!(deser.inode, orig.inode, "Entry {i} inode mismatch");
+            assert_eq!(deser.digest, orig.digest, "Entry {i} digest mismatch");
+        }
+    }
+
+    #[test]
+    fn test_leader_election_by_version() {
+        use std::cmp::Ordering;
+
+        // Create three indices with different versions
+        let entries1 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+        let entries2 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+        let entries3 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+
+        let index1 = MemDbIndex::new(100, 1, 1, 1000, entries1);
+        let index2 = MemDbIndex::new(150, 1, 2, 1000, entries2); // Higher version - should win
+        let index3 = MemDbIndex::new(120, 1, 3, 1000, entries3);
+
+        // Test comparisons
+        assert_eq!(index2.cmp(&index1), Ordering::Greater);
+        assert_eq!(index2.cmp(&index3), Ordering::Greater);
+        assert_eq!(index1.cmp(&index2), Ordering::Less);
+        assert_eq!(index3.cmp(&index2), Ordering::Less);
+    }
+
+    #[test]
+    fn test_leader_election_by_mtime_tiebreaker() {
+        use std::cmp::Ordering;
+
+        // Create two indices with same version but different mtime
+        let entries1 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+        let entries2 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+
+        let index1 = MemDbIndex::new(100, 1, 1, 1000, entries1);
+        let index2 = MemDbIndex::new(100, 1, 2, 2000, entries2); // Same version, higher mtime - should win
+
+        // Test comparison - higher mtime should win
+        assert_eq!(index2.cmp(&index1), Ordering::Greater);
+        assert_eq!(index1.cmp(&index2), Ordering::Less);
+    }
+
+    #[test]
+    fn test_leader_election_equal_indices() {
+        use std::cmp::Ordering;
+
+        // Create two identical indices
+        let entries1 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+        let entries2 = vec![IndexEntry {
+            inode: 1,
+            digest: [0u8; 32],
+        }];
+
+        let index1 = MemDbIndex::new(100, 1, 1, 1000, entries1);
+        let index2 = MemDbIndex::new(100, 1, 2, 1000, entries2);
+
+        // Should be equal
+        assert_eq!(index1.cmp(&index2), Ordering::Equal);
+        assert_eq!(index2.cmp(&index1), Ordering::Equal);
+    }
+
+    #[test]
+    fn test_index_find_differences() {
+        // Leader has inodes 1, 2, 3
+        let leader_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+            IndexEntry {
+                inode: 3,
+                digest: [2u8; 32],
+            },
+        ];
+        let leader = MemDbIndex::new(100, 3, 1, 1000, leader_entries);
+
+        // Follower has inodes 1 (same), 2 (different digest), missing 3
+        let follower_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            }, // Same
+            IndexEntry {
+                inode: 2,
+                digest: [99u8; 32],
+            }, // Different digest
+        ];
+        let follower = MemDbIndex::new(90, 2, 2, 900, follower_entries);
+
+        // Find differences
+        let diffs = leader.find_differences(&follower);
+
+        // Should find inodes 2 (different digest) and 3 (missing in follower)
+        assert_eq!(diffs.len(), 2);
+        assert!(diffs.contains(&2));
+        assert!(diffs.contains(&3));
+    }
+
+    #[test]
+    fn test_index_find_differences_no_diffs() {
+        // Both have same inodes with same digests
+        let entries1 = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+        ];
+        let entries2 = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+        ];
+
+        let index1 = MemDbIndex::new(100, 2, 1, 1000, entries1);
+        let index2 = MemDbIndex::new(100, 2, 2, 1000, entries2);
+
+        let diffs = index1.find_differences(&index2);
+        assert_eq!(diffs.len(), 0);
+    }
+
+    #[test]
+    fn test_index_find_differences_follower_has_extra() {
+        // Leader has inodes 1, 2
+        let leader_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+        ];
+        let leader = MemDbIndex::new(100, 2, 1, 1000, leader_entries);
+
+        // Follower has inodes 1, 2, 3 (extra inode 3)
+        let follower_entries = vec![
+            IndexEntry {
+                inode: 1,
+                digest: [0u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [1u8; 32],
+            },
+            IndexEntry {
+                inode: 3,
+                digest: [2u8; 32],
+            },
+        ];
+        let follower = MemDbIndex::new(90, 3, 2, 900, follower_entries);
+
+        // Find differences - leader should not report extra entries in follower
+        // (follower will delete them when it receives leader's updates)
+        let diffs = leader.find_differences(&follower);
+        assert_eq!(diffs.len(), 0);
+    }
+
+    #[test]
+    fn test_tree_entry_update_serialization() {
+        use crate::types::TreeEntry;
+
+        // Create a TreeEntry
+        let entry = TreeEntry {
+            inode: 42,
+            parent: 1,
+            version: 100,
+            writer: 2,
+            mtime: 12345,
+            size: 11,
+            entry_type: 8, // DT_REG
+            name: "test.conf".to_string(),
+            data: b"hello world".to_vec(),
+        };
+
+        // Serialize for update
+        let serialized = entry.serialize_for_update();
+
+        // Expected size: 41-byte header + 10 bytes (name + null) + 11 bytes (data)
+        // = 62 bytes
+        assert_eq!(serialized.len(), 41 + 10 + 11);
+
+        // Deserialize
+        let deserialized = TreeEntry::deserialize_from_update(&serialized).unwrap();
+
+        // Verify all fields
+        assert_eq!(deserialized.inode, entry.inode);
+        assert_eq!(deserialized.parent, entry.parent);
+        assert_eq!(deserialized.version, entry.version);
+        assert_eq!(deserialized.writer, entry.writer);
+        assert_eq!(deserialized.mtime, entry.mtime);
+        assert_eq!(deserialized.size, entry.size);
+        assert_eq!(deserialized.entry_type, entry.entry_type);
+        assert_eq!(deserialized.name, entry.name);
+        assert_eq!(deserialized.data, entry.data);
+    }
+
+    #[test]
+    fn test_tree_entry_directory_serialization() {
+        use crate::types::TreeEntry;
+
+        // Create a directory entry (no data)
+        let entry = TreeEntry {
+            inode: 10,
+            parent: 1,
+            version: 50,
+            writer: 1,
+            mtime: 10000,
+            size: 0,
+            entry_type: 4, // DT_DIR
+            name: "configs".to_string(),
+            data: Vec::new(),
+        };
+
+        // Serialize
+        let serialized = entry.serialize_for_update();
+
+        // Expected size: 41-byte header + 8 bytes (name + null) + 0 bytes (no data)
+        assert_eq!(serialized.len(), 41 + 8);
+
+        // Deserialize
+        let deserialized = TreeEntry::deserialize_from_update(&serialized).unwrap();
+
+        assert_eq!(deserialized.inode, entry.inode);
+        assert_eq!(deserialized.name, entry.name);
+        assert_eq!(deserialized.entry_type, 4); // DT_DIR
+        assert_eq!(deserialized.data.len(), 0);
+    }
+
+    #[test]
+    fn test_tree_entry_empty_file_serialization() {
+        use crate::types::TreeEntry;
+
+        // Create an empty file
+        let entry = TreeEntry {
+            inode: 20,
+            parent: 1,
+            version: 75,
+            writer: 3,
+            mtime: 20000,
+            size: 0,
+            entry_type: 8, // DT_REG
+            name: "empty.txt".to_string(),
+            data: Vec::new(),
+        };
+
+        // Serialize
+        let serialized = entry.serialize_for_update();
+
+        // Expected size: 41-byte header + 10 bytes (name + null) + 0 bytes (no data)
+        assert_eq!(serialized.len(), 41 + 10);
+
+        // Deserialize
+        let deserialized = TreeEntry::deserialize_from_update(&serialized).unwrap();
+
+        assert_eq!(deserialized.inode, entry.inode);
+        assert_eq!(deserialized.name, entry.name);
+        assert_eq!(deserialized.size, 0);
+        assert_eq!(deserialized.data.len(), 0);
+    }
+
+    #[test]
+    fn test_index_digest_computation() {
+        // Test that different entries produce different digests
+        let digest1 = MemDbIndex::compute_entry_digest(1, 0, 100, 1, 1000, 0, 4, "dir1", &[]);
+
+        let digest2 = MemDbIndex::compute_entry_digest(2, 0, 100, 1, 1000, 0, 4, "dir2", &[]);
+
+        // Different inodes should produce different digests
+        assert_ne!(digest1, digest2);
+
+        // Same parameters should produce same digest
+        let digest3 = MemDbIndex::compute_entry_digest(1, 0, 100, 1, 1000, 0, 4, "dir1", &[]);
+        assert_eq!(digest1, digest3);
+
+        // Different data should produce different digest
+        let digest4 = MemDbIndex::compute_entry_digest(1, 0, 100, 1, 1000, 5, 8, "file", b"hello");
+        let digest5 = MemDbIndex::compute_entry_digest(1, 0, 100, 1, 1000, 5, 8, "file", b"world");
+        assert_ne!(digest4, digest5);
+    }
+
+    #[test]
+    fn test_index_sorted_entries() {
+        // Create entries in unsorted order
+        let entries = vec![
+            IndexEntry {
+                inode: 5,
+                digest: [5u8; 32],
+            },
+            IndexEntry {
+                inode: 2,
+                digest: [2u8; 32],
+            },
+            IndexEntry {
+                inode: 8,
+                digest: [8u8; 32],
+            },
+            IndexEntry {
+                inode: 1,
+                digest: [1u8; 32],
+            },
+        ];
+
+        let index = MemDbIndex::new(100, 8, 1, 1000, entries);
+
+        // Verify entries are stored sorted by inode
+        assert_eq!(index.entries[0].inode, 1);
+        assert_eq!(index.entries[1].inode, 2);
+        assert_eq!(index.entries[2].inode, 5);
+        assert_eq!(index.entries[3].inode, 8);
+    }
+
+    #[test]
+    fn test_large_index_serialization() {
+        // Test with a larger number of entries
+        let mut entries = Vec::new();
+        for i in 1..=100 {
+            entries.push(IndexEntry {
+                inode: i,
+                digest: [(i % 256) as u8; 32],
+            });
+        }
+
+        let index = MemDbIndex::new(1000, 100, 1, 50000, entries);
+
+        // Serialize and deserialize
+        let serialized = index.serialize();
+        let deserialized =
+            MemDbIndex::deserialize(&serialized).expect("Failed to deserialize large index");
+
+        // Verify
+        assert_eq!(deserialized.version, index.version);
+        assert_eq!(deserialized.size, 100);
+        assert_eq!(deserialized.entries.len(), 100);
+
+        for i in 0..100 {
+            assert_eq!(deserialized.entries[i].inode, (i + 1) as u64);
+        }
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/lib.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/lib.rs
new file mode 100644
index 00000000..f5c6d97a
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/lib.rs
@@ -0,0 +1,26 @@
+/// In-memory database with SQLite persistence
+///
+/// This module provides a cluster-synchronized in-memory database with SQLite persistence.
+/// The implementation is organized into focused submodules:
+///
+/// - `types`: Type definitions and constants
+/// - `database`: Core MemDb struct and CRUD operations
+/// - `locks`: Resource locking functionality
+/// - `sync`: State synchronization and serialization
+/// - `index`: C-compatible memdb index structures for efficient state comparison
+/// - `traits`: Trait abstractions for dependency injection and testing
+mod database;
+mod index;
+mod locks;
+mod sync;
+mod traits;
+mod types;
+mod vmlist;
+
+// Re-export public types
+pub use database::MemDb;
+pub use index::{IndexEntry, MemDbIndex};
+pub use locks::is_lock_path;
+pub use traits::MemDbOps;
+pub use types::{ROOT_INODE, TreeEntry};
+pub use vmlist::recreate_vmlist;
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/locks.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/locks.rs
new file mode 100644
index 00000000..6d797fd0
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/locks.rs
@@ -0,0 +1,286 @@
+/// Lock management for memdb
+///
+/// Locks in pmxcfs are implemented as directory entries stored in the database at
+/// `priv/lock/<lockname>`. This ensures locks are:
+/// 1. Persistent across restarts
+/// 2. Synchronized across the cluster via DFSM
+/// 3. Visible to both C and Rust nodes
+///
+/// The in-memory lock table is a cache rebuilt from the database on startup
+/// and updated dynamically during runtime.
+use anyhow::Result;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use super::database::MemDb;
+use super::types::{LOCK_DIR_PATH, LOCK_TIMEOUT, LockInfo};
+
+/// Check if a path is in the lock directory
+///
+/// Matches C's path_is_lockdir() function (cfs-utils.c:306)
+/// Returns true if path is "{LOCK_DIR_PATH}/<something>" (with or without leading /)
+pub fn is_lock_path(path: &str) -> bool {
+    let path = path.trim_start_matches('/');
+    let lock_prefix = format!("{LOCK_DIR_PATH}/");
+    path.starts_with(&lock_prefix) && path.len() > lock_prefix.len()
+}
+
+impl MemDb {
+    /// Check if a lock has expired (with side effects matching C semantics)
+    ///
+    /// This function implements the same behavior as the C version (memdb.c:330-358):
+    /// - If no lock exists in cache: Reads from database, creates cache entry, returns `false`
+    /// - If lock exists but csum mismatches: Updates csum, resets timeout, logs critical error, returns `false`
+    /// - If lock exists, csum matches, and time > LOCK_TIMEOUT: Returns `true` (expired)
+    /// - Otherwise: Returns `false` (not expired)
+    ///
+    /// This function is used for both checking AND managing locks, matching C semantics.
+    ///
+    /// # Current Usage
+    /// - Called from `database::create()` when creating lock directories (matching C memdb.c:928)
+    /// - Called from FUSE utimens operation (pmxcfs/src/fuse/filesystem.rs:717) for mtime=0 unlock requests
+    /// - Called from DFSM unlock message handlers (pmxcfs/src/memdb_callbacks.rs:142,161)
+    ///
+    /// Note: DFSM broadcasting of unlock messages to cluster nodes is not yet fully implemented.
+    /// See TODOs in filesystem.rs:723 and memdb_callbacks.rs:154 for remaining work.
+    pub fn lock_expired(&self, path: &str, csum: &[u8; 32]) -> bool {
+        let mut locks = self.inner.locks.lock();
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs();
+
+        match locks.get_mut(path) {
+            Some(lock_info) => {
+                // Lock exists in cache - check csum
+                if lock_info.csum != *csum {
+                    // Wrong csum - update and reset timeout
+                    lock_info.ltime = now;
+                    lock_info.csum = *csum;
+                    tracing::error!("Lock checksum mismatch for '{}' - resetting timeout", path);
+                    return false;
+                }
+
+                // Csum matches - check if expired
+                let elapsed = now - lock_info.ltime;
+                if elapsed > LOCK_TIMEOUT {
+                    tracing::debug!(path, elapsed, "Lock expired");
+                    return true; // Expired
+                }
+
+                false // Not expired
+            }
+            None => {
+                // No lock in cache - create new cache entry
+                locks.insert(
+                    path.to_string(),
+                    LockInfo {
+                        ltime: now,
+                        csum: *csum,
+                    },
+                );
+                tracing::debug!(path, "Created new lock cache entry");
+                false // Not expired (just created)
+            }
+        }
+    }
+
+    /// Acquire a lock on a path
+    ///
+    /// This creates a directory entry in the database at `priv/lock/<lockname>`
+    /// and broadcasts the operation to the cluster via DFSM.
+    pub fn acquire_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()> {
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs();
+
+        let locks = self.inner.locks.lock();
+
+        // Check if there's an existing valid lock in cache
+        if let Some(existing_lock) = locks.get(path) {
+            let lock_age = now - existing_lock.ltime;
+            if lock_age <= LOCK_TIMEOUT && existing_lock.csum != *csum {
+                return Err(anyhow::anyhow!("Lock already held by another process"));
+            }
+        }
+
+        // Convert path like "/priv/lock/foo.lock" to just the lock name
+        let lock_dir_with_slash = format!("/{LOCK_DIR_PATH}/");
+        let lock_name = if let Some(name) = path.strip_prefix(&lock_dir_with_slash) {
+            name
+        } else {
+            path.strip_prefix('/').unwrap_or(path)
+        };
+
+        let lock_path = format!("/{LOCK_DIR_PATH}/{lock_name}");
+
+        // Release locks mutex before database operations to avoid deadlock
+        drop(locks);
+
+        // Create or update lock directory in database
+        // First check if it exists
+        if self.exists(&lock_path)? {
+            // Lock directory exists - update its mtime to refresh
+            // In C this is implicit through the checksum, we'll update the entry
+            tracing::debug!("Refreshing existing lock directory: {}", lock_path);
+            // We don't need to do anything - the lock cache entry will be updated below
+        } else {
+            // Create lock directory in database
+            let mode = libc::S_IFDIR | 0o755;
+            let mtime = now as u32;
+
+            // Ensure lock directory exists
+            let lock_dir_full = format!("/{LOCK_DIR_PATH}");
+            if !self.exists(&lock_dir_full)? {
+                self.create(&lock_dir_full, libc::S_IFDIR | 0o755, mtime)?;
+            }
+
+            self.create(&lock_path, mode, mtime)?;
+            tracing::debug!("Created lock directory in database: {}", lock_path);
+        }
+
+        // Update in-memory cache
+        let mut locks = self.inner.locks.lock();
+        locks.insert(
+            lock_path.clone(),
+            LockInfo {
+                ltime: now,
+                csum: *csum,
+            },
+        );
+
+        tracing::debug!("Lock acquired on path: {}", lock_path);
+        Ok(())
+    }
+
+    /// Release a lock on a path
+    ///
+    /// This deletes the directory entry from the database and broadcasts
+    /// the delete operation to the cluster via DFSM.
+    pub fn release_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()> {
+        let locks = self.inner.locks.lock();
+
+        if let Some(lock_info) = locks.get(path) {
+            // Only release if checksum matches
+            if lock_info.csum != *csum {
+                return Err(anyhow::anyhow!("Cannot release lock: checksum mismatch"));
+            }
+        } else {
+            return Err(anyhow::anyhow!("No lock found on path: {path}"));
+        }
+
+        // Release locks mutex before database operations
+        drop(locks);
+
+        // Delete lock directory from database
+        if self.exists(path)? {
+            self.delete(path)?;
+            tracing::debug!("Deleted lock directory from database: {}", path);
+        }
+
+        // Remove from in-memory cache
+        let mut locks = self.inner.locks.lock();
+        locks.remove(path);
+
+        tracing::debug!("Lock released on path: {}", path);
+        Ok(())
+    }
+
+    /// Update lock cache by scanning the priv/lock directory in database
+    ///
+    /// This implements the C version's behavior (memdb.c:360-89):
+    /// - Scans the `priv/lock` directory in the database
+    /// - Rebuilds the entire lock hash table from database state
+    /// - Preserves `ltime` from old entries if csum matches
+    /// - Is called on database open and after synchronization
+    ///
+    /// This ensures locks are visible across C/Rust nodes and survive restarts.
+    pub(crate) fn update_locks(&self) {
+        // Check if lock directory exists
+        let _lock_dir = match self.lookup_path(LOCK_DIR_PATH) {
+            Some(entry) if entry.is_dir() => entry,
+            _ => {
+                tracing::debug!(
+                    "{} directory does not exist, initializing empty lock table",
+                    LOCK_DIR_PATH
+                );
+                self.inner.locks.lock().clear();
+                return;
+            }
+        };
+
+        let now = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_secs();
+
+        // Get old locks table for preserving ltimes
+        let old_locks = {
+            let locks = self.inner.locks.lock();
+            locks.clone()
+        };
+
+        // Build new locks table from database
+        let mut new_locks = std::collections::HashMap::new();
+
+        // Read all lock directories
+        match self.readdir(LOCK_DIR_PATH) {
+            Ok(entries) => {
+                for entry in entries {
+                    // Only process directories (locks are stored as directories)
+                    if !entry.is_dir() {
+                        continue;
+                    }
+
+                    let lock_path = format!("{}/{}", LOCK_DIR_PATH, entry.name);
+                    let csum = entry.compute_checksum();
+
+                    // Check if we have an old entry with matching checksum
+                    let ltime = if let Some(old_lock) = old_locks.get(&lock_path) {
+                        if old_lock.csum == csum {
+                            // Checksum matches - preserve old ltime
+                            old_lock.ltime
+                        } else {
+                            // Checksum changed - reset ltime
+                            now
+                        }
+                    } else {
+                        // New lock - set ltime to now
+                        now
+                    };
+
+                    new_locks.insert(lock_path.clone(), LockInfo { ltime, csum });
+                    tracing::debug!("Loaded lock from database: {}", lock_path);
+                }
+            }
+            Err(e) => {
+                tracing::warn!("Failed to read {} directory: {}", LOCK_DIR_PATH, e);
+                return;
+            }
+        }
+
+        // Replace lock table
+        *self.inner.locks.lock() = new_locks;
+
+        tracing::debug!(
+            "Updated lock table from database: {} locks",
+            self.inner.locks.lock().len()
+        );
+    }
+
+    /// Check if a path is locked
+    pub fn is_locked(&self, path: &str) -> bool {
+        let locks = self.inner.locks.lock();
+        if let Some(lock_info) = locks.get(path) {
+            let now = SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap_or_default()
+                .as_secs();
+
+            // Check if lock is still valid (not expired)
+            (now - lock_info.ltime) <= LOCK_TIMEOUT
+        } else {
+            false
+        }
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/sync.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/sync.rs
new file mode 100644
index 00000000..719a2cf0
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/sync.rs
@@ -0,0 +1,249 @@
+/// State synchronization and serialization for memdb
+use anyhow::{Context, Result};
+use sha2::{Digest, Sha256};
+use std::sync::atomic::Ordering;
+
+use super::database::MemDb;
+use super::index::{IndexEntry, MemDbIndex};
+use super::types::TreeEntry;
+
+impl MemDb {
+    /// Encode database index for C-compatible state synchronization
+    ///
+    /// This creates a memdb_index_t structure matching the C implementation,
+    /// containing metadata and a sorted list of (inode, digest) pairs.
+    /// This is sent as the "state" during DFSM synchronization.
+    pub fn encode_index(&self) -> Result<MemDbIndex> {
+        let mut index = self.inner.index.lock();
+
+        // CRITICAL: Synchronize root entry version with global version counter
+        // The C implementation uses root->version as the index version,
+        // so we must ensure they match before encoding.
+        let global_version = self.inner.version.load(Ordering::SeqCst);
+
+        let root_inode = self.inner.root_inode;
+        let mut root_version_updated = false;
+        if let Some(root_entry) = index.get_mut(&root_inode) {
+            if root_entry.version != global_version {
+                root_entry.version = global_version;
+                root_version_updated = true;
+            }
+        } else {
+            anyhow::bail!("Root entry not found in index");
+        }
+
+        // If root version was updated, persist to database
+        if root_version_updated {
+            let conn = self.inner.conn.lock();
+            let root_entry = index.get(&root_inode).unwrap();  // Safe: we just checked it exists
+
+            conn.execute(
+                "UPDATE entries SET version = ? WHERE inode = ?",
+                rusqlite::params![root_entry.version as i64, root_inode as i64],
+            )
+            .context("Failed to update root version in database")?;
+
+            drop(conn);
+        }
+
+        // Collect ALL entries including root, sorted by inode
+        let mut entries: Vec<&TreeEntry> = index.values().collect();
+        entries.sort_by_key(|e| e.inode);
+
+        tracing::info!("=== encode_index: Encoding {} entries ===", entries.len());
+        for te in entries.iter() {
+            tracing::info!(
+                "  Entry: inode={:#018x}, parent={:#018x}, name='{}', type={}, version={}, writer={}, mtime={}, size={}",
+                te.inode, te.parent, te.name, te.entry_type, te.version, te.writer, te.mtime, te.size
+            );
+        }
+
+        // Create index entries with digests
+        let index_entries: Vec<IndexEntry> = entries
+            .iter()
+            .map(|te| {
+                let digest = MemDbIndex::compute_entry_digest(
+                    te.inode,
+                    te.parent,
+                    te.version,
+                    te.writer,
+                    te.mtime,
+                    te.size,
+                    te.entry_type,
+                    &te.name,
+                    &te.data,
+                );
+                tracing::debug!(
+                    "  Digest for inode {:#018x}: {:02x}{:02x}{:02x}{:02x}...{:02x}{:02x}{:02x}{:02x}",
+                    te.inode,
+                    digest[0], digest[1], digest[2], digest[3],
+                    digest[28], digest[29], digest[30], digest[31]
+                );
+                IndexEntry { inode: te.inode, digest }
+            })
+            .collect();
+
+        // Get root entry for mtime and writer_id (now updated with global version)
+        let root_entry = index
+            .get(&self.inner.root_inode)
+            .ok_or_else(|| anyhow::anyhow!("Root entry not found in index"))?;
+
+        let version = global_version;  // Already synchronized above
+        let last_inode = index.keys().max().copied().unwrap_or(1);
+        let writer = root_entry.writer;
+        let mtime = root_entry.mtime;
+
+        drop(index);
+
+        Ok(MemDbIndex::new(
+            version,
+            last_inode,
+            writer,
+            mtime,
+            index_entries,
+        ))
+    }
+
+    /// Encode the entire database state into a byte array
+    /// Matches C version's memdb_encode() function
+    pub fn encode_database(&self) -> Result<Vec<u8>> {
+        let index = self.inner.index.lock();
+
+        // Collect all entries sorted by inode for consistent ordering
+        // This matches the C implementation's memdb_tree_compare function
+        let mut entries: Vec<&TreeEntry> = index.values().collect();
+        entries.sort_by_key(|e| e.inode);
+
+        // Log all entries for debugging
+        tracing::info!(
+            "Encoding database: {} entries",
+            entries.len()
+        );
+        for entry in entries.iter() {
+            tracing::info!(
+                "  Entry: inode={}, name='{}', parent={}, type={}, size={}, version={}",
+                entry.inode,
+                entry.name,
+                entry.parent,
+                entry.entry_type,
+                entry.size,
+                entry.version
+            );
+        }
+
+        // Serialize using bincode (compatible with C struct layout)
+        let encoded = bincode::serialize(&entries)
+            .map_err(|e| anyhow::anyhow!("Failed to encode database: {e}"))?;
+
+        tracing::debug!(
+            "Encoded database: {} entries, {} bytes",
+            entries.len(),
+            encoded.len()
+        );
+
+        Ok(encoded)
+    }
+
+    /// Compute checksum of the entire database state
+    /// Used for DFSM state verification
+    pub fn compute_database_checksum(&self) -> Result<[u8; 32]> {
+        let encoded = self.encode_database()?;
+
+        let mut hasher = Sha256::new();
+        hasher.update(&encoded);
+
+        Ok(hasher.finalize().into())
+    }
+
+    /// Decode database state from a byte array
+    /// Used during DFSM state synchronization
+    pub fn decode_database(data: &[u8]) -> Result<Vec<TreeEntry>> {
+        let entries: Vec<TreeEntry> = bincode::deserialize(data)
+            .map_err(|e| anyhow::anyhow!("Failed to decode database: {e}"))?;
+
+        tracing::debug!("Decoded database: {} entries", entries.len());
+
+        Ok(entries)
+    }
+
+    /// Synchronize corosync configuration from MemDb to filesystem
+    ///
+    /// Reads corosync.conf from memdb and writes to system file if changed.
+    /// This syncs the cluster configuration from the distributed database
+    /// to the local filesystem.
+    ///
+    /// # Arguments
+    /// * `system_path` - Path to write the corosync.conf file (default: /etc/corosync/corosync.conf)
+    /// * `force` - Force write even if unchanged
+    pub fn sync_corosync_conf(&self, system_path: Option<&str>, force: bool) -> Result<()> {
+        let system_path = system_path.unwrap_or("/etc/corosync/corosync.conf");
+        tracing::info!(
+            "Syncing corosync configuration to {} (force={})",
+            system_path,
+            force
+        );
+
+        // Path in memdb for corosync.conf
+        let memdb_path = "/corosync.conf";
+
+        // Try to read from memdb
+        let memdb_data = match self.lookup_path(memdb_path) {
+            Some(entry) if entry.is_file() => entry.data,
+            Some(_) => {
+                return Err(anyhow::anyhow!("{memdb_path} exists but is not a file"));
+            }
+            None => {
+                tracing::debug!("{} not found in memdb, nothing to sync", memdb_path);
+                return Ok(());
+            }
+        };
+
+        // Read current system file if it exists
+        let system_data = std::fs::read(system_path).ok();
+
+        // Determine if we need to write
+        let should_write = force || system_data.as_ref() != Some(&memdb_data);
+
+        if !should_write {
+            tracing::debug!("Corosync configuration unchanged, skipping write");
+            return Ok(());
+        }
+
+        // SAFETY CHECK: Writing to /etc requires root permissions
+        // We'll attempt the write but log clearly if it fails
+        tracing::info!(
+            "Corosync configuration changed (size: {} bytes), updating {}",
+            memdb_data.len(),
+            system_path
+        );
+
+        // Basic validation: check if it looks like a valid corosync config
+        let config_str =
+            std::str::from_utf8(&memdb_data).context("Corosync config is not valid UTF-8")?;
+
+        if !config_str.contains("totem") {
+            tracing::warn!("Corosync config validation: missing 'totem' section");
+        }
+        if !config_str.contains("nodelist") {
+            tracing::warn!("Corosync config validation: missing 'nodelist' section");
+        }
+
+        // Attempt to write (will fail if not root or no permissions)
+        match std::fs::write(system_path, &memdb_data) {
+            Ok(()) => {
+                tracing::info!("Successfully updated {}", system_path);
+                Ok(())
+            }
+            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
+                tracing::warn!(
+                    "Permission denied writing {}: {}. Run as root to enable corosync sync.",
+                    system_path,
+                    e
+                );
+                // Don't return error - this is expected in non-root mode
+                Ok(())
+            }
+            Err(e) => Err(anyhow::anyhow!("Failed to write {system_path}: {e}")),
+        }
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/traits.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/traits.rs
new file mode 100644
index 00000000..efe3ff36
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/traits.rs
@@ -0,0 +1,101 @@
+//! Traits for MemDb operations
+//!
+//! This module provides the `MemDbOps` trait which abstracts MemDb operations
+//! for dependency injection and testing. Similar to `StatusOps` in pmxcfs-status.
+
+use crate::types::TreeEntry;
+use anyhow::Result;
+
+/// Trait abstracting MemDb operations for dependency injection and mocking
+///
+/// This trait enables:
+/// - Dependency injection of MemDb into components
+/// - Testing with MockMemDb instead of real database
+/// - Trait objects for runtime polymorphism
+///
+/// # Example
+/// ```no_run
+/// use pmxcfs_memdb::{MemDb, MemDbOps};
+/// use std::sync::Arc;
+///
+/// fn use_database(db: Arc<dyn MemDbOps>) {
+///     // Can work with real MemDb or MockMemDb
+///     let exists = db.exists("/test").unwrap();
+/// }
+/// ```
+pub trait MemDbOps: Send + Sync {
+    // ===== Basic File Operations =====
+
+    /// Create a new file or directory
+    fn create(&self, path: &str, mode: u32, mtime: u32) -> Result<()>;
+
+    /// Read data from a file
+    fn read(&self, path: &str, offset: u64, size: usize) -> Result<Vec<u8>>;
+
+    /// Write data to a file
+    fn write(
+        &self,
+        path: &str,
+        offset: u64,
+        mtime: u32,
+        data: &[u8],
+        truncate: bool,
+    ) -> Result<usize>;
+
+    /// Delete a file or directory
+    fn delete(&self, path: &str) -> Result<()>;
+
+    /// Rename a file or directory
+    fn rename(&self, old_path: &str, new_path: &str) -> Result<()>;
+
+    /// Check if a path exists
+    fn exists(&self, path: &str) -> Result<bool>;
+
+    /// List directory contents
+    fn readdir(&self, path: &str) -> Result<Vec<TreeEntry>>;
+
+    /// Set modification time
+    fn set_mtime(&self, path: &str, writer: u32, mtime: u32) -> Result<()>;
+
+    // ===== Path Lookup =====
+
+    /// Look up a path and return its entry
+    fn lookup_path(&self, path: &str) -> Option<TreeEntry>;
+
+    /// Get entry by inode number
+    fn get_entry_by_inode(&self, inode: u64) -> Option<TreeEntry>;
+
+    // ===== Lock Operations =====
+
+    /// Acquire a lock on a path
+    fn acquire_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()>;
+
+    /// Release a lock on a path
+    fn release_lock(&self, path: &str, csum: &[u8; 32]) -> Result<()>;
+
+    /// Check if a path is locked
+    fn is_locked(&self, path: &str) -> bool;
+
+    /// Check if a lock has expired
+    fn lock_expired(&self, path: &str, csum: &[u8; 32]) -> bool;
+
+    // ===== Database Operations =====
+
+    /// Get the current database version
+    fn get_version(&self) -> u64;
+
+    /// Get all entries in the database
+    fn get_all_entries(&self) -> Result<Vec<TreeEntry>>;
+
+    /// Replace all entries (for synchronization)
+    fn replace_all_entries(&self, entries: Vec<TreeEntry>) -> Result<()>;
+
+    /// Apply a single tree entry update
+    fn apply_tree_entry(&self, entry: TreeEntry) -> Result<()>;
+
+    /// Encode the entire database for network transmission
+    fn encode_database(&self) -> Result<Vec<u8>>;
+
+    /// Compute database checksum
+    fn compute_database_checksum(&self) -> Result<[u8; 32]>;
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/types.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/types.rs
new file mode 100644
index 00000000..988596c8
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/types.rs
@@ -0,0 +1,325 @@
+/// Type definitions for memdb module
+use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+
+pub(super) const MEMDB_MAX_FILE_SIZE: usize = 1024 * 1024; // 1 MiB (matches C version)
+pub(super) const LOCK_TIMEOUT: u64 = 120; // Lock timeout in seconds
+pub(super) const DT_DIR: u8 = 4; // Directory type
+pub(super) const DT_REG: u8 = 8; // Regular file type
+
+/// Root inode number (matches C implementation's memdb root inode)
+/// IMPORTANT: This is the MEMDB root inode, which is 0 in both C and Rust.
+/// The FUSE layer exposes this as inode 1 to the filesystem (FUSE_ROOT_ID).
+/// See pmxcfs/src/fuse.rs for the inode mapping logic between memdb and FUSE.
+pub const ROOT_INODE: u64 = 0;
+
+/// Version file name (matches C VERSIONFILENAME)
+/// Used to store root metadata as inode ROOT_INODE in the database
+pub const VERSION_FILENAME: &str = "__version__";
+
+/// Lock directory path (where cluster resource locks are stored)
+/// Locks are implemented as directory entries stored at `priv/lock/<lockname>`
+pub const LOCK_DIR_PATH: &str = "priv/lock";
+
+/// Lock information for resource locking
+///
+/// In the C version (memdb.h:71-74), the lock info struct includes a `path` field
+/// that serves as the hash table key. In Rust, we use `HashMap<String, LockInfo>`
+/// where the path is stored as the HashMap key, so we don't duplicate it here.
+#[derive(Clone, Debug)]
+pub(crate) struct LockInfo {
+    /// Lock timestamp (seconds since UNIX epoch)
+    pub(crate) ltime: u64,
+
+    /// Checksum of the locked resource (used to detect changes)
+    pub(crate) csum: [u8; 32],
+}
+
+/// Tree entry representing a file or directory
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct TreeEntry {
+    pub inode: u64,
+    pub parent: u64,
+    pub version: u64,
+    pub writer: u32,
+    pub mtime: u32,
+    pub size: usize,
+    pub entry_type: u8, // DT_DIR or DT_REG
+    pub name: String,
+    pub data: Vec<u8>, // File data (empty for directories)
+}
+
+impl TreeEntry {
+    pub fn is_dir(&self) -> bool {
+        self.entry_type == DT_DIR
+    }
+
+    pub fn is_file(&self) -> bool {
+        self.entry_type == DT_REG
+    }
+
+    /// Serialize TreeEntry to C-compatible wire format for Update messages
+    ///
+    /// Wire format (matches dcdb_send_update_inode):
+    /// ```c
+    /// [parent: u64][inode: u64][version: u64][writer: u32][mtime: u32]
+    /// [size: u32][namelen: u32][type: u8][name: namelen bytes][data: size bytes]
+    /// ```
+    pub fn serialize_for_update(&self) -> Vec<u8> {
+        let namelen = (self.name.len() + 1) as u32; // Include null terminator
+        let header_size = 8 + 8 + 8 + 4 + 4 + 4 + 4 + 1; // 41 bytes
+        let total_size = header_size + namelen as usize + self.data.len();
+
+        let mut buf = Vec::with_capacity(total_size);
+
+        // Header fields
+        buf.extend_from_slice(&self.parent.to_le_bytes());
+        buf.extend_from_slice(&self.inode.to_le_bytes());
+        buf.extend_from_slice(&self.version.to_le_bytes());
+        buf.extend_from_slice(&self.writer.to_le_bytes());
+        buf.extend_from_slice(&self.mtime.to_le_bytes());
+        buf.extend_from_slice(&(self.size as u32).to_le_bytes());
+        buf.extend_from_slice(&namelen.to_le_bytes());
+        buf.push(self.entry_type);
+
+        // Name (null-terminated)
+        buf.extend_from_slice(self.name.as_bytes());
+        buf.push(0); // null terminator
+
+        // Data (only for files)
+        if self.entry_type == DT_REG && !self.data.is_empty() {
+            buf.extend_from_slice(&self.data);
+        }
+
+        buf
+    }
+
+    /// Deserialize TreeEntry from C-compatible wire format
+    ///
+    /// Matches dcdb_parse_update_inode
+    pub fn deserialize_from_update(data: &[u8]) -> anyhow::Result<Self> {
+        if data.len() < 41 {
+            anyhow::bail!(
+                "Update message too short: {} bytes (need at least 41)",
+                data.len()
+            );
+        }
+
+        let mut offset = 0;
+
+        // Parse header
+        let parent = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
+        offset += 8;
+        let inode = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
+        offset += 8;
+        let version = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
+        offset += 8;
+        let writer = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
+        offset += 4;
+        let mtime = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
+        offset += 4;
+        let size = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize;
+        offset += 4;
+        let namelen = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize;
+        offset += 4;
+        let entry_type = data[offset];
+        offset += 1;
+
+        // Validate type
+        if entry_type != DT_REG && entry_type != DT_DIR {
+            anyhow::bail!("Invalid entry type: {entry_type}");
+        }
+
+        // Validate lengths
+        if data.len() < offset + namelen + size {
+            anyhow::bail!(
+                "Update message too short: {} bytes (need {})",
+                data.len(),
+                offset + namelen + size
+            );
+        }
+
+        // Parse name (null-terminated)
+        let name_bytes = &data[offset..offset + namelen];
+        if name_bytes.is_empty() || name_bytes[namelen - 1] != 0 {
+            anyhow::bail!("Name not null-terminated");
+        }
+        let name = std::str::from_utf8(&name_bytes[..namelen - 1])
+            .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in name: {e}"))?
+            .to_string();
+        offset += namelen;
+
+        // Parse data
+        let data_vec = if entry_type == DT_REG && size > 0 {
+            data[offset..offset + size].to_vec()
+        } else {
+            Vec::new()
+        };
+
+        Ok(TreeEntry {
+            inode,
+            parent,
+            version,
+            writer,
+            mtime,
+            size,
+            entry_type,
+            name,
+            data: data_vec,
+        })
+    }
+
+    /// Compute SHA-256 checksum of this tree entry
+    ///
+    /// This checksum is used by the lock system to detect changes to lock directory entries.
+    /// Matches C version's memdb_tree_entry_csum() function (memdb.c:1389).
+    ///
+    /// The checksum includes all entry metadata (inode, parent, version, writer, mtime, size,
+    /// entry_type, name) and data (for files). This ensures any modification to a lock directory
+    /// entry is detected, triggering lock timeout reset.
+    pub fn compute_checksum(&self) -> [u8; 32] {
+        let mut hasher = Sha256::new();
+
+        // Hash entry metadata in the same order as C version
+        hasher.update(self.inode.to_le_bytes());
+        hasher.update(self.parent.to_le_bytes());
+        hasher.update(self.version.to_le_bytes());
+        hasher.update(self.writer.to_le_bytes());
+        hasher.update(self.mtime.to_le_bytes());
+        hasher.update(self.size.to_le_bytes());
+        hasher.update([self.entry_type]);
+        hasher.update(self.name.as_bytes());
+
+        // Hash data if present
+        if !self.data.is_empty() {
+            hasher.update(&self.data);
+        }
+
+        hasher.finalize().into()
+    }
+}
+
+/// Return type for load_from_db: (index, tree, root_inode, max_version)
+pub(super) type LoadDbResult = (
+    HashMap<u64, TreeEntry>,
+    HashMap<u64, HashMap<String, u64>>,
+    u64,
+    u64,
+);
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ===== TreeEntry Serialization Tests =====
+
+    #[test]
+    fn test_tree_entry_serialize_file_with_data() {
+        let data = b"test file content".to_vec();
+        let entry = TreeEntry {
+            inode: 42,
+            parent: 0,
+            version: 1,
+            writer: 100,
+            name: "testfile.txt".to_string(),
+            mtime: 1234567890,
+            size: data.len(),
+            entry_type: DT_REG,
+            data: data.clone(),
+        };
+
+        let serialized = entry.serialize_for_update();
+
+        // Should have: 41 bytes header + name + null + data
+        let expected_size = 41 + entry.name.len() + 1 + data.len();
+        assert_eq!(serialized.len(), expected_size);
+
+        // Verify roundtrip
+        let deserialized = TreeEntry::deserialize_from_update(&serialized).unwrap();
+        assert_eq!(deserialized.inode, entry.inode);
+        assert_eq!(deserialized.name, entry.name);
+        assert_eq!(deserialized.size, entry.size);
+        assert_eq!(deserialized.data, entry.data);
+    }
+
+    #[test]
+    fn test_tree_entry_serialize_directory() {
+        let entry = TreeEntry {
+            inode: 10,
+            parent: 0,
+            version: 1,
+            writer: 50,
+            name: "mydir".to_string(),
+            mtime: 1234567890,
+            size: 0,
+            entry_type: DT_DIR,
+            data: Vec::new(),
+        };
+
+        let serialized = entry.serialize_for_update();
+
+        // Should have: 41 bytes header + name + null (no data for directories)
+        let expected_size = 41 + entry.name.len() + 1;
+        assert_eq!(serialized.len(), expected_size);
+
+        // Verify roundtrip
+        let deserialized = TreeEntry::deserialize_from_update(&serialized).unwrap();
+        assert_eq!(deserialized.inode, entry.inode);
+        assert_eq!(deserialized.name, entry.name);
+        assert_eq!(deserialized.entry_type, DT_DIR);
+        assert!(
+            deserialized.data.is_empty(),
+            "Directories should have no data"
+        );
+    }
+
+    #[test]
+    fn test_tree_entry_deserialize_truncated_header() {
+        // Only 40 bytes instead of required 41
+        let data = vec![0u8; 40];
+
+        let result = TreeEntry::deserialize_from_update(&data);
+        assert!(result.is_err());
+        assert!(result.unwrap_err().to_string().contains("too short"));
+    }
+
+    #[test]
+    fn test_tree_entry_deserialize_invalid_type() {
+        let mut data = vec![0u8; 100];
+        // Set entry type to invalid value (not DT_REG or DT_DIR)
+        data[40] = 99; // Invalid type
+
+        let result = TreeEntry::deserialize_from_update(&data);
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid entry type")
+        );
+    }
+
+    #[test]
+    fn test_tree_entry_deserialize_missing_name_terminator() {
+        let mut data = vec![0u8; 100];
+
+        // Set valid header fields
+        data[40] = DT_REG; // entry_type at offset 40
+
+        // Set namelen = 5 (at offset 32-35)
+        data[32..36].copy_from_slice(&5u32.to_le_bytes());
+
+        // Put name bytes WITHOUT null terminator
+        data[41..46].copy_from_slice(b"test!");
+        // Note: data[45] should be 0 for null terminator but we set it to '!'
+
+        let result = TreeEntry::deserialize_from_update(&data);
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("not null-terminated")
+        );
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/src/vmlist.rs b/src/pmxcfs-rs/pmxcfs-memdb/src/vmlist.rs
new file mode 100644
index 00000000..fbac7581
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/src/vmlist.rs
@@ -0,0 +1,189 @@
+/// VM list recreation from memdb structure
+///
+/// This module implements memdb_recreate_vmlist() from the C version (memdb.c:415),
+/// which scans the nodes/*/qemu-server/ and nodes/*/lxc/ directories to build
+/// a complete VM/CT registry.
+use super::database::MemDb;
+use anyhow::Result;
+use pmxcfs_api_types::{VmEntry, VmType};
+use std::collections::HashMap;
+
+/// Recreate VM list by scanning memdb structure
+///
+/// Equivalent to C's `memdb_recreate_vmlist()` (memdb.c:415)
+///
+/// Scans the memdb tree structure:
+/// - `nodes/*/qemu-server/*.conf` - QEMU VMs
+/// - `nodes/*/lxc/*.conf` - LXC containers
+///
+/// Returns a HashMap of vmid -> VmEntry with node ownership information.
+///
+/// # Errors
+///
+/// Returns an error if duplicate VMIDs are found across different nodes.
+pub fn recreate_vmlist(memdb: &MemDb) -> Result<HashMap<u32, VmEntry>> {
+    let mut vmlist = HashMap::new();
+    let mut duplicates = Vec::new();
+
+    // Check if nodes directory exists
+    let Ok(nodes_entries) = memdb.readdir("nodes") else {
+        // No nodes directory, return empty vmlist
+        tracing::debug!("No 'nodes' directory found, returning empty vmlist");
+        return Ok(vmlist);
+    };
+
+    // Iterate through each node directory
+    for node_entry in &nodes_entries {
+        if !node_entry.is_dir() {
+            continue;
+        }
+
+        let node_name = node_entry.name.clone();
+
+        // Validate node name (simple check for valid hostname)
+        if !is_valid_nodename(&node_name) {
+            tracing::warn!("Skipping invalid node name: {}", node_name);
+            continue;
+        }
+
+        tracing::debug!("Scanning node: {}", node_name);
+
+        // Scan qemu-server directory
+        let qemu_path = format!("nodes/{node_name}/qemu-server");
+        if let Ok(qemu_entries) = memdb.readdir(&qemu_path) {
+            for vm_entry in qemu_entries {
+                if let Some(vmid) = parse_vm_config_name(&vm_entry.name) {
+                    if let Some(existing) = vmlist.get(&vmid) {
+                        // Duplicate VMID found
+                        tracing::error!(
+                            vmid,
+                            node = %node_name,
+                            vmtype = "qemu",
+                            existing_node = %existing.node,
+                            existing_type = %existing.vmtype,
+                            "Duplicate VMID found"
+                        );
+                        duplicates.push(vmid);
+                    } else {
+                        vmlist.insert(
+                            vmid,
+                            VmEntry {
+                                vmid,
+                                vmtype: VmType::Qemu,
+                                node: node_name.clone(),
+                                version: vm_entry.version as u32,
+                            },
+                        );
+                        tracing::debug!(vmid, node = %node_name, "Found QEMU VM");
+                    }
+                }
+            }
+        }
+
+        // Scan lxc directory
+        let lxc_path = format!("nodes/{node_name}/lxc");
+        if let Ok(lxc_entries) = memdb.readdir(&lxc_path) {
+            for ct_entry in lxc_entries {
+                if let Some(vmid) = parse_vm_config_name(&ct_entry.name) {
+                    if let Some(existing) = vmlist.get(&vmid) {
+                        // Duplicate VMID found
+                        tracing::error!(
+                            vmid,
+                            node = %node_name,
+                            vmtype = "lxc",
+                            existing_node = %existing.node,
+                            existing_type = %existing.vmtype,
+                            "Duplicate VMID found"
+                        );
+                        duplicates.push(vmid);
+                    } else {
+                        vmlist.insert(
+                            vmid,
+                            VmEntry {
+                                vmid,
+                                vmtype: VmType::Lxc,
+                                node: node_name.clone(),
+                                version: ct_entry.version as u32,
+                            },
+                        );
+                        tracing::debug!(vmid, node = %node_name, "Found LXC CT");
+                    }
+                }
+            }
+        }
+    }
+
+    if !duplicates.is_empty() {
+        tracing::warn!(
+            count = duplicates.len(),
+            ?duplicates,
+            "Found duplicate VMIDs"
+        );
+    }
+
+    tracing::info!(
+        vms = vmlist.len(),
+        nodes = nodes_entries.len(),
+        "VM list recreation complete"
+    );
+
+    Ok(vmlist)
+}
+
+/// Parse VM config filename to extract VMID
+///
+/// Expects format: "{vmid}.conf"
+/// Returns Some(vmid) if valid, None otherwise
+fn parse_vm_config_name(name: &str) -> Option<u32> {
+    if let Some(vmid_str) = name.strip_suffix(".conf") {
+        vmid_str.parse::<u32>().ok()
+    } else {
+        None
+    }
+}
+
+/// Validate node name (simple hostname validation)
+///
+/// Matches C version's valid_nodename() check
+fn is_valid_nodename(name: &str) -> bool {
+    if name.is_empty() || name.len() > 255 {
+        return false;
+    }
+
+    // Hostname must start with alphanumeric
+    if let Some(first_char) = name.chars().next()
+        && !first_char.is_ascii_alphanumeric()
+    {
+        return false;
+    }
+
+    // All characters must be alphanumeric, hyphen, or dot
+    name.chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_vm_config_name() {
+        assert_eq!(parse_vm_config_name("100.conf"), Some(100));
+        assert_eq!(parse_vm_config_name("999.conf"), Some(999));
+        assert_eq!(parse_vm_config_name("123"), None);
+        assert_eq!(parse_vm_config_name("abc.conf"), None);
+        assert_eq!(parse_vm_config_name(""), None);
+    }
+
+    #[test]
+    fn test_is_valid_nodename() {
+        assert!(is_valid_nodename("node1"));
+        assert!(is_valid_nodename("pve-node-01"));
+        assert!(is_valid_nodename("server.example.com"));
+
+        assert!(!is_valid_nodename("")); // empty
+        assert!(!is_valid_nodename("-invalid")); // starts with hyphen
+        assert!(!is_valid_nodename(".invalid")); // starts with dot
+        assert!(!is_valid_nodename("node_1")); // underscore not allowed
+    }
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/tests/checksum_test.rs b/src/pmxcfs-rs/pmxcfs-memdb/tests/checksum_test.rs
new file mode 100644
index 00000000..dab6d9a9
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/tests/checksum_test.rs
@@ -0,0 +1,158 @@
+//! Unit tests for database checksum computation
+//!
+//! These tests verify that:
+//! 1. Checksums are deterministic (same data = same checksum)
+//! 2. Checksums change when data changes
+//! 3. Checksums are independent of insertion order
+
+use pmxcfs_memdb::MemDb;
+use std::time::{SystemTime, UNIX_EPOCH};
+use tempfile::TempDir;
+
+#[test]
+fn test_checksum_deterministic() -> anyhow::Result<()> {
+    let temp_dir = TempDir::new()?;
+    let db_path = temp_dir.path().join("test.db");
+    
+    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+    
+    // Create first database
+    let db1 = MemDb::open(&db_path, true)?;
+    db1.create("/test1.txt", 0, now)?;
+    db1.write("/test1.txt", 0, now, b"content1", false)?;
+    db1.create("/test2.txt", 0, now)?;
+    db1.write("/test2.txt", 0, now, b"content2", false)?;
+    
+    let checksum1 = db1.compute_database_checksum()?;
+    drop(db1);
+    
+    // Create second database with same data
+    std::fs::remove_file(&db_path)?;
+    let db2 = MemDb::open(&db_path, true)?;
+    db2.create("/test1.txt", 0, now)?;
+    db2.write("/test1.txt", 0, now, b"content1", false)?;
+    db2.create("/test2.txt", 0, now)?;
+    db2.write("/test2.txt", 0, now, b"content2", false)?;
+    
+    let checksum2 = db2.compute_database_checksum()?;
+    
+    assert_eq!(checksum1, checksum2, "Checksums should be identical for same data");
+    
+    Ok(())
+}
+
+#[test]
+fn test_checksum_changes_with_data() -> anyhow::Result<()> {
+    let temp_dir = TempDir::new()?;
+    let db_path = temp_dir.path().join("test.db");
+    let db = MemDb::open(&db_path, true)?;
+    
+    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+    
+    // Initial checksum
+    let checksum1 = db.compute_database_checksum()?;
+    
+    // Add a file
+    db.create("/test.txt", 0, now)?;
+    db.write("/test.txt", 0, now, b"content", false)?;
+    let checksum2 = db.compute_database_checksum()?;
+    
+    assert_ne!(checksum1, checksum2, "Checksum should change after adding file");
+    
+    // Modify the file
+    db.write("/test.txt", 0, now + 1, b"modified", false)?;
+    let checksum3 = db.compute_database_checksum()?;
+    
+    assert_ne!(checksum2, checksum3, "Checksum should change after modifying file");
+    
+    Ok(())
+}
+
+#[test]
+fn test_checksum_independent_of_insertion_order() -> anyhow::Result<()> {
+    let temp_dir = TempDir::new()?;
+    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+    
+    // Create first database with files in order A, B, C
+    let db_path1 = temp_dir.path().join("test1.db");
+    let db1 = MemDb::open(&db_path1, true)?;
+    db1.create("/a.txt", 0, now)?;
+    db1.write("/a.txt", 0, now, b"content_a", false)?;
+    db1.create("/b.txt", 0, now)?;
+    db1.write("/b.txt", 0, now, b"content_b", false)?;
+    db1.create("/c.txt", 0, now)?;
+    db1.write("/c.txt", 0, now, b"content_c", false)?;
+    let checksum1 = db1.compute_database_checksum()?;
+    
+    // Create second database with files in order C, B, A
+    let db_path2 = temp_dir.path().join("test2.db");
+    let db2 = MemDb::open(&db_path2, true)?;
+    db2.create("/c.txt", 0, now)?;
+    db2.write("/c.txt", 0, now, b"content_c", false)?;
+    db2.create("/b.txt", 0, now)?;
+    db2.write("/b.txt", 0, now, b"content_b", false)?;
+    db2.create("/a.txt", 0, now)?;
+    db2.write("/a.txt", 0, now, b"content_a", false)?;
+    let checksum2 = db2.compute_database_checksum()?;
+    
+    assert_eq!(checksum1, checksum2, "Checksums should be identical regardless of insertion order");
+    
+    Ok(())
+}
+
+#[test]
+fn test_checksum_with_corosync_conf() -> anyhow::Result<()> {
+    let temp_dir = TempDir::new()?;
+    let db_path = temp_dir.path().join("test.db");
+    let db = MemDb::open(&db_path, true)?;
+    
+    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+    
+    // Simulate what happens when corosync.conf is imported
+    let corosync_content = b"totem {\n  version: 2\n}\n";
+    db.create("/corosync.conf", 0, now)?;
+    db.write("/corosync.conf", 0, now, corosync_content, false)?;
+    
+    let checksum_with_corosync = db.compute_database_checksum()?;
+    
+    // Create another database without corosync.conf
+    std::fs::remove_file(&db_path)?;
+    let db2 = MemDb::open(&db_path, true)?;
+    let checksum_without_corosync = db2.compute_database_checksum()?;
+    
+    assert_ne!(
+        checksum_with_corosync, 
+        checksum_without_corosync,
+        "Checksum should differ when corosync.conf is present"
+    );
+    
+    Ok(())
+}
+
+#[test]
+fn test_checksum_with_different_mtimes() -> anyhow::Result<()> {
+    let temp_dir = TempDir::new()?;
+    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as u32;
+    
+    // Create first database with mtime = now
+    let db_path1 = temp_dir.path().join("test1.db");
+    let db1 = MemDb::open(&db_path1, true)?;
+    db1.create("/test.txt", 0, now)?;
+    db1.write("/test.txt", 0, now, b"content", false)?;
+    let checksum1 = db1.compute_database_checksum()?;
+    
+    // Create second database with mtime = now + 1
+    let db_path2 = temp_dir.path().join("test2.db");
+    let db2 = MemDb::open(&db_path2, true)?;
+    db2.create("/test.txt", 0, now + 1)?;
+    db2.write("/test.txt", 0, now + 1, b"content", false)?;
+    let checksum2 = db2.compute_database_checksum()?;
+    
+    assert_ne!(
+        checksum1, 
+        checksum2,
+        "Checksum should differ when mtime differs (even with same content)"
+    );
+    
+    Ok(())
+}
diff --git a/src/pmxcfs-rs/pmxcfs-memdb/tests/sync_integration_tests.rs b/src/pmxcfs-rs/pmxcfs-memdb/tests/sync_integration_tests.rs
new file mode 100644
index 00000000..a7df870c
--- /dev/null
+++ b/src/pmxcfs-rs/pmxcfs-memdb/tests/sync_integration_tests.rs
@@ -0,0 +1,394 @@
+/// Integration tests for MemDb synchronization operations
+///
+/// Tests the apply_tree_entry and encode_index functionality used during
+/// cluster state synchronization.
+use anyhow::Result;
+use pmxcfs_memdb::{MemDb, ROOT_INODE, TreeEntry};
+use tempfile::TempDir;
+
+fn create_test_db() -> Result<(MemDb, TempDir)> {
+    let temp_dir = TempDir::new()?;
+    let db_path = temp_dir.path().join("test.db");
+    let memdb = MemDb::open(&db_path, true)?;
+    Ok((memdb, temp_dir))
+}
+
+#[test]
+fn test_encode_index_empty_db() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Encode index from empty database (only root entry)
+    let index = memdb.encode_index()?;
+
+    // Should have version and one entry (root)
+    assert_eq!(index.version, 1); // Root created with version 1
+    assert_eq!(index.size, 1);
+    assert_eq!(index.entries.len(), 1);
+    // Root is converted to inode 0 for C wire format compatibility
+    assert_eq!(index.entries[0].inode, 0); // Root in C format (was 1 in Rust)
+
+    Ok(())
+}
+
+#[test]
+fn test_encode_index_with_entries() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create some entries
+    memdb.create("/file1.txt", 0, 1000)?;
+    memdb.create("/dir1", libc::S_IFDIR, 1001)?;
+    memdb.create("/dir1/file2.txt", 0, 1002)?;
+
+    // Encode index
+    let index = memdb.encode_index()?;
+
+    // Should have 4 entries: root, file1.txt, dir1, dir1/file2.txt
+    assert_eq!(index.size, 4);
+    assert_eq!(index.entries.len(), 4);
+
+    // Entries should be sorted by inode
+    for i in 1..index.entries.len() {
+        assert!(
+            index.entries[i].inode > index.entries[i - 1].inode,
+            "Entries not sorted"
+        );
+    }
+
+    // Version should be incremented
+    assert!(index.version >= 4); // At least 4 operations
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_tree_entry_new() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create a new TreeEntry
+    let entry = TreeEntry {
+        inode: 10,
+        parent: ROOT_INODE,
+        version: 100,
+        writer: 2,
+        mtime: 5000,
+        size: 13,
+        entry_type: 8, // DT_REG
+        name: "applied.txt".to_string(),
+        data: b"applied data!".to_vec(),
+    };
+
+    // Apply it
+    memdb.apply_tree_entry(entry.clone())?;
+
+    // Verify it was added
+    let retrieved = memdb.lookup_path("/applied.txt");
+    assert!(retrieved.is_some());
+    let retrieved = retrieved.unwrap();
+
+    assert_eq!(retrieved.inode, 10);
+    assert_eq!(retrieved.name, "applied.txt");
+    assert_eq!(retrieved.version, 100);
+    assert_eq!(retrieved.writer, 2);
+    assert_eq!(retrieved.mtime, 5000);
+    assert_eq!(retrieved.data, b"applied data!");
+
+    // Verify database version was updated
+    assert!(memdb.get_version() >= 100);
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_tree_entry_update() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create an initial entry
+    memdb.create("/update.txt", 0, 1000)?;
+    memdb.write("/update.txt", 0, 1001, b"original", false)?;
+
+    let initial = memdb.lookup_path("/update.txt").unwrap();
+    let initial_inode = initial.inode;
+
+    // Apply an updated version
+    let updated = TreeEntry {
+        inode: initial_inode,
+        parent: ROOT_INODE,
+        version: 200,
+        writer: 3,
+        mtime: 2000,
+        size: 7,
+        entry_type: 8,
+        name: "update.txt".to_string(),
+        data: b"updated".to_vec(),
+    };
+
+    memdb.apply_tree_entry(updated)?;
+
+    // Verify it was updated
+    let retrieved = memdb.lookup_path("/update.txt").unwrap();
+    assert_eq!(retrieved.inode, initial_inode); // Same inode
+    assert_eq!(retrieved.version, 200); // Updated version
+    assert_eq!(retrieved.writer, 3); // Updated writer
+    assert_eq!(retrieved.mtime, 2000); // Updated mtime
+    assert_eq!(retrieved.data, b"updated"); // Updated data
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_tree_entry_directory() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Apply a directory entry
+    let dir_entry = TreeEntry {
+        inode: 20,
+        parent: ROOT_INODE,
+        version: 50,
+        writer: 1,
+        mtime: 3000,
+        size: 0,
+        entry_type: 4, // DT_DIR
+        name: "newdir".to_string(),
+        data: Vec::new(),
+    };
+
+    memdb.apply_tree_entry(dir_entry)?;
+
+    // Verify directory was created
+    let retrieved = memdb.lookup_path("/newdir").unwrap();
+    assert_eq!(retrieved.inode, 20);
+    assert!(retrieved.is_dir());
+    assert_eq!(retrieved.name, "newdir");
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_tree_entry_move() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create initial structure
+    memdb.create("/olddir", libc::S_IFDIR, 1000)?;
+    memdb.create("/newdir", libc::S_IFDIR, 1001)?;
+    memdb.create("/olddir/file.txt", 0, 1002)?;
+
+    let file = memdb.lookup_path("/olddir/file.txt").unwrap();
+    let file_inode = file.inode;
+    let newdir = memdb.lookup_path("/newdir").unwrap();
+
+    // Apply entry that moves file to newdir
+    let moved = TreeEntry {
+        inode: file_inode,
+        parent: newdir.inode, // New parent
+        version: 100,
+        writer: 2,
+        mtime: 2000,
+        size: 0,
+        entry_type: 8,
+        name: "file.txt".to_string(),
+        data: Vec::new(),
+    };
+
+    memdb.apply_tree_entry(moved)?;
+
+    // Verify file moved
+    assert!(memdb.lookup_path("/olddir/file.txt").is_none());
+    assert!(memdb.lookup_path("/newdir/file.txt").is_some());
+    let retrieved = memdb.lookup_path("/newdir/file.txt").unwrap();
+    assert_eq!(retrieved.inode, file_inode);
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_multiple_entries() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Apply multiple entries simulating a sync
+    let entries = vec![
+        TreeEntry {
+            inode: 10,
+            parent: ROOT_INODE,
+            version: 100,
+            writer: 2,
+            mtime: 5000,
+            size: 0,
+            entry_type: 4, // Dir
+            name: "configs".to_string(),
+            data: Vec::new(),
+        },
+        TreeEntry {
+            inode: 11,
+            parent: 10,
+            version: 101,
+            writer: 2,
+            mtime: 5001,
+            size: 12,
+            entry_type: 8, // File
+            name: "config1.txt".to_string(),
+            data: b"config data1".to_vec(),
+        },
+        TreeEntry {
+            inode: 12,
+            parent: 10,
+            version: 102,
+            writer: 2,
+            mtime: 5002,
+            size: 12,
+            entry_type: 8,
+            name: "config2.txt".to_string(),
+            data: b"config data2".to_vec(),
+        },
+    ];
+
+    // Apply all entries
+    for entry in entries {
+        memdb.apply_tree_entry(entry)?;
+    }
+
+    // Verify all were applied correctly
+    assert!(memdb.lookup_path("/configs").is_some());
+    assert!(memdb.lookup_path("/configs/config1.txt").is_some());
+    assert!(memdb.lookup_path("/configs/config2.txt").is_some());
+
+    let config1 = memdb.lookup_path("/configs/config1.txt").unwrap();
+    assert_eq!(config1.data, b"config data1");
+
+    let config2 = memdb.lookup_path("/configs/config2.txt").unwrap();
+    assert_eq!(config2.data, b"config data2");
+
+    // Verify database version
+    assert_eq!(memdb.get_version(), 102);
+
+    Ok(())
+}
+
+#[test]
+fn test_encode_decode_round_trip() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create some entries
+    memdb.create("/file1.txt", 0, 1000)?;
+    memdb.write("/file1.txt", 0, 1001, b"data1", false)?;
+    memdb.create("/dir1", libc::S_IFDIR, 1002)?;
+    memdb.create("/dir1/file2.txt", 0, 1003)?;
+    memdb.write("/dir1/file2.txt", 0, 1004, b"data2", false)?;
+
+    // Encode index
+    let index = memdb.encode_index()?;
+    let serialized = index.serialize();
+
+    // Deserialize
+    let deserialized = pmxcfs_memdb::MemDbIndex::deserialize(&serialized)?;
+
+    // Verify roundtrip
+    assert_eq!(deserialized.version, index.version);
+    assert_eq!(deserialized.last_inode, index.last_inode);
+    assert_eq!(deserialized.writer, index.writer);
+    assert_eq!(deserialized.mtime, index.mtime);
+    assert_eq!(deserialized.size, index.size);
+    assert_eq!(deserialized.entries.len(), index.entries.len());
+
+    for (orig, deser) in index.entries.iter().zip(deserialized.entries.iter()) {
+        assert_eq!(deser.inode, orig.inode);
+        assert_eq!(deser.digest, orig.digest);
+    }
+
+    Ok(())
+}
+
+#[test]
+fn test_apply_tree_entry_persistence() -> Result<()> {
+    let temp_dir = TempDir::new()?;
+    let db_path = temp_dir.path().join("persist.db");
+
+    // Create database and apply entry
+    {
+        let memdb = MemDb::open(&db_path, true)?;
+        let entry = TreeEntry {
+            inode: 15,
+            parent: ROOT_INODE,
+            version: 75,
+            writer: 3,
+            mtime: 7000,
+            size: 9,
+            entry_type: 8,
+            name: "persist.txt".to_string(),
+            data: b"persisted".to_vec(),
+        };
+        memdb.apply_tree_entry(entry)?;
+    }
+
+    // Reopen database and verify entry persisted
+    {
+        let memdb = MemDb::open(&db_path, false)?;
+        let retrieved = memdb.lookup_path("/persist.txt");
+        assert!(retrieved.is_some());
+        let retrieved = retrieved.unwrap();
+        assert_eq!(retrieved.inode, 15);
+        assert_eq!(retrieved.version, 75);
+        assert_eq!(retrieved.data, b"persisted");
+    }
+
+    Ok(())
+}
+
+#[test]
+fn test_index_digest_stability() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create entry
+    memdb.create("/stable.txt", 0, 1000)?;
+    memdb.write("/stable.txt", 0, 1001, b"stable data", false)?;
+
+    // Encode index twice
+    let index1 = memdb.encode_index()?;
+    let index2 = memdb.encode_index()?;
+
+    // Digests should be identical
+    assert_eq!(index1.entries.len(), index2.entries.len());
+    for (e1, e2) in index1.entries.iter().zip(index2.entries.iter()) {
+        assert_eq!(e1.inode, e2.inode);
+        assert_eq!(e1.digest, e2.digest, "Digests should be stable");
+    }
+
+    Ok(())
+}
+
+#[test]
+fn test_index_digest_changes_on_modification() -> Result<()> {
+    let (memdb, _temp_dir) = create_test_db()?;
+
+    // Create entry
+    memdb.create("/change.txt", 0, 1000)?;
+    memdb.write("/change.txt", 0, 1001, b"original", false)?;
+
+    // Get initial digest
+    let index1 = memdb.encode_index()?;
+    let original_digest = index1
+        .entries
+        .iter()
+        .find(|e| e.inode != 1) // Not root
+        .unwrap()
+        .digest;
+
+    // Modify the file
+    memdb.write("/change.txt", 0, 1002, b"modified", false)?;
+
+    // Get new digest
+    let index2 = memdb.encode_index()?;
+    let modified_digest = index2
+        .entries
+        .iter()
+        .find(|e| e.inode != 1) // Not root
+        .unwrap()
+        .digest;
+
+    // Digest should change
+    assert_ne!(
+        original_digest, modified_digest,
+        "Digest should change after modification"
+    );
+
+    Ok(())
+}
-- 
2.47.3



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


  parent reply	other threads:[~2026-01-07  9:15 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-01-06 14:24 [pve-devel] [PATCH pve-cluster 00/15 v1] Rewrite pmxcfs with Rust Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 01/15] pmxcfs-rs: add workspace and pmxcfs-api-types crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 02/15] pmxcfs-rs: add pmxcfs-config crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 03/15] pmxcfs-rs: add pmxcfs-logger crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 04/15] pmxcfs-rs: add pmxcfs-rrd crate Kefu Chai
2026-01-06 14:24 ` Kefu Chai [this message]
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 06/15] pmxcfs-rs: add pmxcfs-status crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 07/15] pmxcfs-rs: add pmxcfs-test-utils infrastructure crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 08/15] pmxcfs-rs: add pmxcfs-services crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 09/15] pmxcfs-rs: add pmxcfs-ipc crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 10/15] pmxcfs-rs: add pmxcfs-dfsm crate Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 11/15] pmxcfs-rs: vendor patched rust-corosync for CPG compatibility Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 13/15] pmxcfs-rs: add integration and workspace tests Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 14/15] pmxcfs-rs: add Makefile for build automation Kefu Chai
2026-01-06 14:24 ` [pve-devel] [PATCH pve-cluster 15/15] pmxcfs-rs: add project documentation Kefu Chai

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260106142440.2368585-6-k.chai@proxmox.com \
    --to=k.chai@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal