Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/freebsd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: FreeBSD

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
workflow_dispatch:

env:
CARGO_TERM_COLOR: always

jobs:
build:
name: Build and Test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@v1
timeout-minutes: 90
with:
release: '15'
usesh: true
copyback: false
cache-after-prepare: true

prepare: |
pkg install -y git pkgconf rust protobuf llvm fusefs-libs3
Comment thread
vgough marked this conversation as resolved.

run: |
set -e

# protobuf provides protoc, which build.rs falls back to because
# protoc-bin-vendored ships no FreeBSD binary. llvm provides the
# libclang bindgen needs, and fusefs-libs3 the fuse3.pc that
# libfuse-sys probes for.
rustc --version
pkg-config --modversion fuse3

# fusefs is a loadable module, not compiled into GENERIC, so
# /dev/fuse only exists once it is loaded.
kldstat -q -n fusefs.ko || kldload fusefs

cargo clippy --all-targets --all-features -- -D warnings

cargo build --release

cargo test --release

ENCFS_LIVE_TESTS=1 cargo test --release --test live_mount -- --ignored --test-threads=1
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use std::io::Result;

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

prost_build::Config::new()
Expand Down
49 changes: 31 additions & 18 deletions src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ use crate::crypto::block::BlockLayout;
use crate::crypto::cipher::Cipher;
use crate::crypto::file::{FileDecoder, FileEncoder};
use crate::crypto::file_iv::FileIv;
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use crate::xattr_name;
use libc;
use log::{debug, error, warn};
use std::borrow::Cow;
Expand Down Expand Up @@ -1583,10 +1582,9 @@ impl EncFs {
libc::EIO
})?;

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

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

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

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

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

// Decrypt value
let decrypted_value = self
Expand Down Expand Up @@ -1658,11 +1664,11 @@ impl EncFs {
Err(_) => continue, // Invalid UTF-8, skip
};

if let Some(encoded_part) = name_str.strip_prefix("user.encfs.") {
if let Some(encoded_part) = name_str.strip_prefix(xattr_name::PREFIX) {
// This is an encrypted encfs attribute stored on disk
// Extract the base64-encoded encrypted name
match STANDARD_NO_PAD.decode(encoded_part) {
Ok(encrypted_name_bytes) => {
match xattr_name::decode(encoded_part) {
Some(encrypted_name_bytes) => {
match self
.cipher
.decrypt_xattr_name(&encrypted_name_bytes, path_iv)
Expand All @@ -1678,7 +1684,7 @@ impl EncFs {
}
}
}
Err(_) => {
None => {
warn!("Failed to decode base64 xattr name: {}", name_str);
// Skip this name but continue
}
Expand Down Expand Up @@ -1715,14 +1721,21 @@ impl EncFs {
})?;

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

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

// Remove xattr from underlying filesystem
passthrough::removexattr_nofollow(&c_path, &c_name).map_err(|e| e.raw())
// Remove xattr from underlying filesystem, falling back to the older
// spelling for attributes written before the alphabet change.
match passthrough::removexattr_nofollow(&c_path, &c_name) {
Err(e) if e == Errno::ENOATTR => {
let legacy = xattr_name::encode_legacy(&encrypted_name);
let c_legacy = std::ffi::CString::new(legacy).map_err(|_| libc::EINVAL)?;
passthrough::removexattr_nofollow(&c_path, &c_legacy).map_err(|e| e.raw())
}
other => other.map_err(|e| e.raw()),
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod crypto;
pub mod fs;
pub mod reverse_fs;
pub mod security;
pub mod xattr_name;

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

Expand Down
15 changes: 8 additions & 7 deletions src/reverse_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ use crate::config::EncfsConfig;
use crate::crypto::block::{BlockCodec, BlockLayout};
use crate::crypto::cipher::Cipher;
use crate::crypto::file_iv::FileIv;
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use crate::xattr_name;
use libc;
use log::{debug, warn};
use std::borrow::Cow;
Expand Down Expand Up @@ -719,19 +718,21 @@ impl ReverseFs {

fn decrypt_xattr_name(&self, name: &OsStr, path_iv: u64) -> Result<Vec<u8>, libc::c_int> {
let name = name.to_str().ok_or(libc::EILSEQ)?;
let encoded = name.strip_prefix("user.encfs.").ok_or(libc::ENODATA)?;
let encrypted = STANDARD_NO_PAD.decode(encoded).map_err(|_| libc::ENODATA)?;
let encoded = name
.strip_prefix(xattr_name::PREFIX)
.ok_or(Errno::ENOATTR.raw())?;
let encrypted = xattr_name::decode(encoded).ok_or(Errno::ENOATTR.raw())?;
self.cipher
.decrypt_xattr_name(&encrypted, path_iv)
.map_err(|_| libc::ENODATA)
.map_err(|_| Errno::ENOATTR.raw())
}

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

Expand Down Expand Up @@ -1393,7 +1394,7 @@ impl PathFilesystem for ReverseFs {
) -> Result<XattrReply, Errno> {
let path = node.path().ok_or(Errno::ENOENT)?;
if Self::is_config_path(path) {
return Err(Errno::ENODATA);
return Err(Errno::ENOATTR);
}
let (source, path_iv) = self.resolve_source_path(path)?;
let plain_name = self.decrypt_xattr_name(name, path_iv)?;
Expand Down
85 changes: 85 additions & 0 deletions src/xattr_name.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! On-disk naming for encfs's encrypted extended attributes.
//!
//! Each attribute is stored under [`PREFIX`] followed by the base64 of its
//! encrypted name. [`PREFIX`] carries the `user.` namespace; that is part of
//! the stored name on Linux and macOS, but on FreeBSD the `extattr_*`
//! syscalls pass it out-of-band and the backing file records only
//! `encfs.<b64>`. The standard base64 alphabet includes `/`, which FreeBSD
//! will not accept in an extended-attribute name: `setextattr(8)` fails with
//! `EINVAL` on a name containing one, while the same name spelled with `+`
//! or `=` is stored without complaint. Two of the six distinct names the
//! xattr tests here produce contain a `/`, so a third of attributes could
//! not be stored on FreeBSD at all.
//!
//! New names therefore use the URL-safe alphabet, which spells the two
//! disputed characters `-` and `_`. Reading accepts either. The alphabets
//! differ only in those four characters, so a string that decodes under both
//! contains none of them and yields the same bytes either way: trying one and
//! then the other cannot return the wrong plaintext. [`encode_legacy`]
//! reproduces the older spelling so a lookup can fall back to it.
//!
//! Only this port is affected. The C++ encfs passed attribute names through
//! to the backing file unchanged; encrypting and encoding them arrived with
//! the Rust port. Filenames are unrelated -- they use the cipher's own
//! alphabet, not this one.

use base64::Engine;
use base64::engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD};

/// Prefix encfs uses for a stored (encrypted) attribute name.
pub const PREFIX: &str = "user.encfs.";

/// The on-disk name for an encrypted attribute name.
pub fn encode(encrypted_name: &[u8]) -> String {
format!("{}{}", PREFIX, URL_SAFE_NO_PAD.encode(encrypted_name))
}

/// The on-disk name a build from before the alphabet change would have
/// written. Identical to [`encode`] whenever the encoding happens to use none
/// of the characters the two alphabets disagree on.
pub fn encode_legacy(encrypted_name: &[u8]) -> String {
format!("{}{}", PREFIX, STANDARD_NO_PAD.encode(encrypted_name))
}

/// Decode the base64 part of a stored name, accepting either alphabet.
pub fn decode(encoded: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD
.decode(encoded)
.or_else(|_| STANDARD_NO_PAD.decode(encoded))
.ok()
}

#[cfg(test)]
mod tests {
use super::*;

/// Encodes to `///8` under the standard alphabet and `___8` under the
/// URL-safe one, so it exercises exactly the disagreement.
const DISPUTED: &[u8] = &[0xFF, 0xFF, 0xFC];

#[test]
fn new_names_avoid_the_character_freebsd_rejects() {
let name = encode(DISPUTED);
assert!(!name.contains('/'), "{}", name);
// and the old spelling really did contain it, or this proves nothing
assert!(encode_legacy(DISPUTED).contains('/'));
}

#[test]
fn both_spellings_decode_to_the_same_bytes() {
for name in [encode(DISPUTED), encode_legacy(DISPUTED)] {
let encoded = name.strip_prefix(PREFIX).expect("prefix");
assert_eq!(decode(encoded).expect("decodes"), DISPUTED, "{}", name);
}
}

#[test]
fn the_spellings_coincide_when_nothing_is_disputed() {
assert_eq!(encode(b"encfs"), encode_legacy(b"encfs"));
}

#[test]
fn rejects_what_is_not_base64() {
assert!(decode("not base64!").is_none());
}
}
9 changes: 6 additions & 3 deletions tests/live/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,13 @@ pub fn mountinfo_has_mount(mount_point: &Path) -> io::Result<bool> {
Ok(false)
}

#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
pub fn mountinfo_has_mount(mount_point: &Path) -> io::Result<bool> {
// No /proc on macOS; parse `mount` output. Compare both the raw path and
// the canonicalized one (/var/folders/... resolves to /private/var/...).
// No /proc on macOS or FreeBSD; parse `mount` output. Compare both the raw
// path and the canonicalized one (/var/folders/... resolves to
// /private/var/...). FreeBSD prints the same
// "<device> on <mountpoint> (<fstype>, ...)" shape and calls the type
// "fusefs", so the substring test below covers it unchanged.
let output = Command::new("/sbin/mount").stdin(Stdio::null()).output()?;
let data = String::from_utf8_lossy(&output.stdout);
let raw = format!(" on {} (", mount_point.display());
Expand Down
13 changes: 8 additions & 5 deletions tests/permissions_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,11 @@ fn test_symlink_permissions_are_standard() {
// Verify it's marked as a symlink
assert_eq!(attr.kind, FileType::Symlink, "Expected symlink file type");

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

// Verify symlink permissions (should be 0o777)
// Verify symlink permissions (0o777 on Linux; see the note in
// test_symlink_permissions_are_standard for why the BSDs are excluded)
let link = node(&encfs, &root, "/test_dir/test_link", &r);
let link_attr = encfs
.getattr(link.as_node(), None, &r)
.expect("getattr for symlink failed");
let link_actual = (link_attr.perm as u32) & 0o777;
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
assert_eq!(
link_actual, 0o777,
"Symlink mode mismatch: expected {:o}, got {:o}",
Expand Down
Loading
Loading