diff --git a/doc/CONFIGURATION.md b/doc/CONFIGURATION.md index fb66f4689..afcab04bc 100644 --- a/doc/CONFIGURATION.md +++ b/doc/CONFIGURATION.md @@ -298,6 +298,25 @@ ExecStop=/usr/bin/fusermount -u /home/ec2-user/s3-bucket-mount WantedBy=remote-fs.target ``` +### Providing a FUSE file descriptor for mounting + +Mountpoint supports mounting S3 buckets at a given path, or using a provided FUSE file descriptor (only on Linux). + +For directory mount points, the passed path must be an existing directory. + +For FUSE file descriptors on Linux, you can specify an open FUSE file descriptor as a mount point with `/dev/fd/N` syntax. +This is useful in container environments to achieve unprivileged mounts. +In this case, the caller is responsible for the following: +1. Opening the FUSE device (`/dev/fuse`) in read-write mode to obtain a file descriptor. +2. Performing the `mount` syscall with the desired mount point, the file descriptor, and any mount options. + Mountpoint by default uses and recommends enabling `nodev`, `nosuid`, `default_permissions`, and `noatime` mount options. + See the [Linux kernel documentation](https://man7.org/linux/man-pages/man8/mount.fuse3.8.html#OPTIONS) for more details on mount options. +3. Spawning Mountpoint with the file descriptor using `/dev/fd/N` syntax as the mount point argument. +4. Closing the file descriptor in the parent process. +5. Performing the `unmount` syscall on the mount point when unmounting is desired or when the Mountpoint process terminates. + +See [mounthelper.go](https://github.com/awslabs/mountpoint-s3/tree/main/examples/fuse-fd-mount-point/mounthelper.go) as an example usage of this feature. + ## Caching configuration Mountpoint can optionally cache object metadata and content to reduce cost and improve performance for repeated reads to the same file. diff --git a/examples/fuse-fd-mount-point/mounthelper.go b/examples/fuse-fd-mount-point/mounthelper.go new file mode 100644 index 000000000..fa9cc4513 --- /dev/null +++ b/examples/fuse-fd-mount-point/mounthelper.go @@ -0,0 +1,116 @@ +// An example script showing usage of FUSE file descriptor as a mount point. +// +// Example usage: +// $ go build mounthelper.go +// $ sudo /sbin/setcap 'cap_sys_admin=ep' ./mounthelper # `mount` syscall requires `CAP_SYS_ADMIN`, alternatively, `mounthelper` can be run as root +// $ ./mounthelper -mountpoint /tmp/mountpoint -bucket bucketname +// $ # Mountpoint mounted at /tmp/mountpoint until `mounthelper` is terminated with ctrl+c. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" +) + +var mountPoint = flag.String("mountpoint", "", "Path to mount the filesystem at") +var bucket = flag.String("bucket", "", "S3 Bucket to mount") + +func main() { + flag.Parse() + + // `os.MkdirAll` will return `nil` if `mountPoint` is an already existing directory. + if err := os.MkdirAll(*mountPoint, 0644); err != nil { + log.Panicf("Failed to create target mount point %s: %v\n", *mountPoint, err) + } + + // 1. Open FUSE device + const USE_DEFAULT_PERM = 0 + fd, err := syscall.Open("/dev/fuse", os.O_RDWR, USE_DEFAULT_PERM) + if err != nil { + log.Panicf("Failed to open /dev/fuse: %v\n", err) + } + + // Ensure to close the file descriptor in case of an error, if there are no errors, + // the file descriptor will be closed and `closeFd` will set to `false`. + closeFd := true + defer func() { + if closeFd { + err := syscall.Close(fd) + if err != nil { + log.Panicf("Failed to close fd on parent: %v\n", err) + } + } + }() + + var stat syscall.Stat_t + err = syscall.Stat(*mountPoint, &stat) + if err != nil { + log.Panicf("Failed to stat mount point %s: %v\n", *mountPoint, err) + } + + // 2. Perform `mount` syscall + // These mount options and flags match those typically set when using Mountpoint. + // Some are set by the underlying FUSE library. + // Mountpoint sets (correct at the time of authoring this comment): + // * `noatime` to avoid unsupported access time updates. + // * `default_permissions` to tell the Kernel to evaluate permissions itself, since Mountpoint does not currently provide any handler for FUSE `access`. + options := []string{ + fmt.Sprintf("fd=%d", fd), + fmt.Sprintf("rootmode=%o", stat.Mode), + fmt.Sprintf("user_id=%d", os.Geteuid()), + fmt.Sprintf("group_id=%d", os.Getegid()), + "default_permissions", + } + var flags uintptr = syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOATIME + err = syscall.Mount("mountpoint-s3", *mountPoint, "fuse", flags, strings.Join(options, ",")) + if err != nil { + log.Panicf("Failed to call mount syscall: %v\n", err) + } + + // 3. Define and defer call to `unmount` syscall, to be invoked once script terminates + defer func() { + err := syscall.Unmount(*mountPoint, 0) + if err != nil { + log.Printf("Failed to unmount %s: %v\n", *mountPoint, err) + } else { + log.Printf("Succesfully unmounted %s\n", *mountPoint) + } + }() + + // 4. Spawn Mountpoint with the fd + mountpointCmd := exec.Command("./target/release/mount-s3", + *bucket, + fmt.Sprintf("/dev/fd/%d", fd), + // Other mount options can be added here + "--prefix=some_s3_prefix/", + "--allow-delete", + ) + mountpointCmd.Stdout = os.Stdout + mountpointCmd.Stderr = os.Stderr + err = mountpointCmd.Run() + if err != nil { + log.Panicf("Failed to start Mountpoint: %v\n", err) + } + + // 4. Close fd on parent + err = syscall.Close(fd) + if err != nil { + log.Panicf("Failed to close fd on parent: %v\n", err) + } + // As we expliclity closed it, no need for `defer`red close to happen. + closeFd = false + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + log.Print("Filesystem mounted, waiting for ctrl+c signal to terminate") + <-done + + // 5. Unmounting will happen here due to `defer` in step 3. +} diff --git a/mountpoint-s3/CHANGELOG.md b/mountpoint-s3/CHANGELOG.md index e5f2b2fb9..1e487c449 100644 --- a/mountpoint-s3/CHANGELOG.md +++ b/mountpoint-s3/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +### New features + +* Mountpoint now supports specifying an open FUSE file descriptor in place of the mount path by using the syntax `/dev/fd/N`. + See [mounthelper.go](https://github.com/awslabs/mountpoint-s3/tree/main/examples/fuse-fd-mount-point/mounthelper.go) as an example usage and see + [Configuring mount point](https://github.com/awslabs/mountpoint-s3/blob/main/doc/CONFIGURATION.md#configuring-mount-point) about more details on configuring this feature. + ([#1103](https://github.com/awslabs/mountpoint-s3/pull/1103)) + ### Other changes * Fix an issue where an interrupt during `readdir` syscall leads to an error. ([#965](https://github.com/awslabs/mountpoint-s3/pull/965)) diff --git a/mountpoint-s3/src/cli.rs b/mountpoint-s3/src/cli.rs index 544aee5d3..9f0686989 100644 --- a/mountpoint-s3/src/cli.rs +++ b/mountpoint-s3/src/cli.rs @@ -1,9 +1,10 @@ use std::env; use std::ffi::OsString; +use std::fmt::Debug; use std::fs::File; use std::io::{Read, Write}; use std::num::NonZeroUsize; -use std::os::fd::AsRawFd; +use std::os::fd::{AsRawFd, RawFd}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -64,7 +65,18 @@ pub struct CliArgs { #[clap(help = "Name of bucket to mount", value_parser = parse_bucket_name)] pub bucket_name: String, - #[clap(help = "Directory to mount the bucket at", value_name = "DIRECTORY")] + #[clap( + help = "Directory or FUSE file descriptor to mount the bucket at", + long_help = "\ +Directory or FUSE file descriptor to mount the bucket at. + +For directory mount points, the passed path must be an existing directory. + +For FUSE file descriptors (Linux-only), it should be of the format `/dev/fd/N`. +Learn more in Mountpoint's configuration documentation (CONFIGURATION.md).\ + ", + value_name = "DIRECTORY" + )] pub mount_point: PathBuf, #[clap( @@ -522,7 +534,8 @@ impl CliArgs { } } - fn fuse_session_config(&self) -> FuseSessionConfig { + fn fuse_session_config(&self) -> anyhow::Result { + let mount_point = MountPoint::new(&self.mount_point).context("Failed to create mount point")?; let fs_name = String::from("mountpoint-s3"); let mut options = vec![ MountOption::DefaultPermissions, @@ -542,13 +555,29 @@ impl CliArgs { options.push(MountOption::AllowOther); } - let mount_point = self.mount_point.to_owned(); + #[cfg(target_os = "linux")] + if matches!(mount_point, MountPoint::FileDescriptor(_)) { + let passed_mount_options = &[(self.read_only, "--read-only"), (self.auto_unmount, "--auto-unmount")] + .iter() + .filter(|o| o.0) + .map(|o| o.1) + .collect::>(); + + if !passed_mount_options.is_empty() { + return Err(anyhow!( + "Mount options: {} are ignored with FUSE fd mount point.\ + Mount options should be passed while performing `mount` syscall in the caller process.", + passed_mount_options.join(", ") + )); + } + } + let max_threads = self.max_threads as usize; - FuseSessionConfig { + Ok(FuseSessionConfig { mount_point, options, max_threads, - } + }) } } @@ -831,7 +860,8 @@ where tracing::info!("mount-s3 {}", build_info::FULL_VERSION); tracing::debug!("{:?}", args); - validate_mount_point(&args.mount_point)?; + let fuse_config = args.fuse_session_config()?; + validate_sse_args(args.sse.as_deref(), args.sse_kms_key_id.as_deref())?; let (client, runtime, s3_personality) = client_builder(&args)?; @@ -839,8 +869,6 @@ where let bucket_description = args.bucket_description(); tracing::debug!("using S3 personality {s3_personality:?} for {bucket_description}"); - let fuse_config = args.fuse_session_config(); - let mut filesystem_config = S3FilesystemConfig::default(); if let Some(uid) = args.uid { filesystem_config.uid = uid; @@ -1011,15 +1039,21 @@ where { let fuse_fs = S3FuseFilesystem::new(fs); tracing::debug!(?fuse_session_config, "creating fuse session"); - let session = Session::new(fuse_fs, &fuse_session_config.mount_point, &fuse_session_config.options) - .context("Failed to create FUSE session")?; + let mount_point_path = format!("{}", &fuse_session_config.mount_point); + let session = match fuse_session_config.mount_point { + MountPoint::Directory(path) => { + Session::new(fuse_fs, path, &fuse_session_config.options).context("Failed to create FUSE session")? + } + #[cfg(target_os = "linux")] + MountPoint::FileDescriptor(fd) => Session::from_fd( + fuse_fs, + fd, + session_acl_from_mount_options(&fuse_session_config.options), + ), + }; let session = FuseSession::new(session, fuse_session_config.max_threads).context("Failed to start FUSE session")?; - tracing::info!( - "successfully mounted {} at {}", - bucket_description, - fuse_session_config.mount_point.display() - ); + tracing::info!("successfully mounted {} at {}", bucket_description, mount_point_path); Ok(session) } @@ -1027,11 +1061,136 @@ where /// Configuration for a FUSE background session. #[derive(Debug)] struct FuseSessionConfig { - pub mount_point: PathBuf, + pub mount_point: MountPoint, pub options: Vec, pub max_threads: usize, } +/// OS mount point where S3 file system should be mounted. +/// This is typically a [Directory], but may be a different variant for more advanced use cases. +#[derive(Debug)] +enum MountPoint { + /// Directory to mount the new S3 filesystem at. + Directory(PathBuf), + /// Use a FUSE file descriptor that has already been opened and mounted. + #[cfg(target_os = "linux")] + FileDescriptor(std::os::fd::OwnedFd), +} + +impl MountPoint { + fn new(mount_point: impl AsRef) -> anyhow::Result { + match parse_fd_from_mount_point(&mount_point) { + Some(fd) => MountPoint::from_fd(fd), + None => MountPoint::from_directory(mount_point), + } + } + + #[cfg(not(target_os = "linux"))] + fn from_fd(_: RawFd) -> anyhow::Result { + Err(anyhow!("Passing a FUSE file descriptor only supported on Linux")) + } + + #[cfg(target_os = "linux")] + fn from_fd(fd: RawFd) -> anyhow::Result { + const FUSE_DEV: &str = "/dev/fuse"; + + use procfs::{ + process::{FDPermissions, FDTarget, Process}, + ProcError, + }; + use std::os::fd::{FromRawFd, OwnedFd}; + + let mount_point = format!("/dev/fd/{}", fd); + + let process = Process::myself().unwrap(); + let fd_info = match process.fd_from_fd(fd) { + Ok(fd_info) => fd_info, + Err(ProcError::NotFound(_)) => { + return Err(anyhow!("mount point {} is not a valid file descriptor", &mount_point)) + } + Err(err) => { + return Err(anyhow!( + "failed to get file descriptor information for mount point {}: {}", + &mount_point, + err + )) + } + }; + let FDTarget::Path(path) = &fd_info.target else { + return Err(anyhow!( + "expected mount point {} to be a {} device file descriptor but got {:?}", + &mount_point, + FUSE_DEV, + fd_info.target + )); + }; + if path != &PathBuf::from(FUSE_DEV) { + return Err(anyhow!( + "expected mount point {} to be a {} file descriptor but got {}", + &mount_point, + FUSE_DEV, + path.display() + )); + } + + if !fd_info.mode().contains(FDPermissions::READ | FDPermissions::WRITE) { + return Err(anyhow!( + "expected mount point {} file descriptor to have read and write permissions but got {:?}", + &mount_point, + fd_info.mode() + )); + } + + // SAFETY: `fd` is validated to be a valid FUSE file descriptor, and it is documented + // for users of this feature to give ownership of this file descriptor to Mountpoint. + let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) }; + + Ok(MountPoint::FileDescriptor(owned_fd)) + } + + fn from_directory(mount_point: impl AsRef) -> anyhow::Result { + let path = mount_point.as_ref(); + + if !path.exists() { + return Err(anyhow!("mount point {} does not exist", path.display())); + } + + if !path.is_dir() { + return Err(anyhow!("mount point {} is not a directory", path.display())); + } + + #[cfg(target_os = "linux")] + { + use procfs::process::Process; + + // This is a best-effort validation, so don't fail if we can't read /proc/self/mountinfo for + // some reason. + match Process::myself().and_then(|me| me.mountinfo()) { + Ok(mounts) => { + if mounts.0.iter().any(|mount| mount.mount_point == path) { + return Err(anyhow!("mount point {} is already mounted", path.display())); + } + } + Err(e) => { + tracing::debug!("failed to read mountinfo, not checking for existing mounts: {e:?}"); + } + }; + } + + Ok(MountPoint::Directory(mount_point.as_ref().to_owned())) + } +} + +impl std::fmt::Display for MountPoint { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + MountPoint::Directory(path) => write!(f, "{}", path.display()), + #[cfg(target_os = "linux")] + MountPoint::FileDescriptor(fd) => write!(f, "/dev/fd/{}", fd.as_raw_fd()), + } + } +} + /// Create a client for a bucket in the given region and send a ListObjectsV2 request to validate /// that it's accessible. If no region is provided, attempt to infer it by first sending a /// ListObjectsV2 to the default region. @@ -1190,39 +1349,6 @@ fn infer_s3_personality( } } -fn validate_mount_point(path: impl AsRef) -> anyhow::Result<()> { - let mount_point = path.as_ref(); - - if !mount_point.exists() { - return Err(anyhow!("mount point {} does not exist", mount_point.display())); - } - - if !mount_point.is_dir() { - return Err(anyhow!("mount point {} is not a directory", mount_point.display())); - } - - #[cfg(target_os = "linux")] - { - use procfs::process::Process; - - // This is a best-effort validation, so don't fail if we can't read /proc/self/mountinfo for - // some reason. - let mounts = match Process::myself().and_then(|me| me.mountinfo()) { - Ok(mounts) => mounts, - Err(e) => { - tracing::debug!("failed to read mountinfo, not checking for existing mounts: {e:?}"); - return Ok(()); - } - }; - - if mounts.0.iter().any(|mount| mount.mount_point == mount_point) { - return Err(anyhow!("mount point {} is already mounted", mount_point.display())); - } - } - - Ok(()) -} - /// Disallow specifying `--sse-kms-key-id` when `--sse=AES256` as this is not allowed by the S3 API. /// We are not able to perform this check via clap API (the closest it has is `conflicts_with` method), /// thus having a custom validation. @@ -1234,6 +1360,30 @@ fn validate_sse_args(sse_type: Option<&str>, sse_kms_key_id: Option<&str>) -> an } } +/// Parses file descriptor from given mount point. +/// The syntax for passing file descriptors as mount points is "/dev/fd/N", +/// and this function basically returns "N". +fn parse_fd_from_mount_point(path: impl AsRef) -> Option { + let re = Regex::new(r"^/dev/fd/(?\d+)$").unwrap(); + let path = path.as_ref().to_str()?; + let caps = re.captures(path)?; + let fd = &caps["fd"]; + fd.parse().ok() +} + +#[cfg(target_os = "linux")] +/// Determines "SessionACL" to use from given mount options. +/// The logic is same as what fuser's "Mount" does. +fn session_acl_from_mount_options(options: &[MountOption]) -> fuser::SessionACL { + if options.contains(&MountOption::AllowRoot) { + fuser::SessionACL::RootAndOwner + } else if options.contains(&MountOption::AllowOther) { + fuser::SessionACL::All + } else { + fuser::SessionACL::Owner + } +} + #[cfg(test)] mod tests { use super::*; @@ -1275,4 +1425,24 @@ mod tests { parsed.expect_err("invalid kms key identifier"); } } + + #[test_case("/dev/fd/3", Some(3); "valid file descriptor")] + #[test_case("/dev/fd/378", Some(378); "long valid file descriptor")] + #[test_case("/dev/fd/-1", None; "invalid file descriptor")] + #[test_case("/mnt/fd/3", None; "a folder with number")] + #[test_case("/mnt/fd/378", None; "a folder with a longer number")] + #[test_case("/mnt/mp", None; "a folder")] + #[test_case("", None; "empty")] + fn test_parsing_fuse_fd_from_mount_point(mount_point: &str, expected: Option) { + assert_eq!(expected, parse_fd_from_mount_point(mount_point)); + } + + #[cfg(target_os = "linux")] + #[test_case(&[], fuser::SessionACL::Owner; "empty options")] + #[test_case(&[MountOption::AllowOther], fuser::SessionACL::All; "only allows other")] + #[test_case(&[MountOption::AllowRoot], fuser::SessionACL::RootAndOwner; "only allows root")] + #[test_case(&[MountOption::AllowOther, MountOption::AllowRoot], fuser::SessionACL::RootAndOwner; "allows root and other")] + fn test_creating_session_acl_from_mount_options(mount_options: &[MountOption], expected: fuser::SessionACL) { + assert_eq!(expected, session_acl_from_mount_options(mount_options)); + } } diff --git a/mountpoint-s3/tests/common/fuse.rs b/mountpoint-s3/tests/common/fuse.rs index 9cb67631d..fa779968d 100644 --- a/mountpoint-s3/tests/common/fuse.rs +++ b/mountpoint-s3/tests/common/fuse.rs @@ -1,9 +1,10 @@ use std::ffi::OsStr; -use std::fs::ReadDir; +use std::fs::{File, ReadDir}; +use std::os::fd::{AsFd, AsRawFd}; use std::path::Path; use std::sync::Arc; -use fuser::{BackgroundSession, MountOption, Session}; +use fuser::{BackgroundSession, Mount, MountOption, Session}; use futures::task::Spawn; use mountpoint_s3::data_cache::DataCache; use mountpoint_s3::fuse::S3FuseFilesystem; @@ -14,6 +15,7 @@ use mountpoint_s3_client::checksums::crc32c; use mountpoint_s3_client::config::S3ClientAuthConfig; use mountpoint_s3_client::types::{Checksum, PutObjectSingleParams, UploadChecksum}; use mountpoint_s3_client::ObjectClient; +use nix::fcntl::{self, FdFlag}; use tempfile::TempDir; use crate::common::{get_crt_client_auth_config, tokio_block_on}; @@ -62,6 +64,9 @@ pub struct TestSessionConfig { pub filesystem_config: S3FilesystemConfig, pub prefetcher_config: PrefetcherConfig, pub auth_config: S3ClientAuthConfig, + // If true, the test session will be created by opening and passing + // FUSE device using `Session::from_fd`, otherwise `Session::new` will be used. + pub pass_fuse_fd: bool, } impl Default for TestSessionConfig { @@ -73,6 +78,7 @@ impl Default for TestSessionConfig { filesystem_config: Default::default(), prefetcher_config: Default::default(), auth_config: Default::default(), + pass_fuse_fd: false, } } } @@ -82,6 +88,11 @@ impl TestSessionConfig { self.auth_config = get_crt_client_auth_config(credentials); self } + + pub fn with_pass_fuse_fd(mut self, pass_fuse_fd: bool) -> Self { + self.pass_fuse_fd = pass_fuse_fd; + self + } } // Holds resources for the testing session and cleans them on drop. @@ -90,14 +101,22 @@ pub struct TestSession { test_client: Box, // Option so we can explicitly unmount session: Option, + // Only set if `pass_fuse_fd` is true, will unmount the filesystem on drop. + mount: Option, } impl TestSession { - pub fn new(mount_dir: TempDir, session: BackgroundSession, test_client: impl TestClient + 'static) -> Self { + pub fn new( + mount_dir: TempDir, + session: BackgroundSession, + test_client: impl TestClient + 'static, + mount: Option, + ) -> Self { Self { mount_dir, test_client: Box::new(test_client), session: Some(session), + mount, } } @@ -112,7 +131,10 @@ impl TestSession { impl Drop for TestSession { fn drop(&mut self) { - // Unmount first by dropping the background session + // If the session created with a pre-existing mount (e.g., with `pass_fuse_fd`), + // this will unmount it explicitly... + drop(self.mount.take()); + // ...if not, the background session will have a mount here, and dropping it will unmount it. self.session.take(); } } @@ -124,6 +146,7 @@ pub trait TestSessionCreator: FnOnce(&str, TestSessionConfig) -> TestSession {} // `FnOnce(...)` in place of `impl TestSessionCreator`. impl TestSessionCreator for T where T: FnOnce(&str, TestSessionConfig) -> TestSession {} +#[allow(clippy::too_many_arguments)] pub fn create_fuse_session( client: Client, prefetcher: Prefetcher, @@ -132,7 +155,8 @@ pub fn create_fuse_session( prefix: &str, mount_dir: &Path, filesystem_config: S3FilesystemConfig, -) -> BackgroundSession + pass_fuse_fd: bool, +) -> (BackgroundSession, Option) where Client: ObjectClient + Clone + Send + Sync + 'static, Prefetcher: Prefetch + Send + Sync + 'static, @@ -144,23 +168,46 @@ where MountOption::NoAtime, MountOption::AllowOther, ]; + // `MountOption::AllowOther` corresponds to `SessionACL::All`; + let session_acl = fuser::SessionACL::All; let prefix = Prefix::new(prefix).expect("valid prefix"); - let session = Session::new( - S3FuseFilesystem::new(S3Filesystem::new( - client, - prefetcher, - runtime, - bucket, - &prefix, - filesystem_config, - )), - mount_dir, - &options, - ) - .unwrap(); - - BackgroundSession::new(session).unwrap() + let fs = S3FuseFilesystem::new(S3Filesystem::new( + client, + prefetcher, + runtime, + bucket, + &prefix, + filesystem_config, + )); + let (session, mount) = if pass_fuse_fd { + let (fd, mount) = mount_for_passing_fuse_fd(mount_dir, &options); + let owned_fd = fd.as_fd().try_clone_to_owned().unwrap(); + (Session::from_fd(fs, owned_fd, session_acl), Some(mount)) + } else { + (Session::new(fs, mount_dir, &options).unwrap(), None) + }; + + (BackgroundSession::new(session).unwrap(), mount) +} + +/// Open `/dev/fuse` and call `mount` syscall with given `mount_point`. +/// +/// The mount is automatically unmounted when the returned [Mount] is dropped. +pub fn mount_for_passing_fuse_fd(mount_point: &Path, options: &[MountOption]) -> (Arc, Mount) { + let (file, mount) = Mount::new(mount_point, options).unwrap(); + + // fuser sets `FD_CLOEXEC` (i.e., close-on-exec) flag on the file descriptor in its libfuse3 implementation. + // Since we're forking the process in some of our test cases, this prevents child process to inherit the FUSE fd and causes our tests to fail. + // Here we're clearing this flag if its set on the FUSE fd. + let fd = file.as_raw_fd(); + let mut flags = FdFlag::from_bits_retain(fcntl::fcntl(fd, fcntl::F_GETFD).unwrap()); + if flags.contains(FdFlag::FD_CLOEXEC) { + flags.remove(FdFlag::FD_CLOEXEC); + let _ = fcntl::fcntl(fd, fcntl::F_SETFD(flags)).unwrap(); + } + + (file, mount) } pub mod mock_session { @@ -193,7 +240,7 @@ pub mod mock_session { let client = Arc::new(MockClient::new(client_config)); let runtime = ThreadPool::builder().pool_size(1).create().unwrap(); let prefetcher = default_prefetch(runtime.clone(), test_config.prefetcher_config); - let session = create_fuse_session( + let (session, mount) = create_fuse_session( client.clone(), prefetcher, runtime, @@ -201,10 +248,11 @@ pub mod mock_session { &prefix, mount_dir.path(), test_config.filesystem_config, + test_config.pass_fuse_fd, ); let test_client = create_test_client(client, &prefix); - TestSession::new(mount_dir, session, test_client) + TestSession::new(mount_dir, session, test_client, mount) } /// Create a FUSE mount backed by a mock object client, with caching, that does not talk to S3 @@ -231,7 +279,7 @@ pub mod mock_session { let client = Arc::new(MockClient::new(client_config)); let runtime = ThreadPool::builder().pool_size(1).create().unwrap(); let prefetcher = caching_prefetch(cache, runtime.clone(), test_config.prefetcher_config); - let session = create_fuse_session( + let (session, mount) = create_fuse_session( client.clone(), prefetcher, runtime, @@ -239,10 +287,11 @@ pub mod mock_session { &prefix, mount_dir.path(), test_config.filesystem_config, + test_config.pass_fuse_fd, ); let test_client = create_test_client(client, &prefix); - TestSession::new(mount_dir, session, test_client) + TestSession::new(mount_dir, session, test_client, mount) } } @@ -371,7 +420,7 @@ pub mod s3_session { let client = S3CrtClient::new(client_config).unwrap(); let runtime = client.event_loop_group(); let prefetcher = default_prefetch(runtime.clone(), test_config.prefetcher_config); - let session = create_fuse_session( + let (session, mount) = create_fuse_session( client, prefetcher, runtime, @@ -379,10 +428,11 @@ pub mod s3_session { &prefix, mount_dir.path(), test_config.filesystem_config, + test_config.pass_fuse_fd, ); let test_client = create_test_client(®ion, &bucket, &prefix); - TestSession::new(mount_dir, session, test_client) + TestSession::new(mount_dir, session, test_client, mount) } /// Create a FUSE mount backed by a real S3 client, with caching @@ -403,7 +453,7 @@ pub mod s3_session { ); let runtime = client.event_loop_group(); let prefetcher = caching_prefetch(cache, runtime.clone(), test_config.prefetcher_config); - let session = create_fuse_session( + let (session, mount) = create_fuse_session( client, prefetcher, runtime, @@ -411,10 +461,11 @@ pub mod s3_session { &prefix, mount_dir.path(), test_config.filesystem_config, + test_config.pass_fuse_fd, ); let test_client = create_test_client(®ion, &bucket, &prefix); - TestSession::new(mount_dir, session, test_client) + TestSession::new(mount_dir, session, test_client, mount) } } diff --git a/mountpoint-s3/tests/fuse_tests/cache_test.rs b/mountpoint-s3/tests/fuse_tests/cache_test.rs index e24ccbbdd..6289069d5 100644 --- a/mountpoint-s3/tests/fuse_tests/cache_test.rs +++ b/mountpoint-s3/tests/fuse_tests/cache_test.rs @@ -316,7 +316,7 @@ where let mount_point = tempfile::tempdir().unwrap(); let runtime = client.event_loop_group(); let prefetcher = caching_prefetch(cache, runtime.clone(), Default::default()); - let session = create_fuse_session( + let (session, _mount) = create_fuse_session( client, prefetcher, runtime, @@ -324,6 +324,7 @@ where prefix, mount_point.path(), Default::default(), + false, ); (mount_point, session) } diff --git a/mountpoint-s3/tests/fuse_tests/fork_test.rs b/mountpoint-s3/tests/fuse_tests/fork_test.rs index 556ef31ad..d484e73f8 100644 --- a/mountpoint-s3/tests/fuse_tests/fork_test.rs +++ b/mountpoint-s3/tests/fuse_tests/fork_test.rs @@ -4,10 +4,13 @@ use assert_cmd::prelude::*; #[cfg(not(feature = "s3express_tests"))] use aws_sdk_s3::primitives::ByteStream; +use fuser::MountOption; +use predicates::prelude::*; use std::fs::{self, File}; #[cfg(not(feature = "s3express_tests"))] use std::io::Read; use std::io::{self, BufRead, BufReader, Cursor, Write}; +use std::os::fd::AsRawFd; use std::path::Path; use std::process::{Child, ExitStatus, Stdio}; use std::time::{Duration, Instant}; @@ -16,7 +19,7 @@ use tempfile::NamedTempFile; use test_case::test_case; use crate::common::creds::{get_sdk_default_chain_creds, get_subsession_iam_role}; -use crate::common::fuse::read_dir_to_entry_names; +use crate::common::fuse::{mount_for_passing_fuse_fd, read_dir_to_entry_names}; use crate::common::s3::{ create_objects, get_test_bucket_and_prefix, get_test_bucket_forbidden, get_test_endpoint_url, get_test_region, get_test_sdk_client, @@ -25,6 +28,9 @@ use crate::common::tokio_block_on; #[cfg(not(feature = "s3express_tests"))] use crate::common::{creds::get_scoped_down_credentials, s3::get_non_test_region, s3::get_test_kms_key_id}; +const MOUNT_OPTION_READ_ONLY: &str = "--read-only"; +const MOUNT_OPTION_AUTO_UNMOUNT: &str = "--auto-unmount"; + const MAX_WAIT_DURATION: std::time::Duration = std::time::Duration::from_secs(10); #[test] @@ -57,6 +63,39 @@ fn run_in_background() -> Result<(), Box> { Ok(()) } +#[test] +fn run_in_background_with_passed_fuse_fd() -> Result<(), Box> { + let (bucket, prefix) = get_test_bucket_and_prefix("run_in_background_with_passed_fuse_fd"); + let region = get_test_region(); + let mount_point = assert_fs::TempDir::new()?; + + let (fd, _mount) = mount_for_passing_fuse_fd( + mount_point.path(), + &[MountOption::FSName("mountpoint-s3-fd".to_string())], + ); + + let mut cmd = Command::cargo_bin("mount-s3")?; + let child = cmd + .arg(&bucket) + .arg(format!("/dev/fd/{}", fd.as_raw_fd())) + .arg(format!("--prefix={prefix}")) + .arg(format!("--region={region}")) + .spawn() + .expect("unable to spawn child"); + + let exit_status = wait_for_exit(child); + + // verify mount status and mount entry + assert!(exit_status.success()); + assert!(mount_exists("mountpoint-s3-fd", mount_point.path().to_str().unwrap())); + + test_read_files(&bucket, &prefix, ®ion, &mount_point.to_path_buf()); + + unmount(mount_point.path()); + + Ok(()) +} + #[test] fn run_in_background_region_from_env() -> Result<(), Box> { let (bucket, prefix) = get_test_bucket_and_prefix("test_run_in_background_region_from_env"); @@ -151,6 +190,74 @@ fn run_in_foreground() -> Result<(), Box> { Ok(()) } +#[test] +fn run_in_foreground_with_passed_fuse_fd() -> Result<(), Box> { + let (bucket, prefix) = get_test_bucket_and_prefix("run_in_foreground_with_passed_fuse_fd"); + let region = get_test_region(); + let mount_point = assert_fs::TempDir::new()?; + + let (fd, _mount) = mount_for_passing_fuse_fd( + mount_point.path(), + &[MountOption::FSName("mountpoint-s3-fd".to_string())], + ); + + let mut cmd = Command::cargo_bin("mount-s3")?; + let mut child = cmd + .arg(&bucket) + .arg(format!("/dev/fd/{}", fd.as_raw_fd())) + .arg(format!("--prefix={prefix}")) + .arg("--foreground") + .arg(format!("--region={region}")) + .spawn() + .expect("unable to spawn child"); + + wait_for_mount("mountpoint-s3-fd", mount_point.path().to_str().unwrap()); + + let child_exit_status = child.try_wait().unwrap(); + assert_eq!( + None, child_exit_status, + "child exit status should be None as it should still be running" + ); + + assert!(mount_exists("mountpoint-s3-fd", mount_point.path().to_str().unwrap())); + + test_read_files(&bucket, &prefix, ®ion, &mount_point.to_path_buf()); + + unmount(mount_point.path()); + + Ok(()) +} + +#[test] +fn run_in_background_with_passed_fuse_fd_fail_on_mount() -> Result<(), Box> { + // the mount would fail from error 403 on HeadBucket + let bucket = get_test_bucket_forbidden(); + let mount_point = assert_fs::TempDir::new()?; + + let (fd, mount) = mount_for_passing_fuse_fd( + mount_point.path(), + &[MountOption::FSName("mountpoint-s3-fd".to_string())], + ); + + let mut cmd = Command::cargo_bin("mount-s3")?; + let child = cmd + .arg(&bucket) + .arg(format!("/dev/fd/{}", fd.as_raw_fd())) + .spawn() + .expect("unable to spawn child"); + + let exit_status = wait_for_exit(child); + + // Drop `Mount` to trigger unmount. + drop(mount); + + // verify mount status and mount entry + assert!(!exit_status.success()); + assert!(!mount_exists("mountpoint-s3-fd", mount_point.path().to_str().unwrap())); + + Ok(()) +} + #[test] fn run_in_background_fail_on_mount() -> Result<(), Box> { // the mount would fail from error 403 on HeadBucket @@ -241,6 +348,107 @@ fn run_fail_on_duplicate_mount() -> Result<(), Box> { Ok(()) } +#[test] +fn run_fail_on_non_existent_fd() -> Result<(), Box> { + let (bucket, prefix) = get_test_bucket_and_prefix("run_fail_on_non_existent_fd"); + let region = get_test_region(); + + let mount_point = "/dev/fd/1025"; + + let mut cmd = Command::cargo_bin("mount-s3")?; + let child = cmd + .arg(&bucket) + .arg(mount_point) + .arg(format!("--prefix={prefix}")) + .arg(format!("--region={region}")) + .spawn() + .expect("unable to spawn child"); + + let exit_status = wait_for_exit(child); + + // verify mount status + assert!(!exit_status.success()); + + // verify error message + let error_message = format!("mount point {} is not a valid file descriptor", mount_point); + cmd.assert().failure().stderr(predicate::str::contains(error_message)); + + Ok(()) +} + +#[test] +fn run_fail_on_non_fuse_fd() -> Result<(), Box> { + let (bucket, prefix) = get_test_bucket_and_prefix("run_fail_on_non_fuse_fd"); + let region = get_test_region(); + + // 1 is fd for stdout + let mount_point = "/dev/fd/1"; + + let mut cmd = Command::cargo_bin("mount-s3")?; + let child = cmd + .arg(&bucket) + .arg(mount_point) + .arg(format!("--prefix={prefix}")) + .arg(format!("--region={region}")) + .spawn() + .expect("unable to spawn child"); + + let exit_status = wait_for_exit(child); + + // verify mount status + assert!(!exit_status.success()); + + // verify error message + let error_message = format!( + "expected mount point {} to be a /dev/fuse device file descriptor but got Pipe", + mount_point + ); + cmd.assert().failure().stderr(predicate::str::contains(error_message)); + + Ok(()) +} + +#[test_case(&[MOUNT_OPTION_READ_ONLY])] +#[test_case(&[MOUNT_OPTION_AUTO_UNMOUNT])] +#[test_case(&[MOUNT_OPTION_READ_ONLY, MOUNT_OPTION_AUTO_UNMOUNT])] +fn run_fail_on_non_fuse_fd_if_mount_options_passed(mount_options: &[&str]) -> Result<(), Box> { + let (bucket, prefix) = get_test_bucket_and_prefix("run_fail_on_non_fuse_fd_if_mount_options_passed"); + let region = get_test_region(); + let mount_point = assert_fs::TempDir::new()?; + + let (fd, _mount) = mount_for_passing_fuse_fd( + mount_point.path(), + &[MountOption::FSName("mountpoint-s3-fd".to_string())], + ); + + let mut cmd = Command::cargo_bin("mount-s3")?; + cmd.arg(&bucket) + .arg(format!("/dev/fd/{}", fd.as_raw_fd())) + .arg(format!("--prefix={prefix}")) + .arg(format!("--region={region}")); + + for opt in mount_options { + cmd.arg(opt); + } + + let child = cmd.spawn().expect("unable to spawn child"); + + let exit_status = wait_for_exit(child); + + // verify mount status + assert!(!exit_status.success()); + + // verify error message + let error_message = format!( + "Mount options: {} are ignored with FUSE fd mount point.\ + Mount options should be passed while performing `mount` syscall in the caller process.", + mount_options.join(", "), + ); + cmd.assert().failure().stderr(predicate::str::contains(error_message)); + + Ok(()) +} + #[test] fn mount_readonly() -> Result<(), Box> { let (bucket, prefix) = get_test_bucket_and_prefix("test_mount_readonly"); diff --git a/mountpoint-s3/tests/fuse_tests/read_test.rs b/mountpoint-s3/tests/fuse_tests/read_test.rs index 6279701fe..f77de9ee0 100644 --- a/mountpoint-s3/tests/fuse_tests/read_test.rs +++ b/mountpoint-s3/tests/fuse_tests/read_test.rs @@ -11,10 +11,31 @@ use mountpoint_s3::S3FilesystemConfig; use rand::RngCore; use rand::SeedableRng as _; use rand_chacha::ChaChaRng; -use test_case::test_case; +use test_case::test_matrix; use crate::common::fuse::{self, read_dir_to_entry_names, TestSessionConfig, TestSessionCreator}; +const READ_ONLY: bool = true; +const READ_WRITE: bool = false; + +const FUSE_PASS_FD: bool = true; +const FUSE_SELF_MOUNT: bool = false; + +/// Test wrapper to support generation of test names when used with [test_case]. +enum BucketPrefix { + None, + Some(&'static str), +} + +impl std::fmt::Display for BucketPrefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BucketPrefix::Some(prefix) => write!(f, "{}", prefix), + BucketPrefix::None => write!(f, ""), + } + } +} + fn open_for_read(path: impl AsRef, read_only: bool) -> std::io::Result { let mut options = File::options(); if !read_only { @@ -23,10 +44,10 @@ fn open_for_read(path: impl AsRef, read_only: bool) -> std::io::Result