Skip to content

Commit 84ced9e

Browse files
committed
update typed-fuse dependency, add additional tests
1 parent 1c55446 commit 84ced9e

5 files changed

Lines changed: 261 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/encfs_config.proto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ message BasicBlockCipher {
6060

6161
// Width of the random per-file IV. FILE_IV_WIDTH_64 is the zero value so an
6262
// absent/omitted field (all configs written before this ADR) means 64-bit.
63-
// Leaves room for a future FILE_IV_WIDTH_128 without adding a new field;
63+
// Leaves room for future expansions;
6464
// any new value must be paired with a minimum_reader_version bump so old
6565
// readers fail closed instead of silently misreading it as 64-bit.
6666
enum FileIvWidth {

tests/live/mod.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,46 @@ pub fn data_block_size(cfg: &LiveConfig) -> u64 {
8787
cfg.block_size - cfg.block_mac_bytes
8888
}
8989

90+
/// Builds a fresh backing root holding a wide-file-IV V7 config — the same
91+
/// shape `encfsctl new` (no flags) produces. There is no `.encfs7` fixture
92+
/// checked in, so this generates one on the fly via
93+
/// `EncfsConfig::standard_v7()` + `set_v7_key`, exactly as `encfsctl new`
94+
/// does, rather than loading it from `tests/fixtures`.
95+
pub fn init_wide_v7_backing_root() -> Result<(PathBuf, LiveConfig)> {
96+
let backing_root = unique_temp_dir("encfs_live_wide_v7_backing")?;
97+
98+
let mut config = EncfsConfig::standard_v7();
99+
anyhow::ensure!(config.wide_file_iv, "standard_v7() must default to wide IV");
100+
// Cheap KDF for test speed only; everything else is production defaults.
101+
config.argon2_memory_cost = Some(8);
102+
config.argon2_time_cost = Some(1);
103+
config.argon2_parallelism = Some(1);
104+
getrandom::fill(&mut config.salt).context("fill salt")?;
105+
106+
let key_len = (config.key_size / 8) as usize;
107+
let mut volume_key_blob = vec![0u8; key_len + 16];
108+
getrandom::fill(&mut volume_key_blob).context("fill volume key")?;
109+
110+
let password = "wide-v7-live-test";
111+
config
112+
.set_v7_key(password, &volume_key_blob)
113+
.context("set_v7_key")?;
114+
config
115+
.save(&backing_root.join(".encfs7"))
116+
.context("save V7 config")?;
117+
118+
let live_cfg = LiveConfig {
119+
kind: LiveConfigKind::V7,
120+
password: "wide-v7-live-test",
121+
block_size: config.block_size as u64,
122+
block_mac_bytes: config.block_mac_bytes as u64,
123+
chained_name_iv: config.chained_name_iv,
124+
external_iv_chaining: config.external_iv_chaining,
125+
};
126+
127+
Ok((backing_root, live_cfg))
128+
}
129+
90130
pub fn unique_temp_dir(prefix: &str) -> Result<PathBuf> {
91131
let pid = std::process::id();
92132
let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);

tests/live_mount.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,3 +1461,109 @@ fn live_tar_extract_single_file() -> Result<()> {
14611461

14621462
Ok(())
14631463
}
1464+
1465+
/// Regression test for the "backing file created but no data written"
1466+
/// report: `cp` between two paths *within the same EncFS mount*.
1467+
///
1468+
/// Root cause, bisected to a single upstream commit: fixed in typed-fuse
1469+
/// commit `5a07205` ("fix statfs on darwin"), picked up here via the
1470+
/// `Cargo.lock` bump in this same change. Before that fix, macOS's
1471+
/// `fuse_reply_statfs` binding built a `struct statfs` and handed its
1472+
/// pointer to `fuse_reply_statfs_vanilla`, which actually expects `struct
1473+
/// statvfs*` — a different, differently-laid-out type. Every
1474+
/// `statfs()`/`statvfs()` on any file in the mount therefore returned
1475+
/// reinterpreted garbage, independently confirmed by `stat -f`: two very
1476+
/// differently sized files in the mount reported identical, nonsensical
1477+
/// `st_blocks`, matching the mountpoint's own volume-level number. Exactly
1478+
/// how `cp`'s data-copy path consumes that garbage to end up writing zero
1479+
/// bytes while still exiting 0 was not isolated (would need
1480+
/// `fs_usage`/`dtruss`, which need root) — what's verified is the A/B: the
1481+
/// same `cp` invocation loses data at the pre-fix commit and doesn't at the
1482+
/// post-fix one, with no other functional change in between. `dd`, a
1483+
/// single-call Python `write()`, `rsync`, and `cp` from an *external* source
1484+
/// into the mount were all unaffected even pre-fix.
1485+
///
1486+
/// This is deliberately config-independent (V6 `Standard` fixture and a
1487+
/// fresh wide-file-IV V7 volume both exercise it) because the bug reproduced
1488+
/// identically on both before the fix: it is a macOS/FUSE-binding-layer bug
1489+
/// with no dependency on file-IV width, header size, or block mode, and it
1490+
/// predates the 96-bit-IV work that first surfaced it (that work simply made
1491+
/// this the new default happy path people would immediately test with `cp`).
1492+
fn run_internal_copy_same_mount(
1493+
mount: &MountGuard,
1494+
header_size: u64,
1495+
expected_physical_size: u64,
1496+
) -> Result<()> {
1497+
let src = mount.mount_point.join("cp_src_internal.bin");
1498+
let dst = mount.mount_point.join("cp_dst_internal.bin");
1499+
let payload = pattern_bytes(400_000);
1500+
fs::write(&src, &payload).context("write source inside mount")?;
1501+
1502+
let before = live::list_non_dot_entries_recursive(&mount.backing_root)?
1503+
.into_iter()
1504+
.collect::<std::collections::BTreeSet<_>>();
1505+
1506+
let status = Command::new("cp")
1507+
.arg(&src)
1508+
.arg(&dst)
1509+
.status()
1510+
.context("spawn cp")?;
1511+
anyhow::ensure!(status.success(), "cp exited with {status}");
1512+
1513+
let after = live::list_non_dot_entries_recursive(&mount.backing_root)?;
1514+
let new_backing_file = after
1515+
.iter()
1516+
.find(|p| !before.contains(*p))
1517+
.context("no new backing file appeared for the cp destination")?;
1518+
let backing_len = fs::metadata(new_backing_file)
1519+
.context("stat new backing file")?
1520+
.len();
1521+
1522+
let got = fs::read(&dst).context("read cp destination")?;
1523+
assert_eq!(
1524+
(backing_len, got.len()),
1525+
(expected_physical_size, payload.len()),
1526+
"cp between two paths on the same EncFS mount lost data: backing file is \
1527+
{backing_len} bytes (header alone is {header_size}, full copy should be \
1528+
{expected_physical_size}), mounted read returned {} bytes, expected {} bytes of payload",
1529+
got.len(),
1530+
payload.len()
1531+
);
1532+
assert_eq!(got, payload, "cp destination content mismatch");
1533+
Ok(())
1534+
}
1535+
1536+
#[test]
1537+
#[ignore]
1538+
fn live_internal_copy_same_mount_standard() -> Result<()> {
1539+
require_live();
1540+
if !live_enabled() {
1541+
return Ok(());
1542+
}
1543+
let cfg = load_live_config(live::LiveConfigKind::Standard)?;
1544+
let mount = MountGuard::mount(cfg, false)?;
1545+
// Standard fixture: uniqueIV=1, wideFileIV unset (narrow) => 8-byte
1546+
// header; blockMACBytes=0 => no per-block overhead.
1547+
run_internal_copy_same_mount(&mount, 8, 8 + 400_000)
1548+
}
1549+
1550+
#[test]
1551+
#[ignore]
1552+
fn live_internal_copy_same_mount_wide_v7() -> Result<()> {
1553+
require_live();
1554+
if !live_enabled() {
1555+
return Ok(());
1556+
}
1557+
let (backing_root, cfg) = live::init_wide_v7_backing_root()?;
1558+
// standard_v7() defaults: uniqueIV=1, wideFileIV=1 => 12-byte header,
1559+
// AES-GCM-SIV block mode => 16-byte per-block tag overhead.
1560+
let expected = encfs::crypto::file::FileEncoder::<fs::File>::calculate_physical_size_with_mode(
1561+
400_000,
1562+
12,
1563+
cfg.block_size,
1564+
cfg.block_mac_bytes,
1565+
encfs::crypto::block::BlockMode::AesGcmSiv,
1566+
);
1567+
let mount = MountGuard::mount_existing_backing_root(cfg, false, backing_root)?;
1568+
run_internal_copy_same_mount(&mount, 12, expected)
1569+
}

tests/wide_file_iv_live_test.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Live FUSE-mount coverage for the 96-bit wide file IV (V7/AES-GCM-SIV,
2+
//! `wide_file_iv = true`), the config `encfsctl new` produces by default.
3+
//!
4+
//! `tests/wide_file_iv_test.rs` drives `EncFs` directly (no FUSE) and
5+
//! `tests/live_mount.rs` only mounts V6 fixtures (`LiveConfigKind::Standard`
6+
//! / `Paranoia`). Every wide-file-IV volume actually reachable in practice —
7+
//! via `encfsctl new` with default flags, then `encfs` — goes through a real
8+
//! FUSE mount, which neither existing suite exercised before this file. It
9+
//! uses the same `MountGuard` harness as `live_mount.rs`, generating the V7
10+
//! config on the fly via `live::init_wide_v7_backing_root()` (there is no
11+
//! `.encfs7` fixture checked in), exactly as `encfsctl new` does.
12+
//!
13+
//! The config-independent "cp between two paths on the same mount silently
14+
//! drops data" bug (see `tests/live_mount.rs::live_internal_copy_same_mount_*`)
15+
//! is *not* re-tested here: it reproduces identically under a plain V6
16+
//! config, so it isn't specific to wide file IVs and doesn't belong in this
17+
//! file. What belongs here is proof that the wide-IV read/write/header path
18+
//! itself is sound end-to-end through a real mount.
19+
20+
mod live;
21+
22+
use anyhow::{Context, Result};
23+
use live::{MountGuard, live_enabled};
24+
use std::fs;
25+
use std::process::Command;
26+
27+
fn require_live() {
28+
if !live_enabled() {
29+
eprintln!("skipping live mount test (set ENCFS_LIVE_TESTS=1 to enable)");
30+
}
31+
}
32+
33+
fn ciphertext_files_total_len(backing_root: &std::path::Path) -> Result<u64> {
34+
let files = live::list_non_dot_entries_recursive(backing_root)?;
35+
let mut total = 0u64;
36+
for f in files {
37+
if f.is_file() {
38+
total += fs::metadata(&f)?.len();
39+
}
40+
}
41+
Ok(total)
42+
}
43+
44+
/// Plain write-then-read through a fresh wide-IV mount, checking the backing
45+
/// ciphertext's *physical* size (not the mounted logical size, which reads
46+
/// back as 0 for a missing, empty, and header-only file alike and so can't
47+
/// distinguish "no data persisted" from "no file").
48+
#[test]
49+
#[ignore]
50+
fn live_wide_file_iv_basic_write_readback() -> Result<()> {
51+
require_live();
52+
if !live_enabled() {
53+
return Ok(());
54+
}
55+
56+
let (backing_root, cfg) = live::init_wide_v7_backing_root()?;
57+
let mount = MountGuard::mount_existing_backing_root(cfg, false, backing_root)?;
58+
59+
let p = mount.mount_point.join("hello.txt");
60+
let payload = b"hello wide-iv encfs, written through a real FUSE mount\n";
61+
fs::write(&p, payload).context("write through mount")?;
62+
63+
let got = fs::read(&p).context("read back through mount")?;
64+
assert_eq!(got, payload);
65+
66+
let physical = ciphertext_files_total_len(&mount.backing_root)?;
67+
// Header alone is 12 bytes; a real write must push this well past that.
68+
assert!(
69+
physical > 12,
70+
"backing ciphertext is only {physical} bytes; file data was not persisted \
71+
(this is the reported wide-file-IV bug: header-only backing file, no data)"
72+
);
73+
74+
Ok(())
75+
}
76+
77+
/// Copies a multi-block file *from outside the mount* into a fresh wide-IV
78+
/// mount via the real `cp` binary, matching a realistic `cp src dest-inside-mount`.
79+
/// (This is the external-source case; see `live_mount.rs` for the same-mount
80+
/// case, which fails for reasons unrelated to wide IVs.)
81+
#[test]
82+
#[ignore]
83+
fn live_wide_file_iv_multi_block_copy() -> Result<()> {
84+
require_live();
85+
if !live_enabled() {
86+
return Ok(());
87+
}
88+
89+
let (backing_root, cfg) = live::init_wide_v7_backing_root()?;
90+
let mount = MountGuard::mount_existing_backing_root(cfg, false, backing_root)?;
91+
92+
let src_dir = live::unique_temp_dir("encfs_live_wide_v7_src")?;
93+
let src = src_dir.join("payload.bin");
94+
let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
95+
fs::write(&src, &payload).context("write source payload")?;
96+
97+
let dst = mount.mount_point.join("payload.bin");
98+
let status = Command::new("cp")
99+
.arg(&src)
100+
.arg(&dst)
101+
.status()
102+
.context("spawn cp")?;
103+
anyhow::ensure!(status.success(), "cp exited with {status}");
104+
105+
let got = fs::read(&dst).context("read back copied file")?;
106+
assert_eq!(got.len(), payload.len(), "copied file size mismatch");
107+
assert_eq!(got, payload, "copied file content mismatch");
108+
109+
Ok(())
110+
}

0 commit comments

Comments
 (0)