Skip to content

Commit 18ff8f4

Browse files
neilpangneilpangvgough
authored
build and test on FreeBSD (#705)
* build and test on FreeBSD * build: move the typed-fuse pin past the FreeBSD fixes --------- Co-authored-by: neilpang <git@neilpang.com> Co-authored-by: Valient Gough <vgough@arg0.net>
1 parent a991b37 commit 18ff8f4

10 files changed

Lines changed: 297 additions & 74 deletions

File tree

.github/workflows/freebsd.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: FreeBSD
2+
3+
on:
4+
push:
5+
branches: [ "master" ]
6+
pull_request:
7+
branches: [ "master" ]
8+
workflow_dispatch:
9+
10+
env:
11+
CARGO_TERM_COLOR: always
12+
13+
jobs:
14+
build:
15+
name: Build and Test
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- uses: actions/checkout@v7
20+
21+
- name: Build and test in a FreeBSD VM
22+
uses: vmactions/freebsd-vm@v1
23+
timeout-minutes: 90
24+
with:
25+
release: '15'
26+
usesh: true
27+
copyback: false
28+
cache-after-prepare: true
29+
30+
prepare: |
31+
pkg install -y git pkgconf rust protobuf llvm fusefs-libs3
32+
33+
run: |
34+
set -e
35+
36+
# protobuf provides protoc, which build.rs falls back to because
37+
# protoc-bin-vendored ships no FreeBSD binary. llvm provides the
38+
# libclang bindgen needs, and fusefs-libs3 the fuse3.pc that
39+
# libfuse-sys probes for.
40+
rustc --version
41+
pkg-config --modversion fuse3
42+
43+
# fusefs is a loadable module, not compiled into GENERIC, so
44+
# /dev/fuse only exists once it is loaded.
45+
kldstat -q -n fusefs.ko || kldload fusefs
46+
47+
cargo clippy --all-targets --all-features -- -D warnings
48+
49+
cargo build --release
50+
51+
cargo test --release
52+
53+
ENCFS_LIVE_TESTS=1 cargo test --release --test live_mount -- --ignored --test-threads=1

Cargo.lock

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

build.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
use std::io::Result;
22

33
fn main() -> Result<()> {
4-
let protoc_path = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc");
5-
// SAFETY: build script runs in isolated build environment; no other threads rely on PROTOC.
6-
unsafe {
7-
std::env::set_var("PROTOC", protoc_path);
4+
// protoc-bin-vendored only ships binaries for Linux, macOS and Windows,
5+
// so on other platforms (FreeBSD) there is nothing to point PROTOC at.
6+
// Leaving PROTOC unset makes prost-build search PATH for protoc instead.
7+
if let Ok(protoc_path) = protoc_bin_vendored::protoc_bin_path() {
8+
// SAFETY: build script runs in isolated build environment; no other threads rely on PROTOC.
9+
unsafe {
10+
std::env::set_var("PROTOC", protoc_path);
11+
}
812
}
913

1014
prost_build::Config::new()

src/fs.rs

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ use crate::crypto::block::BlockLayout;
22
use crate::crypto::cipher::Cipher;
33
use crate::crypto::file::{FileDecoder, FileEncoder};
44
use crate::crypto::file_iv::FileIv;
5-
use base64::Engine;
6-
use base64::engine::general_purpose::STANDARD_NO_PAD;
5+
use crate::xattr_name;
76
use libc;
87
use log::{debug, error, warn};
98
use std::borrow::Cow;
@@ -1583,10 +1582,9 @@ impl EncFs {
15831582
libc::EIO
15841583
})?;
15851584

1586-
// Store with "user.encfs." prefix + base64-encoded encrypted name
1587-
// Use base64 encoding for the encrypted name to make it filesystem-safe
1588-
let encoded_name = STANDARD_NO_PAD.encode(&encrypted_name);
1589-
let final_name = format!("user.encfs.{}", encoded_name);
1585+
// Store under the "user.encfs." prefix, with the encrypted name
1586+
// base64-encoded so it is a legal attribute name everywhere.
1587+
let final_name = xattr_name::encode(&encrypted_name);
15901588

15911589
let c_name = std::ffi::CString::new(final_name).map_err(|_| libc::EINVAL)?;
15921590
let c_path = c_path(&real_path).map_err(|e| e.raw())?;
@@ -1615,16 +1613,24 @@ impl EncFs {
16151613
})?;
16161614

16171615
// Encode encrypted name for storage lookup
1618-
let encoded_name = STANDARD_NO_PAD.encode(&encrypted_name);
1619-
let lookup_name = format!("user.encfs.{}", encoded_name);
1616+
let lookup_name = xattr_name::encode(&encrypted_name);
16201617

16211618
let c_name = std::ffi::CString::new(lookup_name).map_err(|_| libc::EINVAL)?;
16221619
let c_path = c_path(&real_path).map_err(|e| e.raw())?;
16231620

16241621
// Read the on-disk (encrypted) value; the caller's size limit is
16251622
// applied by the trait wrapper against the decrypted length.
1626-
let encrypted_value =
1627-
passthrough::getxattr_value_nofollow(&c_path, &c_name).map_err(|e| e.raw())?;
1623+
let encrypted_value = match passthrough::getxattr_value_nofollow(&c_path, &c_name) {
1624+
Ok(value) => value,
1625+
// An attribute written before the alphabet change carries the
1626+
// older spelling; try that before reporting it missing.
1627+
Err(e) if e == Errno::ENOATTR => {
1628+
let legacy = xattr_name::encode_legacy(&encrypted_name);
1629+
let c_legacy = std::ffi::CString::new(legacy).map_err(|_| libc::EINVAL)?;
1630+
passthrough::getxattr_value_nofollow(&c_path, &c_legacy).map_err(|e| e.raw())?
1631+
}
1632+
Err(e) => return Err(e.raw()),
1633+
};
16281634

16291635
// Decrypt value
16301636
let decrypted_value = self
@@ -1658,11 +1664,11 @@ impl EncFs {
16581664
Err(_) => continue, // Invalid UTF-8, skip
16591665
};
16601666

1661-
if let Some(encoded_part) = name_str.strip_prefix("user.encfs.") {
1667+
if let Some(encoded_part) = name_str.strip_prefix(xattr_name::PREFIX) {
16621668
// This is an encrypted encfs attribute stored on disk
16631669
// Extract the base64-encoded encrypted name
1664-
match STANDARD_NO_PAD.decode(encoded_part) {
1665-
Ok(encrypted_name_bytes) => {
1670+
match xattr_name::decode(encoded_part) {
1671+
Some(encrypted_name_bytes) => {
16661672
match self
16671673
.cipher
16681674
.decrypt_xattr_name(&encrypted_name_bytes, path_iv)
@@ -1678,7 +1684,7 @@ impl EncFs {
16781684
}
16791685
}
16801686
}
1681-
Err(_) => {
1687+
None => {
16821688
warn!("Failed to decode base64 xattr name: {}", name_str);
16831689
// Skip this name but continue
16841690
}
@@ -1715,14 +1721,21 @@ impl EncFs {
17151721
})?;
17161722

17171723
// Encode encrypted name for storage lookup
1718-
let encoded_name = STANDARD_NO_PAD.encode(&encrypted_name);
1719-
let lookup_name = format!("user.encfs.{}", encoded_name);
1724+
let lookup_name = xattr_name::encode(&encrypted_name);
17201725

17211726
let c_name = std::ffi::CString::new(lookup_name).map_err(|_| libc::EINVAL)?;
17221727
let c_path = c_path(&real_path).map_err(|e| e.raw())?;
17231728

1724-
// Remove xattr from underlying filesystem
1725-
passthrough::removexattr_nofollow(&c_path, &c_name).map_err(|e| e.raw())
1729+
// Remove xattr from underlying filesystem, falling back to the older
1730+
// spelling for attributes written before the alphabet change.
1731+
match passthrough::removexattr_nofollow(&c_path, &c_name) {
1732+
Err(e) if e == Errno::ENOATTR => {
1733+
let legacy = xattr_name::encode_legacy(&encrypted_name);
1734+
let c_legacy = std::ffi::CString::new(legacy).map_err(|_| libc::EINVAL)?;
1735+
passthrough::removexattr_nofollow(&c_path, &c_legacy).map_err(|e| e.raw())
1736+
}
1737+
other => other.map_err(|e| e.raw()),
1738+
}
17261739
}
17271740
}
17281741

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod crypto;
66
pub mod fs;
77
pub mod reverse_fs;
88
pub mod security;
9+
pub mod xattr_name;
910

1011
rust_i18n::i18n!("locales", fallback = "en");
1112

src/reverse_fs.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ use crate::config::EncfsConfig;
22
use crate::crypto::block::{BlockCodec, BlockLayout};
33
use crate::crypto::cipher::Cipher;
44
use crate::crypto::file_iv::FileIv;
5-
use base64::Engine;
6-
use base64::engine::general_purpose::STANDARD_NO_PAD;
5+
use crate::xattr_name;
76
use libc;
87
use log::{debug, warn};
98
use std::borrow::Cow;
@@ -719,19 +718,21 @@ impl ReverseFs {
719718

720719
fn decrypt_xattr_name(&self, name: &OsStr, path_iv: u64) -> Result<Vec<u8>, libc::c_int> {
721720
let name = name.to_str().ok_or(libc::EILSEQ)?;
722-
let encoded = name.strip_prefix("user.encfs.").ok_or(libc::ENODATA)?;
723-
let encrypted = STANDARD_NO_PAD.decode(encoded).map_err(|_| libc::ENODATA)?;
721+
let encoded = name
722+
.strip_prefix(xattr_name::PREFIX)
723+
.ok_or(Errno::ENOATTR.raw())?;
724+
let encrypted = xattr_name::decode(encoded).ok_or(Errno::ENOATTR.raw())?;
724725
self.cipher
725726
.decrypt_xattr_name(&encrypted, path_iv)
726-
.map_err(|_| libc::ENODATA)
727+
.map_err(|_| Errno::ENOATTR.raw())
727728
}
728729

729730
fn encrypted_xattr_name(&self, name: &[u8], path_iv: u64) -> Result<String, libc::c_int> {
730731
let encrypted = self
731732
.cipher
732733
.encrypt_xattr_name(name, path_iv)
733734
.map_err(|_| libc::EIO)?;
734-
Ok(format!("user.encfs.{}", STANDARD_NO_PAD.encode(encrypted)))
735+
Ok(xattr_name::encode(&encrypted))
735736
}
736737
}
737738

@@ -1393,7 +1394,7 @@ impl PathFilesystem for ReverseFs {
13931394
) -> Result<XattrReply, Errno> {
13941395
let path = node.path().ok_or(Errno::ENOENT)?;
13951396
if Self::is_config_path(path) {
1396-
return Err(Errno::ENODATA);
1397+
return Err(Errno::ENOATTR);
13971398
}
13981399
let (source, path_iv) = self.resolve_source_path(path)?;
13991400
let plain_name = self.decrypt_xattr_name(name, path_iv)?;

src/xattr_name.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! On-disk naming for encfs's encrypted extended attributes.
2+
//!
3+
//! Each attribute is stored under [`PREFIX`] followed by the base64 of its
4+
//! encrypted name. [`PREFIX`] carries the `user.` namespace; that is part of
5+
//! the stored name on Linux and macOS, but on FreeBSD the `extattr_*`
6+
//! syscalls pass it out-of-band and the backing file records only
7+
//! `encfs.<b64>`. The standard base64 alphabet includes `/`, which FreeBSD
8+
//! will not accept in an extended-attribute name: `setextattr(8)` fails with
9+
//! `EINVAL` on a name containing one, while the same name spelled with `+`
10+
//! or `=` is stored without complaint. Two of the six distinct names the
11+
//! xattr tests here produce contain a `/`, so a third of attributes could
12+
//! not be stored on FreeBSD at all.
13+
//!
14+
//! New names therefore use the URL-safe alphabet, which spells the two
15+
//! disputed characters `-` and `_`. Reading accepts either. The alphabets
16+
//! differ only in those four characters, so a string that decodes under both
17+
//! contains none of them and yields the same bytes either way: trying one and
18+
//! then the other cannot return the wrong plaintext. [`encode_legacy`]
19+
//! reproduces the older spelling so a lookup can fall back to it.
20+
//!
21+
//! Only this port is affected. The C++ encfs passed attribute names through
22+
//! to the backing file unchanged; encrypting and encoding them arrived with
23+
//! the Rust port. Filenames are unrelated -- they use the cipher's own
24+
//! alphabet, not this one.
25+
26+
use base64::Engine;
27+
use base64::engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD};
28+
29+
/// Prefix encfs uses for a stored (encrypted) attribute name.
30+
pub const PREFIX: &str = "user.encfs.";
31+
32+
/// The on-disk name for an encrypted attribute name.
33+
pub fn encode(encrypted_name: &[u8]) -> String {
34+
format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(encrypted_name))
35+
}
36+
37+
/// The on-disk name a build from before the alphabet change would have
38+
/// written. Identical to [`encode`] whenever the encoding happens to use none
39+
/// of the characters the two alphabets disagree on.
40+
pub fn encode_legacy(encrypted_name: &[u8]) -> String {
41+
format!("{}{}", PREFIX, STANDARD_NO_PAD.encode(encrypted_name))
42+
}
43+
44+
/// Decode the base64 part of a stored name, accepting either alphabet.
45+
pub fn decode(encoded: &str) -> Option<Vec<u8>> {
46+
URL_SAFE_NO_PAD
47+
.decode(encoded)
48+
.or_else(|_| STANDARD_NO_PAD.decode(encoded))
49+
.ok()
50+
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use super::*;
55+
56+
/// Encodes to `///8` under the standard alphabet and `___8` under the
57+
/// URL-safe one, so it exercises exactly the disagreement.
58+
const DISPUTED: &[u8] = &[0xFF, 0xFF, 0xFC];
59+
60+
#[test]
61+
fn new_names_avoid_the_character_freebsd_rejects() {
62+
let name = encode(DISPUTED);
63+
assert!(!name.contains('/'), "{}", name);
64+
// and the old spelling really did contain it, or this proves nothing
65+
assert!(encode_legacy(DISPUTED).contains('/'));
66+
}
67+
68+
#[test]
69+
fn both_spellings_decode_to_the_same_bytes() {
70+
for name in [encode(DISPUTED), encode_legacy(DISPUTED)] {
71+
let encoded = name.strip_prefix(PREFIX).expect("prefix");
72+
assert_eq!(decode(encoded).expect("decodes"), DISPUTED, "{}", name);
73+
}
74+
}
75+
76+
#[test]
77+
fn the_spellings_coincide_when_nothing_is_disputed() {
78+
assert_eq!(encode(b"encfs"), encode_legacy(b"encfs"));
79+
}
80+
81+
#[test]
82+
fn rejects_what_is_not_base64() {
83+
assert!(decode("not base64!").is_none());
84+
}
85+
}

tests/live/mod.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,13 @@ pub fn mountinfo_has_mount(mount_point: &Path) -> io::Result<bool> {
194194
Ok(false)
195195
}
196196

197-
#[cfg(target_os = "macos")]
197+
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
198198
pub fn mountinfo_has_mount(mount_point: &Path) -> io::Result<bool> {
199-
// No /proc on macOS; parse `mount` output. Compare both the raw path and
200-
// the canonicalized one (/var/folders/... resolves to /private/var/...).
199+
// No /proc on macOS or FreeBSD; parse `mount` output. Compare both the raw
200+
// path and the canonicalized one (/var/folders/... resolves to
201+
// /private/var/...). FreeBSD prints the same
202+
// "<device> on <mountpoint> (<fstype>, ...)" shape and calls the type
203+
// "fusefs", so the substring test below covers it unchanged.
201204
let output = Command::new("/sbin/mount").stdin(Stdio::null()).output()?;
202205
let data = String::from_utf8_lossy(&output.stdout);
203206
let raw = format!(" on {} (", mount_point.display());

tests/permissions_test.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,11 @@ fn test_symlink_permissions_are_standard() {
321321
// Verify it's marked as a symlink
322322
assert_eq!(attr.kind, FileType::Symlink, "Expected symlink file type");
323323

324-
// On most Unix systems, symlinks have 0o777 permissions
325-
// (the actual file permissions are determined by the target)
326-
#[cfg(not(target_os = "macos"))]
324+
// Linux fixes symlink permission bits at 0o777 and ignores them. The BSDs
325+
// give symlinks mode bits of their own -- FreeBSD has lchmod(2), "similar
326+
// to chmod() but does not follow symbolic links" -- and report 0o755 here,
327+
// so they are excluded rather than asserted against a Linux constant.
328+
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
327329
assert_eq!(
328330
actual_mode, 0o777,
329331
"Symlinks should have 0o777 permissions, got {:o}",
@@ -407,13 +409,14 @@ fn test_permissions_mixed_types_in_directory() {
407409
file_mode, file_actual
408410
);
409411

410-
// Verify symlink permissions (should be 0o777)
412+
// Verify symlink permissions (0o777 on Linux; see the note in
413+
// test_symlink_permissions_are_standard for why the BSDs are excluded)
411414
let link = node(&encfs, &root, "/test_dir/test_link", &r);
412415
let link_attr = encfs
413416
.getattr(link.as_node(), None, &r)
414417
.expect("getattr for symlink failed");
415418
let link_actual = (link_attr.perm as u32) & 0o777;
416-
#[cfg(not(target_os = "macos"))]
419+
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
417420
assert_eq!(
418421
link_actual, 0o777,
419422
"Symlink mode mismatch: expected {:o}, got {:o}",

0 commit comments

Comments
 (0)