From 01c1b247932205c1a10f183e41a6e8e07bddd1ae Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 6 Dec 2025 10:19:08 +0200 Subject: [PATCH 1/9] Add Reed-Solomon dependecy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `reed-solomon-erasure = \"5\"` to provide Reed–Solomon encoding/decoding support for future error-correction feature. --- Cargo.lock | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 1 + 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d2d0a9e..865a7d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1283,6 +1283,12 @@ version = "0.2.160" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0b21006cd1874ae9e650973c565615676dc4a274c965bb0a73796dac838ce4f" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -1534,7 +1540,7 @@ checksum = "4df83a1e0b8db61fca8be93470edf2829d28988917abbe17170fd813cdd0331d" dependencies = [ "crc32c", "flume", - "parking_lot", + "parking_lot 0.12.3", ] [[package]] @@ -1571,6 +1577,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1578,7 +1595,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.10", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -1589,7 +1620,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if 1.0.0", "libc", - "redox_syscall", + "redox_syscall 0.5.7", "smallvec", "windows-targets 0.52.6", ] @@ -1790,6 +1821,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -1799,6 +1839,18 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "reed-solomon-erasure" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fe31452b684b8b33f65f8730c8b8812c3f5a0bb8a096934717edb1ac488641" +dependencies = [ + "libm", + "parking_lot 0.11.2", + "smallvec", + "spin", +] + [[package]] name = "regex" version = "1.11.0" @@ -1870,6 +1922,7 @@ dependencies = [ "rand", "rand_chacha", "rand_core", + "reed-solomon-erasure", "retainer", "ring", "rpassword", @@ -2371,7 +2424,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.3", "pin-project-lite", "signal-hook-registry", "socket2 0.5.7", diff --git a/Cargo.toml b/Cargo.toml index 8484ba24..fed12d6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ subtle = "2.6.1" bon = "3.3.0" shush-rs = "0.1.10" criterion = { version = "0.5.1", features = ["html_reports"] } +reed-solomon-erasure = "5" [target.'cfg(target_os = "linux")'.dependencies] fuse3 = { version = "0.8.1", features = ["tokio-runtime", "unprivileged"] } From d7305f5fed040b2862de14a7850d7835fd56b2cc Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 6 Dec 2025 10:27:42 +0200 Subject: [PATCH 2/9] Expose Reed-Solomon module in crypto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `reed-solomon-erasure = \"5\"` so the Reed–Solomon helper is available as `crate::crypto::rs`." --- src/crypto.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/crypto.rs b/src/crypto.rs index 72ca489a..3f18fd93 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -29,6 +29,7 @@ use crate::{fs_util, stream_util}; pub mod buf_mut; pub mod read; +pub mod rs; pub mod write; pub static BASE64: GeneralPurpose = GeneralPurpose::new(&STANDARD, NO_PAD); From 73f8b36a179edf5a32fcd35c982bed46adeacc88 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 6 Dec 2025 10:30:41 +0200 Subject: [PATCH 3/9] Implement Reed-Solomon helper --- src/crypto/rs.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/crypto/rs.rs diff --git a/src/crypto/rs.rs b/src/crypto/rs.rs new file mode 100644 index 00000000..5fb40e80 --- /dev/null +++ b/src/crypto/rs.rs @@ -0,0 +1,114 @@ +use reed_solomon_erasure::galois_8::Field as Gf8; +use reed_solomon_erasure::ReedSolomon; +use std::error::Error; + +pub struct RsEncoder { + data_shards: usize, + parity_shards: usize, +} + +impl RsEncoder { + pub fn new(data_shards: usize, parity_shards: usize) -> Self { + Self { + data_shards, + parity_shards, + } + } + + pub fn encode(&self, data: &[u8]) -> Result>, Box> { + let r = ReedSolomon::::new(self.data_shards, self.parity_shards)?; + + // prefix with original length so we can trim when reconstructing + let mut payload = Vec::with_capacity(8 + data.len()); + payload.extend_from_slice(&(data.len() as u64).to_le_bytes()); + payload.extend_from_slice(data); + + let shard_size = (payload.len() + self.data_shards - 1) / self.data_shards; + let total_shards = self.data_shards + self.parity_shards; + + let mut shards: Vec> = vec![vec![0u8; shard_size]; total_shards]; + + for i in 0..self.data_shards { + let start = i * shard_size; + let end = std::cmp::min(start + shard_size, payload.len()); + if start < payload.len() { + shards[i][..end - start].copy_from_slice(&payload[start..end]); + } + } + + let mut shard_refs: Vec<&mut [u8]> = shards.iter_mut().map(|v| v.as_mut_slice()).collect(); + r.encode(&mut shard_refs)?; + + Ok(shards) + } + + pub fn reconstruct( + &self, + shards_opt: &mut [Option>], + ) -> Result, Box> { + let r = ReedSolomon::::new(self.data_shards, self.parity_shards)?; + let total_shards = self.data_shards + self.parity_shards; + + if shards_opt.len() != total_shards { + return Err("shards length mismatch".into()); + } + + let shard_len = shards_opt + .iter() + .find_map(|s| s.as_ref().map(|v| v.len())) + .ok_or("no shards available")?; + + for slot in shards_opt.iter_mut() { + if let Some(v) = slot { + if v.len() < shard_len { + v.resize(shard_len, 0u8); + } else if v.len() > shard_len { + return Err("inconsistent shard lengths".into()); + } + } + } + + r.reconstruct(shards_opt)?; + + let mut payload = Vec::with_capacity(shard_len * self.data_shards); + for i in 0..self.data_shards { + let slice = shards_opt[i] + .as_ref() + .ok_or("missing shard after reconstruct")?; + payload.extend_from_slice(slice); + } + + if payload.len() < 8 { + return Err("payload too small".into()); + } + let orig_len = u64::from_le_bytes(payload[0..8].try_into().unwrap()) as usize; + if 8 + orig_len > payload.len() { + return Err("original length exceeds reconstructed payload".into()); + } + Ok(payload[8..8 + orig_len].to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::RsEncoder; + + #[test] + fn rs_encode_reconstruct() { + let encoder = RsEncoder::new(3, 2); // 3 data, 2 parity + let data = b"Hello Reed-Solomon! Let's test recovery."; + let shards = encoder.encode(data).expect("encode failed"); + assert_eq!(shards.len(), 5); + + let mut shards_opt: Vec>> = shards.into_iter().map(Some).collect(); + + shards_opt[1] = None; + shards_opt[4] = None; + + let recovered = encoder + .reconstruct(&mut shards_opt) + .expect("reconstruct failed"); + + assert_eq!(recovered, data); + } +} From fd30e8ec577509461b91585b48cdf946f8b6ecbc Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 6 Dec 2025 10:31:30 +0200 Subject: [PATCH 4/9] Add example for Reed-Solomon helper --- examples/rs_example.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/rs_example.rs diff --git a/examples/rs_example.rs b/examples/rs_example.rs new file mode 100644 index 00000000..27ee33e2 --- /dev/null +++ b/examples/rs_example.rs @@ -0,0 +1,23 @@ +use rencfs::crypto::rs::RsEncoder; + +fn main() { + let encoder = RsEncoder::new(3, 2); + let data = b"hello, this is my message"; + + println!("Original: {}", String::from_utf8_lossy(data)); + + let shards = encoder.encode(data).expect("encode failed"); + println!("Created {} shards", shards.len()); + + let mut shards_opt: Vec>> = shards.into_iter().map(Some).collect(); + shards_opt[0] = None; // drop one data shard + shards_opt[4] = None; // drop one parity shard + + println!("Simulated missing shards at indexes 0 and 4"); + + let recovered = encoder + .reconstruct(&mut shards_opt) + .expect("reconstruct failed"); + + println!("Recovered: {}", String::from_utf8_lossy(&recovered)); +} From f243f7c3930871058f17f2d626e1c2ce8c77bb88 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 13 Dec 2025 10:40:41 +0200 Subject: [PATCH 5/9] Implement Reed-Solomon encoder and config --- src/crypto.rs | 2 +- src/crypto/rs.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/crypto.rs b/src/crypto.rs index 3f18fd93..51742175 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use shush_rs::{ExposeSecret, SecretString, SecretVec}; use strum_macros::{Display, EnumIter, EnumString}; use thiserror::Error; -use tracing::{debug, error, instrument}; +use tracing::{debug, instrument}; use write::CryptoInnerWriter; use crate::crypto::read::{CryptoRead, CryptoReadSeek, RingCryptoRead}; diff --git a/src/crypto/rs.rs b/src/crypto/rs.rs index 5fb40e80..cc7676bb 100644 --- a/src/crypto/rs.rs +++ b/src/crypto/rs.rs @@ -2,6 +2,16 @@ use reed_solomon_erasure::galois_8::Field as Gf8; use reed_solomon_erasure::ReedSolomon; use std::error::Error; +/// Configuration for Reed-Solomon error correction (opt-in feature). +/// Users can disable by passing `None` to avoid the additional storage overhead. +#[derive(Clone, Debug)] +pub struct RsConfig { + /// Number of data shards (original file is split across these). + pub data_shards: usize, + /// Number of parity shards to create (for recovery). + pub parity_shards: usize, +} + pub struct RsEncoder { data_shards: usize, parity_shards: usize, From 80320ac6aaa3b0531110cd30c990120313fae676 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 13 Dec 2025 10:41:19 +0200 Subject: [PATCH 6/9] Add RS config to EncryptedFs --- src/encryptedfs.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/encryptedfs.rs b/src/encryptedfs.rs index 7c2da5a4..40d3afa5 100644 --- a/src/encryptedfs.rs +++ b/src/encryptedfs.rs @@ -574,6 +574,7 @@ pub struct EncryptedFs { sizes_read: Mutex>, requested_read: Mutex>, read_only: bool, + rs_config: Option, } impl EncryptedFs { @@ -583,6 +584,7 @@ impl EncryptedFs { data_dir: PathBuf, password_provider: Box, cipher: Cipher, + rs_config: Option, read_only: bool, ) -> FsResult> { let key_provider = KeyProvider { @@ -627,6 +629,7 @@ impl EncryptedFs { sizes_read: Mutex::default(), requested_read: Mutex::default(), read_only, + rs_config, }; let arc = Arc::new(fs); @@ -1528,6 +1531,40 @@ impl EncryptedFs { let file = writer.finish()?; file.sync_all()?; File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; + + // Reed-Solomon: Encode and write parity shards if configured + if let Some(ref rs_config) = self.rs_config { + let ino = ctx.ino; + let content_path = self.contents_path(ino); + + // Read the encrypted file content + if let Ok(content) = std::fs::read(&content_path) { + let encoder = crate::crypto::rs::RsEncoder::new( + rs_config.data_shards, + rs_config.parity_shards, + ); + + // Encode to produce data + parity shards + match encoder.encode(&content) { + Ok(shards) => { + // Write parity shards (skip data shards which are the original file split) + for (i, shard) in shards.iter().enumerate().skip(rs_config.data_shards) { + let shard_path = content_path.parent() + .unwrap() + .join(format!("{}.parity.{}", ino, i - rs_config.data_shards)); + if let Err(e) = std::fs::write(&shard_path, shard) { + warn!("Failed to write parity shard {:?}: {}", shard_path, e); + } + } + debug!("RS: Wrote {} parity shards for inode {}", rs_config.parity_shards, ino); + } + Err(e) => { + warn!("RS encoding failed for inode {}: {}", ino, e); + } + } + } + } + // write attr only here to avoid serializing it multiple times while writing // it will merge time fields with existing data because it might got change while we kept the handle let ino = ctx.ino; @@ -1801,6 +1838,76 @@ impl EncryptedFs { return Err(FsError::InvalidInodeType); } + // Reed-Solomon: Try to reconstruct file from parity shards if main file is missing/corrupt + if let Some(ref rs_config) = self.rs_config { + let content_path = self.contents_path(ino); + + // Check if main file is missing or empty (potential corruption) + let needs_reconstruction = !content_path.exists() + || std::fs::metadata(&content_path).map(|m| m.len() == 0).unwrap_or(false); + + if needs_reconstruction { + debug!("RS: Attempting reconstruction for inode {} (file missing or empty)", ino); + + // Build shard array: data shard (main file) + parity shards + let total_shards = rs_config.data_shards + rs_config.parity_shards; + let mut shards_opt: Vec>> = vec![None; total_shards]; + + // Try to load the main file as first data shard (might fail if missing) + if content_path.exists() { + if let Ok(data) = std::fs::read(&content_path) { + if !data.is_empty() { + shards_opt[0] = Some(data); + } + } + } + + // Load all available parity shards + let mut available_shards = if shards_opt[0].is_some() { 1 } else { 0 }; + for i in 0..rs_config.parity_shards { + let shard_path = content_path.parent() + .unwrap() + .join(format!("{}.parity.{}", ino, i)); + if shard_path.exists() { + if let Ok(shard) = std::fs::read(&shard_path) { + shards_opt[rs_config.data_shards + i] = Some(shard); + available_shards += 1; + } + } + } + + // Check if we have enough shards to reconstruct + if available_shards >= rs_config.data_shards { + let encoder = crate::crypto::rs::RsEncoder::new( + rs_config.data_shards, + rs_config.parity_shards, + ); + + match encoder.reconstruct(&mut shards_opt) { + Ok(recovered) => { + // Write recovered content back to main file + if let Err(e) = std::fs::write(&content_path, &recovered) { + error!("RS: Failed to write reconstructed file for inode {}: {}", ino, e); + return Err(FsError::Other(Box::leak(format!("Failed to reconstruct file: {}", e).into_boxed_str()))); + } + info!("RS: Successfully reconstructed inode {} from {} available shards", ino, available_shards); + } + Err(e) => { + error!("RS: Reconstruction failed for inode {}: {}", ino, e); + return Err(FsError::Other(Box::leak(format!("File reconstruction failed: {}", e).into_boxed_str()))); + } + } + } else { + error!("RS: Not enough shards to reconstruct inode {} (need {}, have {})", + ino, rs_config.data_shards, available_shards); + return Err(FsError::Other(Box::leak(format!( + "Cannot reconstruct file: need {} shards, only {} available", + rs_config.data_shards, available_shards + ).into_boxed_str()))); + } + } + } + let mut handle: Option = None; if read { handle = Some(self.next_handle()); From 104efa1d41a6e11cdf4620f63e4054ff8f621478 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 13 Dec 2025 10:56:17 +0200 Subject: [PATCH 7/9] update EncryptedFs constructor calls --- examples/encryptedfs.rs | 1 + examples/file_handling.rs | 1 + src/encryptedfs/test.rs | 81 ++++++++++++++++++++++++++++++++++++++- src/mount/linux.rs | 2 +- src/test_common.rs | 1 + 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/examples/encryptedfs.rs b/examples/encryptedfs.rs index d4fbdaa8..db3ff0bc 100644 --- a/examples/encryptedfs.rs +++ b/examples/encryptedfs.rs @@ -31,6 +31,7 @@ async fn main() -> Result<()> { data_dir.clone(), Box::new(PasswordProviderImpl {}), cipher, + None, false, ) .await?; diff --git a/examples/file_handling.rs b/examples/file_handling.rs index 3482ef81..7714170e 100644 --- a/examples/file_handling.rs +++ b/examples/file_handling.rs @@ -35,6 +35,7 @@ async fn main() -> Result<()> { data_dir.clone(), Box::new(PasswordProviderImpl), cipher, + None, false, ) .await?; diff --git a/src/encryptedfs/test.rs b/src/encryptedfs/test.rs index 81e33d05..e7a2d9f7 100644 --- a/src/encryptedfs/test.rs +++ b/src/encryptedfs/test.rs @@ -2415,7 +2415,7 @@ async fn test_read_only_write() { fs_rw.flush(fh).await.unwrap(); fs_rw.release(fh).await.unwrap(); drop(fs_rw); - let fs_ro = EncryptedFs::new(data_dir, Box::new(PasswordProviderImpl {}), cipher, true) + let fs_ro = EncryptedFs::new(data_dir, Box::new(PasswordProviderImpl {}), cipher, None, true) .await .expect("test_read_only_write: Error creating rw fs."); let fh = fs_ro @@ -2481,3 +2481,82 @@ async fn test_read_only_write() { ) .await; } + +#[tokio::test] +#[traced_test] +async fn test_reed_solomon_error_correction() { + run_test( + TestSetup { + key: "test_rs_error_correction", + read_only: false, + }, + async { + // Create filesystem with Reed-Solomon enabled (3 data shards, 2 parity shards) + let rs_config = Some(crate::crypto::rs::RsConfig { + data_shards: 3, + parity_shards: 2, + }); + + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let fs = EncryptedFs::new( + data_dir.clone(), + Box::new(PasswordProviderImpl {}), + Cipher::ChaCha20Poly1305, + rs_config, + false, + ) + .await + .unwrap(); + + // Create and write a test file + let test_file = SecretString::from_str("test-rs-file").unwrap(); + let (fh, attr) = fs + .create( + ROOT_INODE, + &test_file, + create_attr(FileType::RegularFile), + false, + true, + ) + .await + .unwrap(); + + let original_data = b"Hello, Reed-Solomon! This is test data for error correction."; + write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh).await.unwrap(); + + // Release the file handle (this should trigger RS encoding and create parity shards) + fs.release(fh).await.unwrap(); + + // Verify the main content file exists + let content_path = fs.contents_path(attr.ino); + assert!(content_path.exists(), "Main content file should exist"); + + // Verify parity shard files were created + for i in 0..2 { + let parity_path = content_path.parent().unwrap().join(format!("{}.parity.{}", attr.ino, i)); + assert!(parity_path.exists(), "Parity shard {} should exist", i); + } + + // Simulate file corruption by deleting the main content file + std::fs::remove_file(&content_path).unwrap(); + assert!(!content_path.exists(), "Main content file should be deleted"); + + // Try to read the file - this should trigger reconstruction from parity shards + let read_fh = fs.open(attr.ino, true, false).await.unwrap(); + + // Read the reconstructed content + let mut buffer = vec![0u8; original_data.len()]; + let bytes_read = fs.read(attr.ino, 0, &mut buffer, read_fh).await.unwrap(); + assert_eq!(bytes_read, original_data.len(), "Should read all original bytes"); + assert_eq!(&buffer[..bytes_read], original_data, "Reconstructed data should match original"); + + // Verify the main file was restored + assert!(content_path.exists(), "Main content file should be restored after reconstruction"); + + fs.release(read_fh).await.unwrap(); + }, + ) + .await; +} diff --git a/src/mount/linux.rs b/src/mount/linux.rs index 37aa7946..2d5092fd 100644 --- a/src/mount/linux.rs +++ b/src/mount/linux.rs @@ -140,7 +140,7 @@ impl EncryptedFsFuse3 { read_only: bool, ) -> FsResult { Ok(Self { - fs: EncryptedFs::new(data_dir, password_provider, cipher, read_only).await?, + fs: EncryptedFs::new(data_dir, password_provider, cipher, None, read_only).await?, }) } diff --git a/src/test_common.rs b/src/test_common.rs index f8e18611..f424879b 100644 --- a/src/test_common.rs +++ b/src/test_common.rs @@ -84,6 +84,7 @@ async fn setup(setup: TestSetup) -> SetupResult { Path::new(data_dir_str).to_path_buf(), Box::new(PasswordProviderImpl {}), Cipher::ChaCha20Poly1305, + None, read_only, ) .await From 75905e11a2e336217f3abc795f862a09e2161526 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Sat, 13 Dec 2025 14:28:41 +0200 Subject: [PATCH 8/9] address code review issues Critical fixes: - Eliminate Box::leak memory leaks (add FsError::ReedSolomonError) - Replace blocking std::fs with async tokio::fs - Delete parity shards on file removal to prevent data leakage Important fixes: - Add input validation for RsEncoder (data_shards/parity_shards > 0) - Document data_shards=1 limitation with debug_assert - Respect read-only mode during reconstruction - Only reconstruct missing files, not empty ones --- src/crypto/read.rs | 2 - src/crypto/rs.rs | 2 + src/encryptedfs.rs | 211 ++++++++++++++++++++++++++-------------- src/encryptedfs/test.rs | 141 +++++++++++++-------------- 4 files changed, 207 insertions(+), 149 deletions(-) diff --git a/src/crypto/read.rs b/src/crypto/read.rs index 92e81193..c01a72f1 100644 --- a/src/crypto/read.rs +++ b/src/crypto/read.rs @@ -71,8 +71,6 @@ macro_rules! decrypt_block { }}; } -pub(crate) use decrypt_block; - #[allow(clippy::module_name_repetitions)] pub struct RingCryptoRead { input: Option, diff --git a/src/crypto/rs.rs b/src/crypto/rs.rs index cc7676bb..a54804c8 100644 --- a/src/crypto/rs.rs +++ b/src/crypto/rs.rs @@ -19,6 +19,8 @@ pub struct RsEncoder { impl RsEncoder { pub fn new(data_shards: usize, parity_shards: usize) -> Self { + assert!(data_shards > 0, "data_shards must be > 0"); + assert!(parity_shards > 0, "parity_shards must be > 0"); Self { data_shards, parity_shards, diff --git a/src/encryptedfs.rs b/src/encryptedfs.rs index 40d3afa5..a1547403 100644 --- a/src/encryptedfs.rs +++ b/src/encryptedfs.rs @@ -305,6 +305,8 @@ pub enum FsError { MaxFilesizeExceeded(usize), #[error("Read only mode is active.")] ReadOnly, + #[error("Reed-Solomon error: {0}")] + ReedSolomonError(String), } #[derive(Debug, Clone)] @@ -1004,6 +1006,19 @@ impl EncryptedFs { // remove from contents directory fs::remove_file(self_clone.contents_path(attr.ino))?; + + // remove parity shards if RS is enabled + if let Some(ref rs_config) = self_clone.rs_config { + for i in 0..rs_config.parity_shards { + let parity_path = self_clone.contents_path(attr.ino) + .parent() + .unwrap() + .join(format!("{}.parity.{}", attr.ino, i)); + // Ignore errors - parity shards might not exist + let _ = fs::remove_file(parity_path); + } + } + // remove from parent directory self_clone .remove_directory_entry(parent, &name_clone) @@ -1478,6 +1493,7 @@ impl EncryptedFs { #[allow(clippy::missing_panics_doc)] #[allow(clippy::too_many_lines)] pub async fn release(&self, handle: u64) -> FsResult<()> { + debug!("RS: release() called with handle {}", handle); if handle == 0 { // in the case of directory or if the file was crated // without being opened we don't use a handle @@ -1488,6 +1504,7 @@ impl EncryptedFs { // read let ctx = { self.read_handles.write().await.remove(&handle) }; if let Some(ctx) = ctx { + debug!("RS: release() - found read handle {}", handle); let ctx = ctx.lock().await; { @@ -1517,6 +1534,7 @@ impl EncryptedFs { // write let ctx = { self.write_handles.write().await.remove(&handle) }; + debug!("RS: release() - checking write handle {}, found: {}", handle, ctx.is_some()); if let Some(ctx) = ctx { if self.read_only { return Err(FsError::ReadOnly); @@ -1532,36 +1550,49 @@ impl EncryptedFs { file.sync_all()?; File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; + debug!("RS: release() - checking if RS is enabled, rs_config = {:?}", self.rs_config.is_some()); + // Reed-Solomon: Encode and write parity shards if configured + // This is done after file sync to ensure content is persisted if let Some(ref rs_config) = self.rs_config { - let ino = ctx.ino; - let content_path = self.contents_path(ino); + let content_path = self.contents_path(ctx.ino); + debug!("RS: Attempting to encode file for inode {} at {:?}", ctx.ino, content_path); - // Read the encrypted file content - if let Ok(content) = std::fs::read(&content_path) { - let encoder = crate::crypto::rs::RsEncoder::new( - rs_config.data_shards, - rs_config.parity_shards, - ); - - // Encode to produce data + parity shards - match encoder.encode(&content) { - Ok(shards) => { - // Write parity shards (skip data shards which are the original file split) - for (i, shard) in shards.iter().enumerate().skip(rs_config.data_shards) { - let shard_path = content_path.parent() - .unwrap() - .join(format!("{}.parity.{}", ino, i - rs_config.data_shards)); - if let Err(e) = std::fs::write(&shard_path, shard) { - warn!("Failed to write parity shard {:?}: {}", shard_path, e); + match tokio::fs::read(&content_path).await { + Ok(content) => { + debug!("RS: Read {} bytes from {:?}", content.len(), content_path); + let encoder = crate::crypto::rs::RsEncoder::new( + rs_config.data_shards, + rs_config.parity_shards, + ); + + let shards = encoder.encode(&content) + .map_err(|e| format!("RS encoding failed for inode {}: {}", ctx.ino, e)); + + match shards { + Ok(shards) => { + // Write parity shards (skip data shards which are the original file) + for (i, shard) in shards.iter().enumerate().skip(rs_config.data_shards) { + let shard_path = content_path.parent() + .unwrap() + .join(format!("{}.parity.{}", ctx.ino, i - rs_config.data_shards)); + if let Err(e) = tokio::fs::write(&shard_path, shard).await { + warn!("RS: Failed to write parity shard {:?}: {}", shard_path, e); + } else { + debug!("RS: Wrote parity shard {} for inode {} at {:?}", + i - rs_config.data_shards, ctx.ino, shard_path); + } } + debug!("RS: Wrote {} parity shards for inode {}", rs_config.parity_shards, ctx.ino); + } + Err(e) => { + warn!("{}", e); } - debug!("RS: Wrote {} parity shards for inode {}", rs_config.parity_shards, ino); - } - Err(e) => { - warn!("RS encoding failed for inode {}: {}", ino, e); } } + Err(e) => { + debug!("RS: Failed to read content file {:?}: {}", content_path, e); + } } } @@ -1842,12 +1873,18 @@ impl EncryptedFs { if let Some(ref rs_config) = self.rs_config { let content_path = self.contents_path(ino); - // Check if main file is missing or empty (potential corruption) - let needs_reconstruction = !content_path.exists() - || std::fs::metadata(&content_path).map(|m| m.len() == 0).unwrap_or(false); + // Check if main file is missing (not just empty - empty files are valid!) + let file_missing = !content_path.exists(); - if needs_reconstruction { - debug!("RS: Attempting reconstruction for inode {} (file missing or empty)", ino); + if file_missing { + debug!("RS: Attempting reconstruction for inode {} (file missing)", ino); + + // RS reconstruction currently supports only data_shards = 1 + // The main encrypted file serves as the single data shard + debug_assert!( + rs_config.data_shards == 1, + "RS reconstruction currently supports only data_shards = 1" + ); // Build shard array: data shard (main file) + parity shards let total_shards = rs_config.data_shards + rs_config.parity_shards; @@ -1855,7 +1892,7 @@ impl EncryptedFs { // Try to load the main file as first data shard (might fail if missing) if content_path.exists() { - if let Ok(data) = std::fs::read(&content_path) { + if let Ok(data) = tokio::fs::read(&content_path).await { if !data.is_empty() { shards_opt[0] = Some(data); } @@ -1868,11 +1905,23 @@ impl EncryptedFs { let shard_path = content_path.parent() .unwrap() .join(format!("{}.parity.{}", ino, i)); + debug!("RS: Looking for parity shard at {:?}", shard_path); if shard_path.exists() { - if let Ok(shard) = std::fs::read(&shard_path) { - shards_opt[rs_config.data_shards + i] = Some(shard); - available_shards += 1; + match tokio::fs::read(&shard_path).await { + Ok(shard) => { + shards_opt[rs_config.data_shards + i] = Some(shard); + available_shards += 1; + debug!("RS: Found parity shard {} for inode {}", i, ino); + } + Err(e) => { + error!("RS: Failed to read parity shard {:?}: {}", shard_path, e); + return Err(FsError::ReedSolomonError(format!( + "Failed to read parity shard: {}", e + ))); + } } + } else { + debug!("RS: Parity shard {} not found at {:?}", i, shard_path); } } @@ -1883,27 +1932,34 @@ impl EncryptedFs { rs_config.parity_shards, ); - match encoder.reconstruct(&mut shards_opt) { - Ok(recovered) => { - // Write recovered content back to main file - if let Err(e) = std::fs::write(&content_path, &recovered) { - error!("RS: Failed to write reconstructed file for inode {}: {}", ino, e); - return Err(FsError::Other(Box::leak(format!("Failed to reconstruct file: {}", e).into_boxed_str()))); - } - info!("RS: Successfully reconstructed inode {} from {} available shards", ino, available_shards); - } - Err(e) => { - error!("RS: Reconstruction failed for inode {}: {}", ino, e); - return Err(FsError::Other(Box::leak(format!("File reconstruction failed: {}", e).into_boxed_str()))); - } + let recovered = encoder.reconstruct(&mut shards_opt) + .map_err(|e| FsError::ReedSolomonError(format!( + "File reconstruction failed: {}", e + )))?; + + // Write recovered content back to main file using async I/O + // Check read-only mode before writing + if self.read_only { + return Err(FsError::ReadOnly); } + + tokio::fs::write(&content_path, &recovered).await + .map_err(|e| { + error!("RS: Failed to write reconstructed file for inode {}: {}", ino, e); + FsError::ReedSolomonError(format!( + "Failed to write reconstructed file: {}", e + )) + })?; + + info!( + "RS: Successfully reconstructed inode {} from {} shards", + ino, available_shards + ); } else { - error!("RS: Not enough shards to reconstruct inode {} (need {}, have {})", - ino, rs_config.data_shards, available_shards); - return Err(FsError::Other(Box::leak(format!( - "Cannot reconstruct file: need {} shards, only {} available", + return Err(FsError::ReedSolomonError(format!( + "Cannot reconstruct file: need {} shard(s), only {} available", rs_config.data_shards, available_shards - ).into_boxed_str()))); + ))); } } } @@ -2306,34 +2362,41 @@ impl EncryptedFs { // write let lock = self.opened_files_for_write.read().await; if let Some(fh) = lock.get(&ino) { - if let Some(handle) = skip_write_fh { - if *fh == handle { - return Ok(()); - } - } let lock = self.write_handles.read().await; if let Some(lock) = lock.get(fh) { let mut ctx = lock.lock().await; - let writer = ctx.writer.as_mut().unwrap(); - let file = writer.finish()?; - file.sync_all()?; - File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; - let set_attr: Option = if save_attr { - Some(ctx.attr.clone().into()) - } else { - None - }; - drop(ctx); - if let Some(set_attr) = set_attr { - self.set_attr(ino, set_attr).await?; + + // Only finish and recreate writer if it exists + if ctx.writer.is_some() { + let writer = ctx.writer.as_mut().unwrap(); + let file = writer.finish()?; + file.sync_all()?; + File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; + + let set_attr: Option = if save_attr { + Some(ctx.attr.clone().into()) + } else { + None + }; + drop(ctx); + if let Some(set_attr) = set_attr { + self.set_attr(ino, set_attr).await?; + } + let writer = self + .create_write_seek(OpenOptions::new().read(true).write(true).open(&path)?) + .await?; + let mut ctx = lock.lock().await; + ctx.writer = Some(Box::new(writer)); + let attr = self.get_inode_from_storage(ino).await?; + ctx.attr = attr.into(); + + // Check if we should skip any further operations on this write handle + if let Some(handle) = skip_write_fh { + if *fh == handle { + return Ok(()); + } + } } - let writer = self - .create_write_seek(OpenOptions::new().read(true).write(true).open(&path)?) - .await?; - let mut ctx = lock.lock().await; - ctx.writer = Some(Box::new(writer)); - let attr = self.get_inode_from_storage(ino).await?; - ctx.attr = attr.into(); } } diff --git a/src/encryptedfs/test.rs b/src/encryptedfs/test.rs index e7a2d9f7..27706e39 100644 --- a/src/encryptedfs/test.rs +++ b/src/encryptedfs/test.rs @@ -3,6 +3,7 @@ use std::string::ToString; use std::time::SystemTime; use shush_rs::{ExposeSecret, SecretString}; +use tempfile; use tracing_test::traced_test; use crate::crypto::Cipher; @@ -2485,78 +2486,72 @@ async fn test_read_only_write() { #[tokio::test] #[traced_test] async fn test_reed_solomon_error_correction() { - run_test( - TestSetup { - key: "test_rs_error_correction", - read_only: false, - }, - async { - // Create filesystem with Reed-Solomon enabled (3 data shards, 2 parity shards) - let rs_config = Some(crate::crypto::rs::RsConfig { - data_shards: 3, - parity_shards: 2, - }); - - let temp_dir = tempfile::tempdir().unwrap(); - let data_dir = temp_dir.path().to_path_buf(); - - let fs = EncryptedFs::new( - data_dir.clone(), - Box::new(PasswordProviderImpl {}), - Cipher::ChaCha20Poly1305, - rs_config, - false, - ) - .await - .unwrap(); - - // Create and write a test file - let test_file = SecretString::from_str("test-rs-file").unwrap(); - let (fh, attr) = fs - .create( - ROOT_INODE, - &test_file, - create_attr(FileType::RegularFile), - false, - true, - ) - .await - .unwrap(); - - let original_data = b"Hello, Reed-Solomon! This is test data for error correction."; - write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh).await.unwrap(); - - // Release the file handle (this should trigger RS encoding and create parity shards) - fs.release(fh).await.unwrap(); - - // Verify the main content file exists - let content_path = fs.contents_path(attr.ino); - assert!(content_path.exists(), "Main content file should exist"); - - // Verify parity shard files were created - for i in 0..2 { - let parity_path = content_path.parent().unwrap().join(format!("{}.parity.{}", attr.ino, i)); - assert!(parity_path.exists(), "Parity shard {} should exist", i); - } - - // Simulate file corruption by deleting the main content file - std::fs::remove_file(&content_path).unwrap(); - assert!(!content_path.exists(), "Main content file should be deleted"); - - // Try to read the file - this should trigger reconstruction from parity shards - let read_fh = fs.open(attr.ino, true, false).await.unwrap(); - - // Read the reconstructed content - let mut buffer = vec![0u8; original_data.len()]; - let bytes_read = fs.read(attr.ino, 0, &mut buffer, read_fh).await.unwrap(); - assert_eq!(bytes_read, original_data.len(), "Should read all original bytes"); - assert_eq!(&buffer[..bytes_read], original_data, "Reconstructed data should match original"); - - // Verify the main file was restored - assert!(content_path.exists(), "Main content file should be restored after reconstruction"); - - fs.release(read_fh).await.unwrap(); - }, + // Create filesystem with Reed-Solomon enabled (3 data shards, 2 parity shards) + // Currently only `data_shards = 1` is supported. + // The original encrypted file acts as the single data shard. + // Parity shards are used only for recovery. + let rs_config = Some(crate::crypto::rs::RsConfig { + data_shards: 1, + parity_shards: 2, + }); + + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let fs = EncryptedFs::new( + data_dir.clone(), + Box::new(PasswordProviderImpl {}), + Cipher::ChaCha20Poly1305, + rs_config, + false, ) - .await; + .await + .unwrap(); + + // Create and write a test file + let test_file = SecretString::from_str("test-rs-file").unwrap(); + let (fh, attr) = fs + .create( + ROOT_INODE, + &test_file, + create_attr(FileType::RegularFile), + false, + true, + ) + .await + .unwrap(); + + let original_data = b"Hello, Reed-Solomon! This is test data for error correction."; + write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh).await.unwrap(); + + // Release the file handle (this should trigger RS encoding and create parity shards) + fs.release(fh).await.unwrap(); + + // Verify the main content file exists + let content_path = fs.contents_path(attr.ino); + assert!(content_path.exists(), "Main content file should exist"); + + // Verify parity shard files were created + for i in 0..2 { + let parity_path = content_path.parent().unwrap().join(format!("{}.parity.{}", attr.ino, i)); + assert!(parity_path.exists(), "Parity shard {} should exist", i); + } + + // Simulate file corruption by deleting the main content file + std::fs::remove_file(&content_path).unwrap(); + assert!(!content_path.exists(), "Main content file should be deleted"); + + // Try to read the file - this should trigger reconstruction from parity shards + let read_fh = fs.open(attr.ino, true, false).await.unwrap(); + + // Read the reconstructed content + let mut buffer = vec![0u8; original_data.len()]; + let bytes_read = fs.read(attr.ino, 0, &mut buffer, read_fh).await.unwrap(); + assert_eq!(bytes_read, original_data.len(), "Should read all original bytes"); + assert_eq!(&buffer[..bytes_read], original_data, "Reconstructed data should match original"); + + // Verify the main file was restored + assert!(content_path.exists(), "Main content file should be restored after reconstruction"); + + fs.release(read_fh).await.unwrap(); } From a3d52c4516e8d428dba43d84361c6149b17fd8d3 Mon Sep 17 00:00:00 2001 From: Marin Adela Date: Tue, 10 Mar 2026 16:53:05 +0200 Subject: [PATCH 9/9] address remaining code review feedback --- src/crypto/read.rs | 4 +- src/crypto/rs.rs | 159 ++++++++++++++++++++++++++++++++-------- src/crypto/write.rs | 2 +- src/encryptedfs.rs | 129 ++++++++++++++++++++------------ src/encryptedfs/test.rs | 129 ++++++++++++++++++++++++++------ src/lib.rs | 2 +- 6 files changed, 320 insertions(+), 105 deletions(-) diff --git a/src/crypto/read.rs b/src/crypto/read.rs index c01a72f1..c1cd271e 100644 --- a/src/crypto/read.rs +++ b/src/crypto/read.rs @@ -199,7 +199,7 @@ impl Seek for RingCryptoRead { let block_index = self.pos() / self.plaintext_block_size as u64; let new_block_index = new_pos / self.plaintext_block_size as u64; if block_index == new_block_index { - let at_full_block_end = self.pos() % self.plaintext_block_size as u64 == 0 + let at_full_block_end = self.pos().is_multiple_of(self.plaintext_block_size as u64) && self.buf.available_read() == 0; if self.buf.available() > 0 // this make sure we are not at the end of the current block, which is the start boundary of next block @@ -222,7 +222,7 @@ impl Seek for RingCryptoRead { ))?; self.buf.clear(); self.block_index = new_block_index; - if new_pos % self.plaintext_block_size as u64 == 0 { + if new_pos.is_multiple_of(self.plaintext_block_size as u64) { // in case we need to seek at the start of the new block, we need to decrypt here, because we altered // the block_index but the seek seek_forward from below will not decrypt anything // as the offset in new block is 0. In that case the po() diff --git a/src/crypto/rs.rs b/src/crypto/rs.rs index a54804c8..38800076 100644 --- a/src/crypto/rs.rs +++ b/src/crypto/rs.rs @@ -1,12 +1,23 @@ use reed_solomon_erasure::galois_8::Field as Gf8; use reed_solomon_erasure::ReedSolomon; -use std::error::Error; +use thiserror::Error; + +/// Error type for Reed-Solomon encode/reconstruct operations. +#[derive(Debug, Error)] +pub enum RsError { + #[error("Reed-Solomon error: {0}")] + ReedSolomon(String), +} /// Configuration for Reed-Solomon error correction (opt-in feature). /// Users can disable by passing `None` to avoid the additional storage overhead. +/// +/// Only `data_shards = 1` is currently supported in the filesystem integration. +/// The encrypted file itself acts as the single data shard; parity shards enable +/// recovery if the main file is lost. #[derive(Clone, Debug)] pub struct RsConfig { - /// Number of data shards (original file is split across these). + /// Number of data shards used for encoding. pub data_shards: usize, /// Number of parity shards to create (for recovery). pub parity_shards: usize, @@ -18,6 +29,8 @@ pub struct RsEncoder { } impl RsEncoder { + /// Creates a new encoder with the given shard counts. + /// Panics if either `data_shards` or `parity_shards` is zero. pub fn new(data_shards: usize, parity_shards: usize) -> Self { assert!(data_shards > 0, "data_shards must be > 0"); assert!(parity_shards > 0, "parity_shards must be > 0"); @@ -27,75 +40,93 @@ impl RsEncoder { } } - pub fn encode(&self, data: &[u8]) -> Result>, Box> { - let r = ReedSolomon::::new(self.data_shards, self.parity_shards)?; + /// Creates an encoder from an [`RsConfig`]. + pub fn from_config(config: &RsConfig) -> Self { + Self::new(config.data_shards, config.parity_shards) + } - // prefix with original length so we can trim when reconstructing + /// Encodes `data` into `data_shards + parity_shards` shards. + /// + /// The returned `Vec` has length `data_shards + parity_shards`. The first + /// `data_shards` entries are the (padded) data shards; the remaining entries + /// are the parity shards needed for reconstruction. + pub fn encode(&self, data: &[u8]) -> Result>, RsError> { + let r = ReedSolomon::::new(self.data_shards, self.parity_shards) + .map_err(|e| RsError::ReedSolomon(e.to_string()))?; + + // prefix with original length so we can trim padding when reconstructing let mut payload = Vec::with_capacity(8 + data.len()); payload.extend_from_slice(&(data.len() as u64).to_le_bytes()); payload.extend_from_slice(data); - let shard_size = (payload.len() + self.data_shards - 1) / self.data_shards; + let shard_size = payload.len().div_ceil(self.data_shards); let total_shards = self.data_shards + self.parity_shards; let mut shards: Vec> = vec![vec![0u8; shard_size]; total_shards]; - for i in 0..self.data_shards { + for (i, shard) in shards.iter_mut().take(self.data_shards).enumerate() { let start = i * shard_size; let end = std::cmp::min(start + shard_size, payload.len()); if start < payload.len() { - shards[i][..end - start].copy_from_slice(&payload[start..end]); + shard[..end - start].copy_from_slice(&payload[start..end]); } } let mut shard_refs: Vec<&mut [u8]> = shards.iter_mut().map(|v| v.as_mut_slice()).collect(); - r.encode(&mut shard_refs)?; + r.encode(&mut shard_refs) + .map_err(|e| RsError::ReedSolomon(e.to_string()))?; Ok(shards) } - pub fn reconstruct( - &self, - shards_opt: &mut [Option>], - ) -> Result, Box> { - let r = ReedSolomon::::new(self.data_shards, self.parity_shards)?; + /// Reconstructs the original data from a mix of data and parity shards. + /// + /// `shards_opt` must have exactly `data_shards + parity_shards` entries. + /// Each `None` slot represents a missing shard. At least `data_shards` + /// shards must be present for reconstruction to succeed. + pub fn reconstruct(&self, shards_opt: &mut [Option>]) -> Result, RsError> { + let r = ReedSolomon::::new(self.data_shards, self.parity_shards) + .map_err(|e| RsError::ReedSolomon(e.to_string()))?; let total_shards = self.data_shards + self.parity_shards; if shards_opt.len() != total_shards { - return Err("shards length mismatch".into()); + return Err(RsError::ReedSolomon("shards length mismatch".to_owned())); } let shard_len = shards_opt .iter() .find_map(|s| s.as_ref().map(|v| v.len())) - .ok_or("no shards available")?; - - for slot in shards_opt.iter_mut() { - if let Some(v) = slot { - if v.len() < shard_len { - v.resize(shard_len, 0u8); - } else if v.len() > shard_len { - return Err("inconsistent shard lengths".into()); - } + .ok_or_else(|| RsError::ReedSolomon("no shards available".to_owned()))?; + + for v in shards_opt.iter_mut().flatten() { + if v.len() < shard_len { + v.resize(shard_len, 0u8); + } else if v.len() > shard_len { + return Err(RsError::ReedSolomon( + "inconsistent shard lengths".to_owned(), + )); } } - r.reconstruct(shards_opt)?; + r.reconstruct(shards_opt) + .map_err(|e| RsError::ReedSolomon(e.to_string()))?; let mut payload = Vec::with_capacity(shard_len * self.data_shards); - for i in 0..self.data_shards { - let slice = shards_opt[i] - .as_ref() - .ok_or("missing shard after reconstruct")?; + for shard in shards_opt.iter().take(self.data_shards) { + let slice = shard.as_ref().ok_or_else(|| { + RsError::ReedSolomon("missing shard after reconstruct".to_owned()) + })?; payload.extend_from_slice(slice); } if payload.len() < 8 { - return Err("payload too small".into()); + return Err(RsError::ReedSolomon("payload too small".to_owned())); } let orig_len = u64::from_le_bytes(payload[0..8].try_into().unwrap()) as usize; if 8 + orig_len > payload.len() { - return Err("original length exceeds reconstructed payload".into()); + return Err(RsError::ReedSolomon( + "original length exceeds reconstructed payload".to_owned(), + )); } Ok(payload[8..8 + orig_len].to_vec()) } @@ -103,7 +134,7 @@ impl RsEncoder { #[cfg(test)] mod tests { - use super::RsEncoder; + use super::{RsConfig, RsEncoder}; #[test] fn rs_encode_reconstruct() { @@ -123,4 +154,68 @@ mod tests { assert_eq!(recovered, data); } + + #[test] + fn rs_reconstruct_empty_data() { + let encoder = RsEncoder::new(1, 2); + let data: &[u8] = b""; + let shards = encoder.encode(data).expect("encode failed"); + assert_eq!(shards.len(), 3); + + let mut shards_opt: Vec>> = shards.into_iter().map(Some).collect(); + shards_opt[0] = None; // drop the data shard, keep parity shards + + let recovered = encoder + .reconstruct(&mut shards_opt) + .expect("reconstruct failed"); + + assert_eq!(recovered, data); + } + + #[test] + fn rs_reconstruct_unrecoverable() { + let encoder = RsEncoder::new(1, 2); + let data = b"test data that cannot be recovered"; + let shards = encoder.encode(data).expect("encode failed"); + assert_eq!(shards.len(), 3); + + // Drop all shards - impossible to reconstruct + let mut shards_opt: Vec>> = shards.into_iter().map(|_| None).collect(); + + let result = encoder.reconstruct(&mut shards_opt); + assert!(result.is_err(), "should fail when no shards are available"); + } + + #[test] + fn rs_from_config() { + let config = RsConfig { + data_shards: 2, + parity_shards: 3, + }; + let encoder = RsEncoder::from_config(&config); + let data = b"config-based encoder test"; + let shards = encoder.encode(data).expect("encode failed"); + assert_eq!(shards.len(), 5); + + let mut shards_opt: Vec>> = shards.into_iter().map(Some).collect(); + shards_opt[0] = None; + shards_opt[3] = None; + + let recovered = encoder + .reconstruct(&mut shards_opt) + .expect("reconstruct failed"); + assert_eq!(recovered, data); + } + + #[test] + #[should_panic(expected = "data_shards must be > 0")] + fn rs_zero_data_shards_panics() { + RsEncoder::new(0, 2); + } + + #[test] + #[should_panic(expected = "parity_shards must be > 0")] + fn rs_zero_parity_shards_panics() { + RsEncoder::new(2, 0); + } } diff --git a/src/crypto/write.rs b/src/crypto/write.rs index 901e0091..a64ed2db 100644 --- a/src/crypto/write.rs +++ b/src/crypto/write.rs @@ -382,7 +382,7 @@ impl Seek for RingCryptoWrite { self.block_index = 0; self.decrypt_block()?; } - let at_full_block_end = self.pos() % self.plaintext_block_size as u64 == 0 + let at_full_block_end = self.pos().is_multiple_of(self.plaintext_block_size as u64) && self.buf.pos_write() == self.buf.available(); if self.buf.available() == 0 // this checks if we are at the end of the current block, diff --git a/src/encryptedfs.rs b/src/encryptedfs.rs index a1547403..f945a92b 100644 --- a/src/encryptedfs.rs +++ b/src/encryptedfs.rs @@ -1006,11 +1006,12 @@ impl EncryptedFs { // remove from contents directory fs::remove_file(self_clone.contents_path(attr.ino))?; - + // remove parity shards if RS is enabled if let Some(ref rs_config) = self_clone.rs_config { for i in 0..rs_config.parity_shards { - let parity_path = self_clone.contents_path(attr.ino) + let parity_path = self_clone + .contents_path(attr.ino) .parent() .unwrap() .join(format!("{}.parity.{}", attr.ino, i)); @@ -1018,7 +1019,7 @@ impl EncryptedFs { let _ = fs::remove_file(parity_path); } } - + // remove from parent directory self_clone .remove_directory_entry(parent, &name_clone) @@ -1534,7 +1535,11 @@ impl EncryptedFs { // write let ctx = { self.write_handles.write().await.remove(&handle) }; - debug!("RS: release() - checking write handle {}, found: {}", handle, ctx.is_some()); + debug!( + "RS: release() - checking write handle {}, found: {}", + handle, + ctx.is_some() + ); if let Some(ctx) = ctx { if self.read_only { return Err(FsError::ReadOnly); @@ -1549,15 +1554,21 @@ impl EncryptedFs { let file = writer.finish()?; file.sync_all()?; File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; - - debug!("RS: release() - checking if RS is enabled, rs_config = {:?}", self.rs_config.is_some()); - + + debug!( + "RS: release() - checking if RS is enabled, rs_config = {:?}", + self.rs_config.is_some() + ); + // Reed-Solomon: Encode and write parity shards if configured // This is done after file sync to ensure content is persisted if let Some(ref rs_config) = self.rs_config { let content_path = self.contents_path(ctx.ino); - debug!("RS: Attempting to encode file for inode {} at {:?}", ctx.ino, content_path); - + debug!( + "RS: Attempting to encode file for inode {} at {:?}", + ctx.ino, content_path + ); + match tokio::fs::read(&content_path).await { Ok(content) => { debug!("RS: Read {} bytes from {:?}", content.len(), content_path); @@ -1565,28 +1576,43 @@ impl EncryptedFs { rs_config.data_shards, rs_config.parity_shards, ); - - let shards = encoder.encode(&content) - .map_err(|e| format!("RS encoding failed for inode {}: {}", ctx.ino, e)); - + + let shards = encoder.encode(&content).map_err(|e| { + format!("RS encoding failed for inode {}: {}", ctx.ino, e) + }); + match shards { Ok(shards) => { // Write parity shards (skip data shards which are the original file) - for (i, shard) in shards.iter().enumerate().skip(rs_config.data_shards) { - let shard_path = content_path.parent() - .unwrap() - .join(format!("{}.parity.{}", ctx.ino, i - rs_config.data_shards)); + for (i, shard) in + shards.iter().enumerate().skip(rs_config.data_shards) + { + let shard_path = content_path.parent().unwrap().join(format!( + "{}.parity.{}", + ctx.ino, + i - rs_config.data_shards + )); if let Err(e) = tokio::fs::write(&shard_path, shard).await { - warn!("RS: Failed to write parity shard {:?}: {}", shard_path, e); + error!( + "RS: Failed to write parity shard {:?}: {}", + shard_path, e + ); } else { - debug!("RS: Wrote parity shard {} for inode {} at {:?}", - i - rs_config.data_shards, ctx.ino, shard_path); + debug!( + "RS: Wrote parity shard {} for inode {} at {:?}", + i - rs_config.data_shards, + ctx.ino, + shard_path + ); } } - debug!("RS: Wrote {} parity shards for inode {}", rs_config.parity_shards, ctx.ino); + debug!( + "RS: Wrote {} parity shards for inode {}", + rs_config.parity_shards, ctx.ino + ); } Err(e) => { - warn!("{}", e); + error!("{}", e); } } } @@ -1595,7 +1621,7 @@ impl EncryptedFs { } } } - + // write attr only here to avoid serializing it multiple times while writing // it will merge time fields with existing data because it might got change while we kept the handle let ino = ctx.ino; @@ -1872,24 +1898,27 @@ impl EncryptedFs { // Reed-Solomon: Try to reconstruct file from parity shards if main file is missing/corrupt if let Some(ref rs_config) = self.rs_config { let content_path = self.contents_path(ino); - + // Check if main file is missing (not just empty - empty files are valid!) let file_missing = !content_path.exists(); - + if file_missing { - debug!("RS: Attempting reconstruction for inode {} (file missing)", ino); - + debug!( + "RS: Attempting reconstruction for inode {} (file missing)", + ino + ); + // RS reconstruction currently supports only data_shards = 1 // The main encrypted file serves as the single data shard debug_assert!( rs_config.data_shards == 1, "RS reconstruction currently supports only data_shards = 1" ); - + // Build shard array: data shard (main file) + parity shards let total_shards = rs_config.data_shards + rs_config.parity_shards; let mut shards_opt: Vec>> = vec![None; total_shards]; - + // Try to load the main file as first data shard (might fail if missing) if content_path.exists() { if let Ok(data) = tokio::fs::read(&content_path).await { @@ -1898,11 +1927,12 @@ impl EncryptedFs { } } } - + // Load all available parity shards let mut available_shards = if shards_opt[0].is_some() { 1 } else { 0 }; for i in 0..rs_config.parity_shards { - let shard_path = content_path.parent() + let shard_path = content_path + .parent() .unwrap() .join(format!("{}.parity.{}", ino, i)); debug!("RS: Looking for parity shard at {:?}", shard_path); @@ -1916,7 +1946,8 @@ impl EncryptedFs { Err(e) => { error!("RS: Failed to read parity shard {:?}: {}", shard_path, e); return Err(FsError::ReedSolomonError(format!( - "Failed to read parity shard: {}", e + "Failed to read parity shard: {}", + e ))); } } @@ -1924,33 +1955,37 @@ impl EncryptedFs { debug!("RS: Parity shard {} not found at {:?}", i, shard_path); } } - + // Check if we have enough shards to reconstruct if available_shards >= rs_config.data_shards { let encoder = crate::crypto::rs::RsEncoder::new( rs_config.data_shards, rs_config.parity_shards, ); - - let recovered = encoder.reconstruct(&mut shards_opt) - .map_err(|e| FsError::ReedSolomonError(format!( - "File reconstruction failed: {}", e - )))?; - + + let recovered = encoder.reconstruct(&mut shards_opt).map_err(|e| { + FsError::ReedSolomonError(format!("File reconstruction failed: {}", e)) + })?; + // Write recovered content back to main file using async I/O // Check read-only mode before writing if self.read_only { return Err(FsError::ReadOnly); } - - tokio::fs::write(&content_path, &recovered).await + + tokio::fs::write(&content_path, &recovered) + .await .map_err(|e| { - error!("RS: Failed to write reconstructed file for inode {}: {}", ino, e); + error!( + "RS: Failed to write reconstructed file for inode {}: {}", + ino, e + ); FsError::ReedSolomonError(format!( - "Failed to write reconstructed file: {}", e + "Failed to write reconstructed file: {}", + e )) })?; - + info!( "RS: Successfully reconstructed inode {} from {} shards", ino, available_shards @@ -2365,14 +2400,14 @@ impl EncryptedFs { let lock = self.write_handles.read().await; if let Some(lock) = lock.get(fh) { let mut ctx = lock.lock().await; - + // Only finish and recreate writer if it exists if ctx.writer.is_some() { let writer = ctx.writer.as_mut().unwrap(); let file = writer.finish()?; file.sync_all()?; File::open(self.contents_path(ctx.ino).parent().unwrap())?.sync_all()?; - + let set_attr: Option = if save_attr { Some(ctx.attr.clone().into()) } else { @@ -2389,7 +2424,7 @@ impl EncryptedFs { ctx.writer = Some(Box::new(writer)); let attr = self.get_inode_from_storage(ino).await?; ctx.attr = attr.into(); - + // Check if we should skip any further operations on this write handle if let Some(handle) = skip_write_fh { if *fh == handle { diff --git a/src/encryptedfs/test.rs b/src/encryptedfs/test.rs index 27706e39..80a718b0 100644 --- a/src/encryptedfs/test.rs +++ b/src/encryptedfs/test.rs @@ -2416,9 +2416,15 @@ async fn test_read_only_write() { fs_rw.flush(fh).await.unwrap(); fs_rw.release(fh).await.unwrap(); drop(fs_rw); - let fs_ro = EncryptedFs::new(data_dir, Box::new(PasswordProviderImpl {}), cipher, None, true) - .await - .expect("test_read_only_write: Error creating rw fs."); + let fs_ro = EncryptedFs::new( + data_dir, + Box::new(PasswordProviderImpl {}), + cipher, + None, + true, + ) + .await + .expect("test_read_only_write: Error creating rw fs."); let fh = fs_ro .open(attr.ino, true, false) .await @@ -2486,18 +2492,17 @@ async fn test_read_only_write() { #[tokio::test] #[traced_test] async fn test_reed_solomon_error_correction() { - // Create filesystem with Reed-Solomon enabled (3 data shards, 2 parity shards) - // Currently only `data_shards = 1` is supported. + // Create filesystem with Reed-Solomon enabled (1 data shard, 2 parity shards). // The original encrypted file acts as the single data shard. // Parity shards are used only for recovery. let rs_config = Some(crate::crypto::rs::RsConfig { data_shards: 1, parity_shards: 2, }); - + let temp_dir = tempfile::tempdir().unwrap(); let data_dir = temp_dir.path().to_path_buf(); - + let fs = EncryptedFs::new( data_dir.clone(), Box::new(PasswordProviderImpl {}), @@ -2520,38 +2525,118 @@ async fn test_reed_solomon_error_correction() { ) .await .unwrap(); - + let original_data = b"Hello, Reed-Solomon! This is test data for error correction."; - write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh).await.unwrap(); - + write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh) + .await + .unwrap(); + // Release the file handle (this should trigger RS encoding and create parity shards) fs.release(fh).await.unwrap(); - + // Verify the main content file exists let content_path = fs.contents_path(attr.ino); assert!(content_path.exists(), "Main content file should exist"); - + // Verify parity shard files were created for i in 0..2 { - let parity_path = content_path.parent().unwrap().join(format!("{}.parity.{}", attr.ino, i)); + let parity_path = content_path + .parent() + .unwrap() + .join(format!("{}.parity.{}", attr.ino, i)); assert!(parity_path.exists(), "Parity shard {} should exist", i); } - + // Simulate file corruption by deleting the main content file std::fs::remove_file(&content_path).unwrap(); - assert!(!content_path.exists(), "Main content file should be deleted"); - + assert!( + !content_path.exists(), + "Main content file should be deleted" + ); + // Try to read the file - this should trigger reconstruction from parity shards let read_fh = fs.open(attr.ino, true, false).await.unwrap(); - + // Read the reconstructed content let mut buffer = vec![0u8; original_data.len()]; let bytes_read = fs.read(attr.ino, 0, &mut buffer, read_fh).await.unwrap(); - assert_eq!(bytes_read, original_data.len(), "Should read all original bytes"); - assert_eq!(&buffer[..bytes_read], original_data, "Reconstructed data should match original"); - + assert_eq!( + bytes_read, + original_data.len(), + "Should read all original bytes" + ); + assert_eq!( + &buffer[..bytes_read], + original_data, + "Reconstructed data should match original" + ); + // Verify the main file was restored - assert!(content_path.exists(), "Main content file should be restored after reconstruction"); - + assert!( + content_path.exists(), + "Main content file should be restored after reconstruction" + ); + fs.release(read_fh).await.unwrap(); } + +#[tokio::test] +#[traced_test] +async fn test_reed_solomon_unrecoverable() { + // Create filesystem with Reed-Solomon enabled (1 data shard, 2 parity shards) + let rs_config = Some(crate::crypto::rs::RsConfig { + data_shards: 1, + parity_shards: 2, + }); + + let temp_dir = tempfile::tempdir().unwrap(); + let data_dir = temp_dir.path().to_path_buf(); + + let fs = EncryptedFs::new( + data_dir.clone(), + Box::new(PasswordProviderImpl {}), + Cipher::ChaCha20Poly1305, + rs_config, + false, + ) + .await + .unwrap(); + + let test_file = SecretString::from_str("test-rs-unrecoverable").unwrap(); + let (fh, attr) = fs + .create( + ROOT_INODE, + &test_file, + create_attr(FileType::RegularFile), + false, + true, + ) + .await + .unwrap(); + + let original_data = b"This data will be permanently lost."; + write_all_bytes_to_fs(&fs, attr.ino, 0, original_data, fh) + .await + .unwrap(); + fs.release(fh).await.unwrap(); + + let content_path = fs.contents_path(attr.ino); + + // Delete the main file AND all parity shards - nothing left to reconstruct from + std::fs::remove_file(&content_path).unwrap(); + for i in 0..2 { + let parity_path = content_path + .parent() + .unwrap() + .join(format!("{}.parity.{}", attr.ino, i)); + let _ = std::fs::remove_file(parity_path); + } + + // open() should fail with ReedSolomonError since no shards are available + let result = fs.open(attr.ino, true, false).await; + assert!( + matches!(result, Err(FsError::ReedSolomonError(_))), + "expected ReedSolomonError, got {:?}", + result + ); +} diff --git a/src/lib.rs b/src/lib.rs index 22657c91..967545cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -108,7 +108,7 @@ //! let data_dir = Path::new("/tmp/rencfs_data_test").to_path_buf(); //! let _ = fs::remove_dir_all(data_dir.to_str().unwrap()); //! let cipher = Cipher::ChaCha20Poly1305; -//! let mut fs = EncryptedFs::new(data_dir.clone(), Box::new(PasswordProviderImpl{}), cipher, false).await?; +//! let mut fs = EncryptedFs::new(data_dir.clone(), Box::new(PasswordProviderImpl{}), cipher, None, false).await?; //! //! let file1 = SecretString::new(Box::new(String::from("file-1"))); //! let (fh, attr) = fs.create(ROOT_INODE, &file1, file_attr(), false, true).await?;