diff --git a/Cargo.lock b/Cargo.lock index d6542584..755cbb77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1802,6 +1802,7 @@ name = "rage" version = "0.6.0" dependencies = [ "age", + "age-core", "chrono", "clap 3.0.0-beta.2", "clap_generate", @@ -1816,6 +1817,7 @@ dependencies = [ "libc", "log 0.4.14", "man", + "nix", "pinentry", "rust-embed", "secrecy", diff --git a/age-core/src/format.rs b/age-core/src/format.rs index 6248f1a6..70c4d569 100644 --- a/age-core/src/format.rs +++ b/age-core/src/format.rs @@ -66,7 +66,7 @@ impl<'a> AgeStanza<'a> { /// recipient. /// /// This is the owned type; see [`AgeStanza`] for the reference type. -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Stanza { /// A tag identifying this stanza type. pub tag: String, diff --git a/age/src/cli_common.rs b/age/src/cli_common.rs index 18a68672..b21ab5d7 100644 --- a/age/src/cli_common.rs +++ b/age/src/cli_common.rs @@ -25,14 +25,14 @@ pub fn read_identities( file_not_found: G, identity_encrypted_without_passphrase: H, #[cfg(feature = "ssh")] unsupported_ssh: impl Fn(String, crate::ssh::UnsupportedKey) -> E, -) -> Result>, E> +) -> Result>, E> where E: From, E: From, G: Fn(String) -> E, H: Fn(String) -> E, { - let mut identities: Vec> = vec![]; + let mut identities: Vec> = vec![]; for filename in filenames { // Try parsing as an encrypted age identity. diff --git a/age/src/encrypted.rs b/age/src/encrypted.rs index 8ef4d92b..99efd22f 100644 --- a/age/src/encrypted.rs +++ b/age/src/encrypted.rs @@ -1,6 +1,6 @@ //! The "encrypted age identity file" identity type. -use std::{cell::Cell, io}; +use std::{io, ops::DerefMut, sync::Mutex}; use i18n_embed_fl::fl; @@ -77,12 +77,12 @@ impl IdentityState { /// An encrypted age identity file. pub struct Identity { - state: Cell>, + state: Mutex>>, filename: Option, callbacks: C, } -impl Identity { +impl Identity { /// Parses an encrypted identity from an input containing valid UTF-8. /// /// `filename` is the path to the file that the input is reading from, if any. @@ -98,10 +98,10 @@ impl Identity { match Decryptor::new(data)? { Decryptor::Recipients(_) => Ok(None), Decryptor::Passphrase(decryptor) => Ok(Some(Identity { - state: Cell::new(IdentityState::Encrypted { + state: Mutex::new(Some(IdentityState::Encrypted { decryptor, max_work_factor, - }), + })), filename, callbacks, })), @@ -113,9 +113,11 @@ impl Identity { /// If this encrypted identity has not been decrypted yet, calling this method will /// trigger a passphrase request. pub fn recipients(&self) -> Result>, EncryptError> { - match self - .state + let mut state = self.state.lock().unwrap(); + match state + .deref_mut() .take() + .expect("We never leave this set to None") .decrypt(self.filename.as_deref(), self.callbacks.clone()) { Ok((identities, _)) => { @@ -124,11 +126,11 @@ impl Identity { .map(|entry| entry.to_recipient(self.callbacks.clone())) .collect::, _>>(); - self.state.set(IdentityState::Decrypted(identities)); + *state = Some(IdentityState::Decrypted(identities)); recipients } Err(e) => { - self.state.set(IdentityState::Poisoned(Some(e.clone()))); + *state = Some(IdentityState::Poisoned(Some(e.clone()))); Err(EncryptError::EncryptedIdentities(e)) } } @@ -151,12 +153,14 @@ impl Identity { ) -> Option> where F: Fn( - Result, DecryptError>, + Result, DecryptError>, ) -> Option>, { - match self - .state + let mut state = self.state.lock().unwrap(); + match state + .deref_mut() .take() + .expect("We never leave this set to None") .decrypt(self.filename.as_deref(), self.callbacks.clone()) { Ok((identities, requested_passphrase)) => { @@ -175,18 +179,18 @@ impl Identity { )); } - self.state.set(IdentityState::Decrypted(identities)); + *state = Some(IdentityState::Decrypted(identities)); result } Err(e) => { - self.state.set(IdentityState::Poisoned(Some(e.clone()))); + *state = Some(IdentityState::Poisoned(Some(e.clone()))); Some(Err(e)) } } } } -impl crate::Identity for Identity { +impl crate::Identity for Identity { fn unwrap_stanza( &self, stanza: &age_core::format::Stanza, @@ -210,7 +214,7 @@ impl crate::Identity for Identity>); + struct MockCallbacks(Mutex>); + + impl Clone for MockCallbacks { + fn clone(&self) -> Self { + Self(Mutex::new(self.0.lock().unwrap().clone())) + } + } impl MockCallbacks { fn new(passphrase: &'static str) -> Self { - MockCallbacks(Cell::new(Some(passphrase))) + MockCallbacks(Mutex::new(Some(passphrase))) } } @@ -255,7 +264,9 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo= /// This intentionally panics if called twice. fn request_passphrase(&self, _: &str) -> Option { - Some(SecretString::new(self.0.take().unwrap().to_owned())) + Some(SecretString::new( + self.0.lock().unwrap().take().unwrap().to_owned(), + )) } } diff --git a/age/src/identity.rs b/age/src/identity.rs index 88666887..9534fc56 100644 --- a/age/src/identity.rs +++ b/age/src/identity.rs @@ -19,8 +19,8 @@ pub enum IdentityFileEntry { impl IdentityFileEntry { pub(crate) fn into_identity( self, - callbacks: impl Callbacks + 'static, - ) -> Result, DecryptError> { + callbacks: impl Callbacks + Send + Sync + 'static, + ) -> Result, DecryptError> { match self { IdentityFileEntry::Native(i) => Ok(Box::new(i)), #[cfg(feature = "plugin")] diff --git a/rage/Cargo.toml b/rage/Cargo.toml index 59400e3c..d386ab0d 100644 --- a/rage/Cargo.toml +++ b/rage/Cargo.toml @@ -57,8 +57,10 @@ rust-embed = "5" secrecy = "0.8" # rage-mount dependencies +age-core = { version = "0.6.0", path = "../age-core", optional = true } fuse_mt = { version = "0.5.1", optional = true } libc = { version = "0.2", optional = true } +nix = { version = "0.20", optional = true } tar = { version = "0.4", optional = true } time = { version = "0.1", optional = true } zip = { version = "0.5.9", optional = true } @@ -71,7 +73,7 @@ man = "0.3" [features] default = ["ssh"] -mount = ["fuse_mt", "libc", "tar", "time", "zip"] +mount = ["age-core", "fuse_mt", "libc", "nix", "tar", "time", "zip"] ssh = ["age/ssh"] unstable = ["age/unstable"] @@ -87,3 +89,8 @@ bench = false name = "rage-mount" required-features = ["mount"] bench = false + +[[bin]] +name = "rage-mount-dir" +required-features = ["mount"] +bench = false diff --git a/rage/i18n/en-US/rage.ftl b/rage/i18n/en-US/rage.ftl index 2fb20615..44335e34 100644 --- a/rage/i18n/en-US/rage.ftl +++ b/rage/i18n/en-US/rage.ftl @@ -142,9 +142,12 @@ rec-dec-recipient-flag = Did you mean to use {-flag-identity} to specify a priva info-decrypting = Decrypting {$filename} info-mounting-as-fuse = Mounting as FUSE filesystem +err-mnt-missing-source = Missing source. err-mnt-missing-filename = Missing filename. err-mnt-missing-mountpoint = Missing mountpoint. err-mnt-missing-types = Missing {-flag-mnt-types}. +err-mnt-must-be-dir = Mountpoint must be a directory. +err-mnt-source-must-be-dir = Source must be a directory. err-mnt-unknown-type = Unknown filesystem type "{$fs_type}" ## Unstable features diff --git a/rage/src/bin/rage-mount-dir/main.rs b/rage/src/bin/rage-mount-dir/main.rs new file mode 100644 index 00000000..20cdac08 --- /dev/null +++ b/rage/src/bin/rage-mount-dir/main.rs @@ -0,0 +1,253 @@ +use age::cli_common::read_identities; +use fuse_mt::FilesystemMT; +use gumdrop::Options; +use i18n_embed::{ + fluent::{fluent_language_loader, FluentLanguageLoader}, + DesktopLanguageRequester, +}; +use lazy_static::lazy_static; +use log::{error, info}; +use rust_embed::RustEmbed; +use std::ffi::OsStr; +use std::fmt; +use std::io; +use std::path::PathBuf; + +mod overlay; +mod reader; +mod util; +mod wrapper; + +#[derive(RustEmbed)] +#[folder = "i18n"] +struct Translations; + +const TRANSLATIONS: Translations = Translations {}; + +lazy_static! { + static ref LANGUAGE_LOADER: FluentLanguageLoader = fluent_language_loader!(); +} + +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!(LANGUAGE_LOADER, $message_id) + }}; +} + +macro_rules! wfl { + ($f:ident, $message_id:literal) => { + write!($f, "{}", fl!($message_id)) + }; +} + +macro_rules! wlnfl { + ($f:ident, $message_id:literal) => { + writeln!($f, "{}", fl!($message_id)) + }; +} + +enum Error { + Age(age::DecryptError), + IdentityEncryptedWithoutPassphrase(String), + IdentityNotFound(String), + Io(io::Error), + MissingIdentities, + MissingMountpoint, + MissingSource, + MountpointMustBeDir, + Nix(nix::Error), + SourceMustBeDir, + UnsupportedKey(String, age::ssh::UnsupportedKey), +} + +impl From for Error { + fn from(e: age::DecryptError) -> Self { + Error::Age(e) + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +impl From for Error { + fn from(e: nix::Error) -> Self { + Error::Nix(e) + } +} + +// Rust only supports `fn main() -> Result<(), E: Debug>`, so we implement `Debug` +// manually to provide the error output we want. +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Age(e) => match e { + age::DecryptError::ExcessiveWork { required, .. } => { + writeln!(f, "{}", e)?; + write!( + f, + "{}", + i18n_embed_fl::fl!( + LANGUAGE_LOADER, + "rec-dec-excessive-work", + wf = required + ) + ) + } + _ => write!(f, "{}", e), + }, + Error::IdentityEncryptedWithoutPassphrase(filename) => { + write!( + f, + "{}", + i18n_embed_fl::fl!( + LANGUAGE_LOADER, + "err-dec-identity-encrypted-without-passphrase", + filename = filename.as_str() + ) + ) + } + Error::IdentityNotFound(filename) => write!( + f, + "{}", + i18n_embed_fl::fl!( + LANGUAGE_LOADER, + "err-dec-identity-not-found", + filename = filename.as_str() + ) + ), + Error::Io(e) => write!(f, "{}", e), + Error::MissingIdentities => { + wlnfl!(f, "err-dec-missing-identities")?; + wlnfl!(f, "rec-dec-missing-identities") + } + Error::MissingMountpoint => wfl!(f, "err-mnt-missing-mountpoint"), + Error::MissingSource => wfl!(f, "err-mnt-missing-source"), + Error::MountpointMustBeDir => wfl!(f, "err-mnt-must-be-dir"), + Error::Nix(e) => write!(f, "{}", e), + Error::SourceMustBeDir => wfl!(f, "err-mnt-source-must-be-dir"), + Error::UnsupportedKey(filename, k) => k.display(f, Some(filename.as_str())), + }?; + writeln!(f)?; + writeln!(f, "[ {} ]", fl!("err-ux-A"))?; + write!( + f, + "[ {}: https://str4d.xyz/rage/report {} ]", + fl!("err-ux-B"), + fl!("err-ux-C") + ) + } +} + +#[derive(Debug, Options)] +struct AgeMountOptions { + #[options(free, help = "The directory to mount.")] + directory: String, + + #[options(free, help = "The path to mount at.")] + mountpoint: String, + + #[options(help = "Print this help message and exit.")] + help: bool, + + #[options(help = "Print version info and exit.", short = "V")] + version: bool, + + #[options( + help = "Maximum work factor to allow for passphrase decryption.", + meta = "WF", + no_short + )] + max_work_factor: Option, + + #[options(help = "Use the identity file at IDENTITY. May be repeated.")] + identity: Vec, +} + +fn mount_fs(open: F, mountpoint: PathBuf) +where + F: FnOnce() -> io::Result, +{ + let fuse_args: Vec<&OsStr> = vec![&OsStr::new("-o"), &OsStr::new("ro,auto_unmount")]; + + match open().map(|fs| fuse_mt::FuseMT::new(fs, 1)) { + Ok(fs) => { + info!("{}", fl!("info-mounting-as-fuse")); + if let Err(e) = fuse_mt::mount(fs, &mountpoint, &fuse_args) { + error!("{}", e); + } + } + Err(e) => { + error!("{}", e); + } + } +} + +fn main() -> Result<(), Error> { + use std::env::args; + + env_logger::builder() + .format_timestamp(None) + .filter_level(log::LevelFilter::Off) + .parse_default_env() + .init(); + + let requested_languages = DesktopLanguageRequester::requested_languages(); + i18n_embed::select(&*LANGUAGE_LOADER, &TRANSLATIONS, &requested_languages).unwrap(); + age::localizer().select(&requested_languages).unwrap(); + + let args = args().collect::>(); + + if console::user_attended() && args.len() == 1 { + // If gumdrop ever merges that PR, that can be used here + // instead. + println!("{} {} [OPTIONS]", fl!("usage-header"), args[0]); + println!(); + println!("{}", AgeMountOptions::usage()); + + return Ok(()); + } + + let opts = AgeMountOptions::parse_args_default_or_exit(); + + if opts.version { + println!("rage-mount-dir {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + if opts.directory.is_empty() { + return Err(Error::MissingSource); + } + if opts.mountpoint.is_empty() { + return Err(Error::MissingMountpoint); + } + + let directory = PathBuf::from(opts.directory); + if !directory.is_dir() { + return Err(Error::SourceMustBeDir); + } + let mountpoint = PathBuf::from(opts.mountpoint); + if !mountpoint.is_dir() { + return Err(Error::MountpointMustBeDir); + } + + let identities = read_identities( + opts.identity, + opts.max_work_factor, + Error::IdentityNotFound, + Error::IdentityEncryptedWithoutPassphrase, + Error::UnsupportedKey, + )?; + + if identities.is_empty() { + return Err(Error::MissingIdentities); + } + + mount_fs( + || crate::overlay::AgeOverlayFs::new(directory.into(), identities), + mountpoint, + ); + Ok(()) +} diff --git a/rage/src/bin/rage-mount-dir/overlay.rs b/rage/src/bin/rage-mount-dir/overlay.rs new file mode 100644 index 00000000..3dd8ea5b --- /dev/null +++ b/rage/src/bin/rage-mount-dir/overlay.rs @@ -0,0 +1,418 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use age::Identity; +use fuse_mt::*; +use nix::{dir::Dir, fcntl::OFlag, libc, sys::stat::Mode, unistd::AccessFlags}; +use time::Timespec; + +use crate::{ + reader::OpenedFile, + util::*, + wrapper::{check_file, AgeFile}, +}; + +pub struct AgeOverlayFs { + root: PathBuf, + identities: Vec>, + age_files: Mutex)>>, + open_dirs: Mutex>, + open_files: Mutex>, +} + +impl AgeOverlayFs { + pub fn new( + root: PathBuf, + identities: Vec>, + ) -> io::Result { + // TODO: Scan the directory to find age-encrypted files, and trial-decrypt them. + // We'll do this manually in order to cache the unwrapped FileKeys for X? minutes. + + Ok(AgeOverlayFs { + root, + identities, + age_files: Mutex::new(HashMap::new()), + open_dirs: Mutex::new(HashMap::new()), + open_files: Mutex::new(HashMap::new()), + }) + } + + fn base_path(&self, path: &Path) -> PathBuf { + self.root.join(path.strip_prefix("/").unwrap()) + } + + fn age_stat(&self, f: &AgeFile, mut stat: FileAttr) -> FileAttr { + stat.size = f.size; + stat + } +} + +const TTL: Timespec = Timespec { sec: 1, nsec: 0 }; + +impl FilesystemMT for AgeOverlayFs { + fn getattr(&self, _req: RequestInfo, path: &Path, fh: Option) -> ResultEntry { + let age_files = self.age_files.lock().unwrap(); + let base_path = self.base_path(path); + let (query_path, age_file) = match age_files.get(&base_path) { + Some((real_path, Some(f))) => (real_path, Some(f)), + _ => (&base_path, None), + }; + + use std::os::unix::io::RawFd; + nix_err(if let Some(fd) = fh { + nix::sys::stat::fstat(fd as RawFd) + } else { + nix::sys::stat::lstat(query_path) + }) + .map(nix_stat) + .map(|stat| { + if let Some(f) = age_file { + self.age_stat(f, stat) + } else { + stat + } + }) + .map(|stat| (TTL, stat)) + } + + fn chmod(&self, _req: RequestInfo, _path: &Path, _fh: Option, _mode: u32) -> ResultEmpty { + Err(libc::EROFS) + } + + fn chown( + &self, + _req: RequestInfo, + _path: &Path, + _fh: Option, + _uid: Option, + _gid: Option, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + fn truncate( + &self, + _req: RequestInfo, + _path: &Path, + _fh: Option, + _size: u64, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + fn utimens( + &self, + _req: RequestInfo, + _path: &Path, + _fh: Option, + _atime: Option, + _mtime: Option, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + #[allow(clippy::too_many_arguments)] + fn utimens_macos( + &self, + _req: RequestInfo, + _path: &Path, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + fn readlink(&self, _req: RequestInfo, path: &Path) -> ResultData { + use std::os::unix::ffi::OsStringExt; + nix_err(nix::fcntl::readlink(&self.base_path(path))).map(|s| s.into_vec()) + } + + fn mknod( + &self, + _req: RequestInfo, + _parent: &Path, + _name: &OsStr, + _mode: u32, + _rdev: u32, + ) -> ResultEntry { + Err(libc::EROFS) + } + + fn mkdir(&self, _req: RequestInfo, _parent: &Path, _name: &OsStr, _mode: u32) -> ResultEntry { + Err(libc::EROFS) + } + + fn unlink(&self, _req: RequestInfo, _parent: &Path, _name: &OsStr) -> ResultEmpty { + Err(libc::EROFS) + } + + fn rmdir(&self, _req: RequestInfo, _parent: &Path, _name: &OsStr) -> ResultEmpty { + Err(libc::EROFS) + } + + fn symlink( + &self, + _req: RequestInfo, + _parent: &Path, + _name: &OsStr, + _target: &Path, + ) -> ResultEntry { + Err(libc::EROFS) + } + + fn rename( + &self, + _req: RequestInfo, + _parent: &Path, + _name: &OsStr, + _newparent: &Path, + _newname: &OsStr, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + fn link( + &self, + _req: RequestInfo, + _path: &Path, + _newparent: &Path, + _newname: &OsStr, + ) -> ResultEntry { + Err(libc::EROFS) + } + + fn open(&self, _req: RequestInfo, path: &Path, _flags: u32) -> ResultOpen { + let age_files = self.age_files.lock().unwrap(); + let base_path = self.base_path(path); + let file = match age_files.get(&base_path) { + Some((real_path, Some(f))) => OpenedFile::age(real_path, f), + _ => OpenedFile::normal(&base_path), + } + .map_err(|e| e.raw_os_error().unwrap_or(0))?; + let fh = file.handle(); + + let mut open_files = self.open_files.lock().unwrap(); + open_files.insert(fh, file); + + Ok((fh, 0)) + } + + fn read( + &self, + _req: RequestInfo, + _path: &Path, + fh: u64, + offset: u64, + size: u32, + callback: impl FnOnce(ResultSlice<'_>) -> CallbackResult, + ) -> CallbackResult { + let mut open_files = self.open_files.lock().unwrap(); + let mut buf = vec![0; size as usize]; + callback(open_files.get_mut(&fh).ok_or(libc::EBADF).and_then(|file| { + file.seek(SeekFrom::Start(offset)) + .and_then(|_| file.read(&mut buf)) + .map(|bytes| &buf[..bytes]) + .map_err(|e| e.raw_os_error().unwrap_or(0)) + })) + } + + fn write( + &self, + _req: RequestInfo, + _path: &Path, + _fh: u64, + _offset: u64, + _data: Vec, + _flags: u32, + ) -> ResultWrite { + Err(libc::EROFS) + } + + fn flush(&self, _req: RequestInfo, _path: &Path, _fh: u64, _lock_owner: u64) -> ResultEmpty { + Ok(()) + } + + fn release( + &self, + _req: RequestInfo, + _path: &Path, + fh: u64, + _flags: u32, + _lock_owner: u64, + _flush: bool, + ) -> ResultEmpty { + let mut open_files = self.open_files.lock().unwrap(); + open_files.remove(&fh).map(|_| ()).ok_or(libc::EBADF) + } + + fn fsync(&self, _req: RequestInfo, _path: &Path, _fh: u64, _datasync: bool) -> ResultEmpty { + Err(libc::EROFS) + } + + fn opendir(&self, _req: RequestInfo, path: &Path, flags: u32) -> ResultOpen { + use std::os::unix::io::AsRawFd; + + let oflag = OFlag::from_bits_truncate(flags as i32); + let mode = Mode::from_bits_truncate(flags); + + let dir = nix_err(Dir::open(&self.base_path(path), oflag, mode))?; + let fh = dir.as_raw_fd() as u64; + + let mut open_dirs = self.open_dirs.lock().unwrap(); + open_dirs.insert(fh, dir); + + Ok((fh, 0)) + } + + fn readdir(&self, _req: RequestInfo, path: &Path, fh: u64) -> ResultReaddir { + use std::os::unix::ffi::OsStrExt; + + let mut age_files = self.age_files.lock().unwrap(); + let mut open_dirs = self.open_dirs.lock().unwrap(); + let dir = open_dirs.get_mut(&fh).ok_or(libc::EBADF)?; + + dir.iter() + .map(nix_err) + .map(|res| { + res.and_then(|entry| { + let kind = entry + .file_type() + .map(nix_type) + .ok_or(libc::EINVAL) + .or_else(|_| { + nix_err( + nix::sys::stat::fstat(fh as i32) + .map(nix_stat) + .map(|stat| stat.kind), + ) + })?; + let name = Path::new(OsStr::from_bytes(entry.file_name().to_bytes())); + + let name = match name.extension() { + Some(ext) if ext == "age" => { + let path = self.base_path(path).join(name); + match age_files.get(&path.with_extension("")) { + // We can decrypt this; remove the .age from the filename. + Some((_, Some(_))) => name.to_owned().with_extension("").into(), + // We can't decrypt this; leave the name as-is. + Some((_, None)) => name.into(), + // We haven't seen this .age file; test it! + None => { + let (path, file) = check_file(path, &self.identities) + .map_err(|e| e.raw_os_error().unwrap_or(0))?; + let decrypted = file.is_some(); + + // Remember whether we can decrypt this file! + age_files.insert(path.with_extension(""), (path, file)); + + if decrypted { + // Remove the .age from the filename. + name.to_owned().with_extension("").into() + } else { + name.into() + } + } + } + } + _ => name.into(), + }; + + Ok(DirectoryEntry { name, kind }) + }) + }) + .collect() + } + + fn releasedir(&self, _req: RequestInfo, _path: &Path, fh: u64, _flags: u32) -> ResultEmpty { + let mut open_dirs = self.open_dirs.lock().unwrap(); + open_dirs.remove(&fh).map(|_| ()).ok_or(libc::EBADF) + } + + fn fsyncdir(&self, _req: RequestInfo, _path: &Path, _fh: u64, _datasync: bool) -> ResultEmpty { + Err(libc::EROFS) + } + + fn statfs(&self, _req: RequestInfo, path: &Path) -> ResultStatfs { + nix_err(nix::sys::statfs::statfs(&self.base_path(path))).map(nix_statfs) + } + + fn setxattr( + &self, + _req: RequestInfo, + _path: &Path, + _name: &OsStr, + _value: &[u8], + _flags: u32, + _position: u32, + ) -> ResultEmpty { + Err(libc::EROFS) + } + + /// Get a file extended attribute. + /// + /// * `path`: path to the file + /// * `name`: attribute name. + /// * `size`: the maximum number of bytes to read. + /// + /// If `size` is 0, return `Xattr::Size(n)` where `n` is the size of the attribute data. + /// Otherwise, return `Xattr::Data(data)` with the requested data. + fn getxattr(&self, _req: RequestInfo, _path: &Path, _name: &OsStr, _size: u32) -> ResultXattr { + Err(libc::ENOSYS) + } + + /// List extended attributes for a file. + /// + /// * `path`: path to the file. + /// * `size`: maximum number of bytes to return. + /// + /// If `size` is 0, return `Xattr::Size(n)` where `n` is the size required for the list of + /// attribute names. + /// Otherwise, return `Xattr::Data(data)` where `data` is all the null-terminated attribute + /// names. + fn listxattr(&self, _req: RequestInfo, _path: &Path, _size: u32) -> ResultXattr { + Err(libc::ENOSYS) + } + + fn removexattr(&self, _req: RequestInfo, _path: &Path, _name: &OsStr) -> ResultEmpty { + Err(libc::EROFS) + } + + fn access(&self, _req: RequestInfo, path: &Path, mask: u32) -> ResultEmpty { + let amode = AccessFlags::from_bits_truncate(mask as i32); + nix_err(nix::unistd::access(path, amode)) + } + + fn create( + &self, + _req: RequestInfo, + _parent: &Path, + _name: &OsStr, + _mode: u32, + _flags: u32, + ) -> ResultCreate { + Err(libc::EROFS) + } + + #[cfg(target_os = "macos")] + fn setvolname(&self, _req: RequestInfo, _name: &OsStr) -> ResultEmpty { + Err(libc::EROFS) + } + + // exchange (macOS only, undocumented) + + /// macOS only: Query extended times (bkuptime and crtime). + /// + /// * `path`: path to the file to get the times for. + /// + /// Return an `XTimes` struct with the times, or other error code as appropriate. + #[cfg(target_os = "macos")] + fn getxtimes(&self, _req: RequestInfo, _path: &Path) -> ResultXTimes { + Err(libc::ENOSYS) + } +} diff --git a/rage/src/bin/rage-mount-dir/reader.rs b/rage/src/bin/rage-mount-dir/reader.rs new file mode 100644 index 00000000..f4867c0f --- /dev/null +++ b/rage/src/bin/rage-mount-dir/reader.rs @@ -0,0 +1,70 @@ +use std::fs::File; +use std::io; +use std::path::Path; + +use age::stream::StreamReader; + +use crate::wrapper::AgeFile; + +pub(crate) enum OpenedFile { + Normal(File), + Age { + reader: StreamReader, + handle: u64, + }, +} + +impl OpenedFile { + pub(crate) fn normal(path: &Path) -> io::Result { + File::open(path).map(OpenedFile::Normal) + } + + pub(crate) fn age(path: &Path, age_file: &AgeFile) -> io::Result { + let file = File::open(path)?; + + use std::os::unix::io::AsRawFd; + let handle = file.as_raw_fd() as u64; + + let decryptor = match age::Decryptor::new(file).unwrap() { + age::Decryptor::Recipients(d) => d, + _ => unreachable!(), + }; + let reader = decryptor + .decrypt( + Some(&age_file.file_key) + .into_iter() + .map(|i| i as &dyn age::Identity), + ) + .unwrap(); + + Ok(OpenedFile::Age { reader, handle }) + } + + pub(crate) fn handle(&self) -> u64 { + match self { + OpenedFile::Normal(file) => { + use std::os::unix::io::AsRawFd; + file.as_raw_fd() as u64 + } + OpenedFile::Age { handle, .. } => *handle, + } + } +} + +impl io::Read for OpenedFile { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + OpenedFile::Normal(file) => file.read(buf), + OpenedFile::Age { reader, .. } => reader.read(buf), + } + } +} + +impl io::Seek for OpenedFile { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + match self { + OpenedFile::Normal(file) => file.seek(pos), + OpenedFile::Age { reader, .. } => reader.seek(pos), + } + } +} diff --git a/rage/src/bin/rage-mount-dir/util.rs b/rage/src/bin/rage-mount-dir/util.rs new file mode 100644 index 00000000..ceae7246 --- /dev/null +++ b/rage/src/bin/rage-mount-dir/util.rs @@ -0,0 +1,78 @@ +use std::io; + +use fuse_mt::*; +use nix::{ + libc, + sys::stat::{Mode, SFlag}, +}; +use time::Timespec; + +pub(crate) fn nix_err(res: nix::Result) -> Result { + // TODO: This clobbers nix-unknown errors to 0. + res.map_err(|e| { + e.as_errno() + .map(io::Error::from) + .and_then(|e| e.raw_os_error()) + .unwrap_or(0) + }) +} + +pub(crate) fn nix_kind(kind: SFlag) -> FileType { + match kind & SFlag::S_IFMT { + SFlag::S_IFIFO => FileType::NamedPipe, + SFlag::S_IFCHR => FileType::CharDevice, + SFlag::S_IFDIR => FileType::Directory, + SFlag::S_IFBLK => FileType::BlockDevice, + SFlag::S_IFREG => FileType::RegularFile, + SFlag::S_IFLNK => FileType::Symlink, + SFlag::S_IFSOCK => FileType::Socket, + _ => unreachable!(), + } +} + +pub(crate) fn nix_type(file_type: nix::dir::Type) -> FileType { + use nix::dir::Type; + match file_type { + Type::Fifo => FileType::NamedPipe, + Type::CharacterDevice => FileType::CharDevice, + Type::Directory => FileType::Directory, + Type::BlockDevice => FileType::BlockDevice, + Type::File => FileType::RegularFile, + Type::Symlink => FileType::Symlink, + Type::Socket => FileType::Socket, + } +} + +pub(crate) fn nix_stat(stat: nix::sys::stat::FileStat) -> FileAttr { + let kind = SFlag::from_bits_truncate(stat.st_mode); + let perm = Mode::from_bits_truncate(stat.st_mode); + + FileAttr { + size: stat.st_size as u64, + blocks: stat.st_blocks as u64, + atime: Timespec::new(stat.st_atime, stat.st_atime_nsec as i32), + mtime: Timespec::new(stat.st_mtime, stat.st_mtime_nsec as i32), + ctime: Timespec::new(stat.st_ctime, stat.st_ctime_nsec as i32), + crtime: Timespec::new(0, 0), + kind: nix_kind(kind), + perm: perm.bits() as u16, + nlink: stat.st_nlink as u32, + uid: stat.st_uid, + gid: stat.st_gid, + rdev: stat.st_rdev as u32, + flags: 0, + } +} + +pub(crate) fn nix_statfs(statfs: nix::sys::statfs::Statfs) -> Statfs { + Statfs { + blocks: statfs.blocks(), + bfree: 0, + bavail: 0, + files: statfs.files(), + ffree: 0, + bsize: statfs.optimal_transfer_size() as u32, + namelen: statfs.maximum_name_length() as u32, + frsize: statfs.optimal_transfer_size() as u32, + } +} diff --git a/rage/src/bin/rage-mount-dir/wrapper.rs b/rage/src/bin/rage-mount-dir/wrapper.rs new file mode 100644 index 00000000..6af7ebf1 --- /dev/null +++ b/rage/src/bin/rage-mount-dir/wrapper.rs @@ -0,0 +1,97 @@ +use std::{ + cell::RefCell, + fmt, + fs::File, + io::{self, Seek, SeekFrom}, + path::PathBuf, +}; + +use age::{Decryptor, Identity}; +use age_core::format::{FileKey, Stanza}; +use secrecy::ExposeSecret; + +/// A file key we cached. It is bound to the specific stanza it was unwrapped from. +pub(crate) struct CachedFileKey { + stanza: Stanza, + inner: FileKey, +} + +impl fmt::Debug for CachedFileKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.stanza.fmt(f) + } +} + +impl Identity for CachedFileKey { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + // This compares the entire stanza, including the file key ciphertexts, so we can + // be confident that the wrapped file keys are identical, and thus return this + // cached file key. + if stanza == &self.stanza { + Some(Ok(FileKey::from(*self.inner.expose_secret()))) + } else { + None + } + } +} + +/// A pseudo-identity that caches the first successfully-unwrapped file key. +struct FileKeyCacher<'a> { + identities: &'a [Box], + cache: RefCell>, +} + +impl<'a> FileKeyCacher<'a> { + fn new(identities: &'a [Box]) -> Self { + FileKeyCacher { + identities, + cache: RefCell::new(None), + } + } +} + +impl<'a> Identity for FileKeyCacher<'a> { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + self.identities.iter().find_map(|identity| { + if let Some(Ok(file_key)) = identity.unwrap_stanza(stanza) { + *self.cache.borrow_mut() = Some(CachedFileKey { + stanza: stanza.clone(), + inner: FileKey::from(*file_key.expose_secret()), + }); + Some(Ok(file_key)) + } else { + None + } + }) + } +} + +#[derive(Debug)] +pub(crate) struct AgeFile { + pub(crate) file_key: CachedFileKey, + pub(crate) size: u64, +} + +/// Returns: +/// - Ok((path, Some(_))) if this is an age file we can decrypt. +/// - Ok((path, None)) if this is not an age file, or we can't decrypt it. +pub(crate) fn check_file( + path: PathBuf, + identities: &[Box], +) -> io::Result<(PathBuf, Option)> { + let res = if let Ok(Decryptor::Recipients(d)) = Decryptor::new(File::open(&path)?) { + let cacher = FileKeyCacher::new(identities); + if let Ok(mut r) = d.decrypt(Some(&cacher).into_iter().map(|i| i as &dyn Identity)) { + Some(AgeFile { + file_key: cacher.cache.into_inner().unwrap(), + size: r.seek(SeekFrom::End(0))?, + }) + } else { + None + } + } else { + None + }; + + Ok((path, res)) +} diff --git a/rage/src/bin/rage-mount/main.rs b/rage/src/bin/rage-mount/main.rs index 18784fe1..78537dd5 100644 --- a/rage/src/bin/rage-mount/main.rs +++ b/rage/src/bin/rage-mount/main.rs @@ -281,7 +281,7 @@ fn main() -> Result<(), Error> { } decryptor - .decrypt(identities.iter().map(|i| &**i)) + .decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity)) .map_err(|e| e.into()) .and_then(|stream| mount_stream(stream, types, mountpoint)) } diff --git a/rage/src/bin/rage/main.rs b/rage/src/bin/rage/main.rs index cf3b7e8e..4d257b9c 100644 --- a/rage/src/bin/rage/main.rs +++ b/rage/src/bin/rage/main.rs @@ -422,7 +422,7 @@ fn decrypt(opts: AgeOptions) -> Result<(), error::DecryptError> { &opts.plugin_name, &[plugin::Identity::default_for_plugin(&opts.plugin_name)], UiCallbacks, - )?) as Box] + )?) as Box] }; if identities.is_empty() {