From 889d2996bb745daffb767c6e69b77cdb29020e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:00:08 +0000 Subject: [PATCH 1/3] Allow FUSE_ALLOW_IDMAP to be negotiated Negotiating it is what lets a mount be idmapped: with the capability refused, mount_setattr(MOUNT_ATTR_IDMAP) over a fuser filesystem fails with EINVAL, and with it negotiated the same call succeeds. It is accepted only on a mount that carries MountOption::DefaultPermissions and SessionACL::All, and refused otherwise. Neither condition is fuser's choice. Without default_permissions the kernel refuses the connection outright - every request answered ECONNREFUSED - because with the caller's ids withheld its own checks are the only ones left. And once those ids are withheld fuser cannot tell the mounting user's requests from anyone else's, so SessionACL::Owner and RootAndOwner could no longer be enforced; refusing the capability is better than continuing to offer a restriction that has quietly stopped applying. What the kernel withholds, it withholds only from requests that do not create an inode. Those arrive with uid and gid set to the new FUSE_INVALID_UIDGID. The requests that do create one still carry ids, and they are the owner the new inode should get, already mapped - which is the only thing a filesystem needs them for once the kernel is doing the access checks. Request::uid() and Request::gid() say so rather than changing shape, since a filesystem that does not ask for the capability is never given an invalid id. The tests pin all three of these against a real kernel rather than against the encoding: which mount configurations may negotiate it, that a getattr arrives without ids while a mkdir arrives with them, and that the capability is what decides whether an idmapped mount can be created at all. This is the library capability alone. The simple example does not request it, and wiring it there is not a small change: the example does its own uid-based access checks, which is exactly what an idmapped mount stops it from being able to do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4hiZwHE9fEYdn7DK3ZrV2 --- CHANGELOG.md | 9 ++ src/lib.rs | 191 +++++++++++++++++++++++++++++++++++++++++-- src/request_param.rs | 14 +++- src/session.rs | 160 +++++++++++++++++++++++++++++++++++- 4 files changed, 362 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3824e2c8..6ecf1726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # FUSE for Rust - Changelog ## Unreleased +* `KernelConfig::add_capabilities()` now accepts `InitFlags::FUSE_ALLOW_IDMAP` (ABI 7.41), + which lets the mount be idmapped. It is accepted only where it can be honored - the session + must allow other users, and `default_permissions` must be in force, whether from + `MountOption::DefaultPermissions` or from negotiating `InitFlags::FUSE_POSIX_ACL` - and + refused otherwise: the kernel refuses the connection outright without `default_permissions`, + and without allow_other fuser would be offering an owner-only ACL it can no longer enforce. Once negotiated the kernel withholds the + caller's ids from every request that does not create an inode, so `Request::uid()` and + `Request::gid()` report the new `FUSE_INVALID_UIDGID` there; the requests that do create an + inode still carry ids, and they are the owner the new inode should get, already mapped * Add `Filesystem::statx()`, which the kernel calls for `statx(2)` (ABI 7.38), along with `StatxAttr` and `ReplyStatx`. This exists to report a creation time, which no other request can carry: `fuse_attr` has a field for it on macOS alone, so on Linux `statx(2)` otherwise diff --git a/src/lib.rs b/src/lib.rs index 60b0023a..70d66611 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,6 +143,12 @@ fn default_init_flags(capabilities: InitFlags) -> InitFlags { /// /// Every bit named here is above 31, where macFUSE defines no capability of its own. The refused /// bits that do alias one live in `ALIASED_UNSUPPORTED_CAPABILITIES`, which is empty on macOS. +/// The value [`Request::uid`] and [`Request::gid`] carry when the kernel withheld the caller's +/// ids, which it does on an idmapped mount for every request that does not create an inode. +/// +/// Only reachable once [`InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated. +pub const FUSE_INVALID_UIDGID: u32 = u32::MAX; + const UNSUPPORTED_CAPABILITIES: InitFlags = ALIASED_UNSUPPORTED_CAPABILITIES // The kernel appends a fuse_secctx_header extension after the name of a create, mkdir, // symlink or mknod request. fuser stops parsing at the name, so the security context is @@ -151,10 +157,6 @@ const UNSUPPORTED_CAPABILITIES: InitFlags = ALIASED_UNSUPPORTED_CAPABILITIES // Likewise for the fuse_supp_groups extension. Dropping it leaves the filesystem with the // caller's fsgid in exactly the case the extension exists to correct .union(InitFlags::FUSE_CREATE_SUPP_GROUP) - // Without MountOption::DefaultPermissions the kernel refuses the connection. With it, uid - // and gid arrive as FUSE_INVALID_UIDGID on every request that does not create an inode, - // breaking Request::uid()/gid() and the SessionACL check built on them - .union(InitFlags::FUSE_ALLOW_IDMAP) // Selecting DAX per inode requires FUSE_ATTR_DAX in the flags field of fuse_attr, which // fuser sends as padding. Only a DAX-capable transport (virtiofs) advertises this .union(InitFlags::FUSE_HAS_INODE_DAX) @@ -383,10 +385,21 @@ pub struct KernelConfig { time_gran: Duration, max_stack_depth: u32, kernel_abi: Version, + /// Whether the mount carries `MountOption::DefaultPermissions`, which + /// `InitFlags::FUSE_ALLOW_IDMAP` cannot be negotiated without + default_permissions: bool, + /// The session's ACL, for the same reason + acl: SessionACL, } impl KernelConfig { - fn new(capabilities: InitFlags, max_readahead: u32, kernel_abi: Version) -> Self { + fn new( + capabilities: InitFlags, + max_readahead: u32, + kernel_abi: Version, + default_permissions: bool, + acl: SessionACL, + ) -> Self { Self { capabilities, requested: default_init_flags(capabilities), @@ -400,9 +413,34 @@ impl KernelConfig { time_gran: Duration::new(0, 1), max_stack_depth: 0, kernel_abi, + default_permissions, + acl, } } + /// Whether `InitFlags::FUSE_ALLOW_IDMAP` can be negotiated on this mount, given the + /// capabilities being added alongside it. + /// + /// Two things have to hold, and neither is fuser's choice. The kernel needs something to + /// check access against, since it withholds the caller's ids from the filesystem: it + /// refuses the connection outright - every request answered `ECONNREFUSED` - unless + /// `default_permissions` is in force. The mount option is one way to put it in force and + /// `InitFlags::FUSE_POSIX_ACL` is the other, the kernel setting it from that flag before + /// it looks at this one. + /// + /// And once the ids are withheld fuser cannot tell the mounting user's requests from + /// anyone else's, so `SessionACL::Owner` and `SessionACL::RootAndOwner` could not be + /// enforced; refusing is better than continuing to offer a restriction that has quietly + /// stopped applying. + fn idmap_available(&self, also_adding: InitFlags) -> bool { + // Requesting POSIX ACLs only counts if the kernel offers them, since a capability it + // does not offer is dropped from the negotiated set and sets nothing + let acl_forces_default_permissions = (self.requested | also_adding) + .intersection(self.capabilities) + .contains(InitFlags::FUSE_POSIX_ACL); + (self.default_permissions || acl_forces_default_permissions) && self.acl == SessionACL::All + } + /// Set the maximum stacking depth of the filesystem /// /// This has to be at least 1 to support passthrough to backing files. Setting this to 0 (the @@ -506,12 +544,24 @@ impl KernelConfig { /// Add a set of capabilities. /// + /// [`InitFlags::FUSE_ALLOW_IDMAP`] is accepted only where it can be honored: the session + /// must allow other users, and `default_permissions` must be in force, whether from + /// [`MountOption::DefaultPermissions`] or from negotiating [`InitFlags::FUSE_POSIX_ACL`]. + /// Relying on the latter means adding it in this call or an earlier one, since what has + /// not been asked for yet cannot be counted on. + /// /// # Errors /// When the argument includes capabilities the kernel does not support, or ones fuser cannot /// honor, returns the bits of the capabilities that were refused. Nothing is added in that /// case, not even the capabilities that would have been accepted on their own. pub fn add_capabilities(&mut self, capabilities_to_add: InitFlags) -> Result<(), InitFlags> { - let refused = capabilities_to_add & (!self.capabilities | UNSUPPORTED_CAPABILITIES); + let conditional = if self.idmap_available(capabilities_to_add) { + InitFlags::empty() + } else { + InitFlags::FUSE_ALLOW_IDMAP + }; + let refused = + capabilities_to_add & (!self.capabilities | UNSUPPORTED_CAPABILITIES | conditional); if !refused.is_empty() { return Err(refused); } @@ -1448,7 +1498,13 @@ mod tests { #[test] fn kernel_config_set_max_write_bounds() { - let mut config = KernelConfig::new(InitFlags::empty(), 65536, Version(7, 31)); + let mut config = KernelConfig::new( + InitFlags::empty(), + 65536, + Version(7, 31), + true, + SessionACL::All, + ); // Values the kernel would not honor (it clamps max_write below 4096 up // to 4096) are rejected with the nearest value that will take effect assert_eq!(config.set_max_write(0), Err(MIN_WRITE_SIZE as u32)); @@ -1474,10 +1530,126 @@ mod tests { ); } + /// FUSE_ALLOW_IDMAP is refused unless the mount can carry it, since the kernel refuses + /// the connection without default_permissions and fuser cannot enforce an owner-only ACL + /// once the caller's ids are withheld + #[test] + fn add_capabilities_idmap_needs_default_permissions_and_allow_other() { + let cases = [ + (true, SessionACL::All, true), + (false, SessionACL::All, false), + (true, SessionACL::Owner, false), + (true, SessionACL::RootAndOwner, false), + (false, SessionACL::Owner, false), + ]; + for (default_permissions, acl, accepted) in cases { + let mut config = KernelConfig::new( + InitFlags::all(), + 65536, + Version(7, 43), + default_permissions, + acl, + ); + let result = config.add_capabilities(InitFlags::FUSE_ALLOW_IDMAP); + assert_eq!( + result.is_ok(), + accepted, + "default_permissions={default_permissions} acl={acl:?} should {} the capability", + if accepted { "accept" } else { "refuse" } + ); + if !accepted { + assert_eq!(result, Err(InitFlags::FUSE_ALLOW_IDMAP)); + assert!(!config.requested.contains(InitFlags::FUSE_ALLOW_IDMAP)); + } else { + assert!(config.requested.contains(InitFlags::FUSE_ALLOW_IDMAP)); + } + } + } + + /// Negotiating POSIX ACLs puts default_permissions in force just as the mount option does, + /// the kernel setting it from that flag before it validates this one, so it satisfies the + /// requirement on its own - but only where the kernel offers it, since a capability it + /// does not offer is dropped from the negotiated set and sets nothing + #[test] + fn add_capabilities_idmap_accepts_posix_acl_for_default_permissions() { + // Both in one call + let mut config = KernelConfig::new( + InitFlags::all(), + 65536, + Version(7, 43), + false, + SessionACL::All, + ); + assert_eq!( + config.add_capabilities(InitFlags::FUSE_POSIX_ACL | InitFlags::FUSE_ALLOW_IDMAP), + Ok(()) + ); + + // ACLs asked for first, in an earlier call + let mut config = KernelConfig::new( + InitFlags::all(), + 65536, + Version(7, 43), + false, + SessionACL::All, + ); + assert_eq!(config.add_capabilities(InitFlags::FUSE_POSIX_ACL), Ok(())); + assert_eq!(config.add_capabilities(InitFlags::FUSE_ALLOW_IDMAP), Ok(())); + + // A kernel without POSIX ACLs would drop them, leaving nothing to force + // default_permissions and a connection the kernel would refuse + let mut config = KernelConfig::new( + InitFlags::all() & !InitFlags::FUSE_POSIX_ACL, + 65536, + Version(7, 43), + false, + SessionACL::All, + ); + assert_eq!( + config.add_capabilities(InitFlags::FUSE_POSIX_ACL | InitFlags::FUSE_ALLOW_IDMAP), + Err(InitFlags::FUSE_POSIX_ACL | InitFlags::FUSE_ALLOW_IDMAP) + ); + + // Neither one on its own is enough without allow_other + let mut config = KernelConfig::new( + InitFlags::all(), + 65536, + Version(7, 43), + false, + SessionACL::Owner, + ); + assert_eq!( + config.add_capabilities(InitFlags::FUSE_POSIX_ACL | InitFlags::FUSE_ALLOW_IDMAP), + Err(InitFlags::FUSE_ALLOW_IDMAP) + ); + } + + /// A kernel that does not offer it cannot have it forced on, whatever the mount looks like + #[test] + fn add_capabilities_idmap_needs_the_kernel_to_offer_it() { + let mut config = KernelConfig::new( + InitFlags::all() & !InitFlags::FUSE_ALLOW_IDMAP, + 65536, + Version(7, 43), + true, + SessionACL::All, + ); + assert_eq!( + config.add_capabilities(InitFlags::FUSE_ALLOW_IDMAP), + Err(InitFlags::FUSE_ALLOW_IDMAP) + ); + } + #[test] fn add_capabilities_refuses_unimplemented() { // A kernel advertising everything, so only fuser's own limits can refuse anything - let mut config = KernelConfig::new(InitFlags::all(), 65536, Version(7, 43)); + let mut config = KernelConfig::new( + InitFlags::all(), + 65536, + Version(7, 43), + true, + SessionACL::All, + ); let before = config.requested; for capability in UNSUPPORTED_CAPABILITIES { assert_eq!(config.add_capabilities(capability), Err(capability)); @@ -1508,7 +1680,8 @@ mod tests { fn add_capabilities_refuses_all_or_nothing() { // Advertise everything except one capability fuser does implement let capabilities = InitFlags::all() - InitFlags::FUSE_POSIX_ACL; - let mut config = KernelConfig::new(capabilities, 65536, Version(7, 43)); + let mut config = + KernelConfig::new(capabilities, 65536, Version(7, 43), true, SessionACL::All); let before = config.requested; // Both reasons for refusing are reported together, and a capability that would have // been accepted on its own is not added diff --git a/src/request_param.rs b/src/request_param.rs index e69bb855..6178f3f4 100644 --- a/src/request_param.rs +++ b/src/request_param.rs @@ -21,13 +21,23 @@ impl Request { ll::RequestId(self.header.unique) } - /// Returns the uid of this request + /// Returns the uid of this request, or [`crate::FUSE_INVALID_UIDGID`] when the kernel + /// withheld it. + /// + /// It withholds it on every request that does not create an inode, and only once + /// [`crate::InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated - which a filesystem has to + /// ask for, so a filesystem that does not is never given one. On an idmapped mount the + /// caller's ids mean nothing to the filesystem anyway, which is why that capability + /// requires `default_permissions` and leaves the access checks to the kernel. The + /// requests that do create an inode still carry ids, and they are the owner the new inode + /// should get, already mapped. #[inline] pub fn uid(&self) -> u32 { self.header.uid } - /// Returns the gid of this request + /// Returns the gid of this request, or [`crate::FUSE_INVALID_UIDGID`] when the kernel + /// withheld it. See [`Request::uid`] for when that is. #[inline] pub fn gid(&self) -> u32 { self.header.gid diff --git a/src/session.rs b/src/session.rs index 4c98cf81..0f81e92b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -441,7 +441,15 @@ impl Session { )); } - let mut config = KernelConfig::new(init.capabilities(), init.max_readahead(), v); + let mut config = KernelConfig::new( + init.capabilities(), + init.max_readahead(), + v, + self.config + .mount_options + .contains(&MountOption::DefaultPermissions), + self.allowed, + ); // Call filesystem init method and give it a chance to return an error let Some(filesystem) = &mut self.filesystem.fs else { @@ -825,6 +833,156 @@ mod test { ManuallyDrop::into_inner(tmp); } + /// Negotiating FUSE_ALLOW_IDMAP changes what the kernel puts in every request header, and + /// what it does to the connection if the mount is not set up for it. Both are the + /// kernel's rules rather than fuser's, so they are worth pinning against a real one: + /// requests that do not create an inode arrive with the caller's ids withheld, and the + /// ones that do still carry them, being the owner the new inode should get. + #[test] + #[cfg(target_os = "linux")] + fn idmap_withholds_ids_except_when_creating() { + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::time::Duration; + use std::time::SystemTime; + + use crate::FUSE_INVALID_UIDGID; + use crate::FileAttr; + use crate::FileType; + use crate::INodeNo; + use crate::MountOption; + + struct IdmapFs { + negotiated: Arc, + getattr_uid: Arc>>, + mkdir_uid: Arc>>, + } + + impl Filesystem for IdmapFs { + fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> io::Result<()> { + let accepted = config + .add_capabilities(crate::InitFlags::FUSE_ALLOW_IDMAP) + .is_ok(); + self.negotiated.store(accepted, Ordering::SeqCst); + Ok(()) + } + /// Answered so that creating a name gets as far as mkdir: the kernel looks it up + /// first, and a default ENOSYS there would end the operation before it + fn lookup( + &self, + _req: &Request, + _parent: INodeNo, + _name: &std::ffi::OsStr, + reply: crate::ReplyEntry, + ) { + reply.error(crate::Errno::ENOENT); + } + fn getattr( + &self, + req: &Request, + ino: INodeNo, + _fh: Option, + reply: crate::ReplyAttr, + ) { + *self.getattr_uid.lock() = Some(req.uid()); + reply.attr( + &Duration::from_secs(0), + &FileAttr { + ino, + size: 0, + blocks: 0, + atime: SystemTime::UNIX_EPOCH, + mtime: SystemTime::UNIX_EPOCH, + ctime: SystemTime::UNIX_EPOCH, + crtime: SystemTime::UNIX_EPOCH, + kind: FileType::Directory, + perm: 0o777, + nlink: 2, + uid: 0, + gid: 0, + rdev: 0, + blksize: 512, + flags: 0, + }, + ); + } + fn mkdir( + &self, + req: &Request, + _parent: INodeNo, + _name: &std::ffi::OsStr, + _mode: u32, + _umask: u32, + reply: crate::ReplyEntry, + ) { + *self.mkdir_uid.lock() = Some(req.uid()); + // Nothing is actually created; the ids are the whole point here + reply.error(crate::Errno::ENOSPC); + } + } + + let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap()); + let mountpoint = tmp.path().canonicalize().unwrap(); + let negotiated = Arc::new(AtomicBool::new(false)); + let getattr_uid = Arc::new(Mutex::new(None)); + let mkdir_uid = Arc::new(Mutex::new(None)); + + // Both are what the capability requires: without default_permissions the kernel + // refuses the connection, and without allow_other fuser refuses the capability + let mut config = Config::default(); + config.mount_options.push(MountOption::DefaultPermissions); + config.acl = crate::SessionACL::All; + + let session = match Session::new( + IdmapFs { + negotiated: negotiated.clone(), + getattr_uid: getattr_uid.clone(), + mkdir_uid: mkdir_uid.clone(), + }, + &mountpoint, + &config, + ) { + Ok(session) => session, + Err(error) => { + // allow_other needs either root or user_allow_other in /etc/fuse.conf + eprintln!("skipping idmap: cannot mount with allow_other: {error}"); + ManuallyDrop::into_inner(tmp); + return; + } + }; + // FUSE_ALLOW_IDMAP arrived in ABI 7.41; an older kernel never offers it + let supported = session.proto_version.is_some_and(|v| v >= Version(7, 41)); + let bg = session.spawn().unwrap(); + + let _ = std::fs::metadata(&mountpoint); + let _ = std::fs::create_dir(mountpoint.join("newdir")); + + let getattr_uid = getattr_uid.lock().take(); + let mkdir_uid = mkdir_uid.lock().take(); + let negotiated = negotiated.load(Ordering::SeqCst); + drop(bg); + ManuallyDrop::into_inner(tmp); + + if !supported { + eprintln!("skipping idmap: the kernel's FUSE protocol predates 7.41"); + return; + } + assert!( + negotiated, + "a mount with default_permissions and allow_other must be allowed the capability" + ); + assert_eq!( + getattr_uid, + Some(FUSE_INVALID_UIDGID), + "a request that creates nothing must arrive with the caller's ids withheld" + ); + assert_eq!( + mkdir_uid, + Some(nix::unistd::geteuid().as_raw()), + "a request that creates an inode must still carry the owner it should get" + ); + } + /// statx(2) must reach Filesystem::statx(), and the creation time it carries - which no /// other request can express on Linux - must arrive intact. Also pins the one field the /// wire format carries but the kernel drops, so that a kernel change shows up here. From 3da6162c8a157b8888b2045cbac10d6e6f43bb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:20:26 +0000 Subject: [PATCH 2/3] Say what the code does rather than what changed about it Several comments described the change that introduced them rather than the behavior a reader arrives at, which stops being useful as soon as the change is history. They now state the rule and its reason: why tmpfile checks the parent for search permission when the operand is the directory itself, why nothing touches the parent's timestamps, and which kernel check copy_file_range mirrors. Comparisons to create() go, since in each case the reason alone carries it. Corrects one that was wrong. StatxAttributes said answering FUSE_STATX is what makes chattr +i visible to statx(2). It is not: fuse_do_statx() takes the mask, the creation time and the basic stats out of the reply and ignores the attributes, as StatxAttr::attributes already said. The two were written either side of finding that out and only one was corrected. It now points at the field where the behavior is documented rather than restating it, as does the example. Also drops a kernel version from StatxAttr::attributes. The behavior is the kernel's current one either way, and stamping a version invites a reader to think it changed in that release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4hiZwHE9fEYdn7DK3ZrV2 --- examples/simple.rs | 19 +++++++++---------- src/lib.rs | 26 ++++++++++++-------------- src/ll/flags/statx_flags.rs | 8 +++----- src/request_param.rs | 8 +++----- 4 files changed, 27 insertions(+), 34 deletions(-) diff --git a/examples/simple.rs b/examples/simple.rs index 5a0d2411..a482d680 100644 --- a/examples/simple.rs +++ b/examples/simple.rs @@ -2546,10 +2546,9 @@ impl Filesystem for SimpleFS { }; // The inode flags this filesystem honors, in the encoding statx uses. The kernel - // discards these today - fuse_do_statx() takes the creation time and the basic stats - // out of the reply and nothing else - so `chattr +i` stays invisible to statx(2) - // whatever is reported here. Filled in anyway, since it costs nothing and is what the - // field is for if the kernel starts reading it + // discards them, so `chattr +i` stays invisible to statx(2) whatever is reported here + // - see StatxAttr::attributes. Filled in because that is what the field is for, and + // so nothing has to change should the kernel start reading it let mut attributes = fuser::StatxAttributes::empty(); attributes.set(fuser::StatxAttributes::IMMUTABLE, attrs.is_immutable()); attributes.set(fuser::StatxAttributes::APPEND, attrs.is_append_only()); @@ -2689,9 +2688,9 @@ impl Filesystem for SimpleFS { // The directory has to be writable even though nothing is written to it, and // searchable even though nothing is looked up in it: `vfs_tmpfile` asks its own - // filesystems for `MAY_WRITE | MAY_EXEC` here. Unlike create(), neither is implied by - // anything the kernel has already checked - the directory is the operand rather than - // a path component, so no lookup has passed through it + // filesystems for `MAY_WRITE | MAY_EXEC`. The directory is this request's operand + // rather than a component of a path, so no lookup has passed through it and nothing + // the kernel has already checked implies either if let Err(error_code) = parent_attrs.check_writable() { reply.error(error_code); return; @@ -2709,7 +2708,7 @@ impl Filesystem for SimpleFS { return; } - // Unlike create(), the parent's timestamps are left alone: no entry is added to it + // The parent's timestamps are left alone, since no entry is added to it if req.uid() != 0 { mode &= !(libc::S_ISUID | libc::S_ISGID) as u32; @@ -2856,8 +2855,8 @@ impl Filesystem for SimpleFS { reply.error(Errno::EACCES); return; } - // Unlike a plain write, this is checked against the destination's flags every time - // rather than only at open, matching what Linux does for the copy_file_range syscall + // Checked on every call rather than at open, which is where the flags are otherwise + // enforced: `generic_copy_file_checks` rejects an immutable destination each time match self.get_inode(dest_inode) { Ok(attrs) => { if let Err(error_code) = attrs.check_writable() { diff --git a/src/lib.rs b/src/lib.rs index 70d66611..ab413306 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,6 +134,12 @@ fn default_init_flags(capabilities: InitFlags) -> InitFlags { flags } +/// The value [`Request::uid`] and [`Request::gid`] carry when the kernel withheld the caller's +/// ids, which it does on an idmapped mount for every request that does not create an inode. +/// +/// Only reachable once [`InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated. +pub const FUSE_INVALID_UIDGID: u32 = u32::MAX; + /// Capabilities fuser has no implementation behind, whatever the kernel advertises. /// /// Negotiating one of these makes the kernel change the protocol in a way fuser gets wrong, or @@ -143,12 +149,6 @@ fn default_init_flags(capabilities: InitFlags) -> InitFlags { /// /// Every bit named here is above 31, where macFUSE defines no capability of its own. The refused /// bits that do alias one live in `ALIASED_UNSUPPORTED_CAPABILITIES`, which is empty on macOS. -/// The value [`Request::uid`] and [`Request::gid`] carry when the kernel withheld the caller's -/// ids, which it does on an idmapped mount for every request that does not create an inode. -/// -/// Only reachable once [`InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated. -pub const FUSE_INVALID_UIDGID: u32 = u32::MAX; - const UNSUPPORTED_CAPABILITIES: InitFlags = ALIASED_UNSUPPORTED_CAPABILITIES // The kernel appends a fuse_secctx_header extension after the name of a create, mkdir, // symlink or mknod request. fuser stops parsing at the name, so the security context is @@ -276,12 +276,11 @@ pub struct StatxAttr { pub btime: Option, /// Properties of the file that are set, such as immutable or append-only. /// - /// **The kernel currently discards this.** `fuse_do_statx()` takes the mask, the creation - /// time and the basic stats out of the reply and ignores the attributes, so nothing set - /// here reaches `statx(2)` - checked against mainline as of 6.18. The field is part of - /// the wire format and is sent, so a filesystem that fills it is correct today and needs - /// no change if the kernel starts reading it, but do not expect `chattr +i` to become - /// visible to `statx(2)` this way. + /// **The kernel discards this.** `fuse_do_statx()` takes the mask, the creation time and + /// the basic stats out of the reply and ignores the attributes, so nothing set here + /// reaches `statx(2)`, and `chattr +i` cannot be made visible to it this way. The field + /// is part of the wire format and is sent regardless, so a filesystem that fills it needs + /// no change should the kernel start reading it. pub attributes: StatxAttributes, /// Properties this filesystem knows about at all, set or not, distinguishing "not set" /// from "cannot say". @@ -673,8 +672,7 @@ pub trait Filesystem: Send + Sync + 'static { /// the reply itself. /// /// Answering `ENOSYS`, which is the default, is permanent: the kernel stops sending - /// `FUSE_STATX` on this connection and serves `statx(2)` out of [`Filesystem::getattr`], - /// which is what it did before this existed. + /// `FUSE_STATX` on this connection and serves `statx(2)` out of [`Filesystem::getattr`]. fn statx( &self, _req: &Request, diff --git a/src/ll/flags/statx_flags.rs b/src/ll/flags/statx_flags.rs index 69629faf..4e2a3e99 100644 --- a/src/ll/flags/statx_flags.rs +++ b/src/ll/flags/statx_flags.rs @@ -40,12 +40,10 @@ bitflags! { bitflags! { /// Properties of a file that `statx(2)` reports beyond `stat(2)`, as `stx_attributes`. /// - /// The kernel has no way to learn these from a FUSE filesystem other than being told: it - /// fills `stx_attributes` from its own inode flags, which a FUSE filesystem's flags are - /// not. Answering `FUSE_STATX` is what makes `chattr +i` and friends visible to - /// `statx(2)`. + /// The kernel discards these from a FUSE reply - see [`crate::StatxAttr::attributes`], + /// which is where they are set and where that is documented. /// - /// Whatever is reported here counts only where the matching bit is also set in + /// Whatever is reported counts only where the matching bit is also set in /// [`crate::StatxAttr::attributes_mask`], which is how a caller tells "not set" from /// "not supported". #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] diff --git a/src/request_param.rs b/src/request_param.rs index 6178f3f4..b2e1d1d8 100644 --- a/src/request_param.rs +++ b/src/request_param.rs @@ -26,11 +26,9 @@ impl Request { /// /// It withholds it on every request that does not create an inode, and only once /// [`crate::InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated - which a filesystem has to - /// ask for, so a filesystem that does not is never given one. On an idmapped mount the - /// caller's ids mean nothing to the filesystem anyway, which is why that capability - /// requires `default_permissions` and leaves the access checks to the kernel. The - /// requests that do create an inode still carry ids, and they are the owner the new inode - /// should get, already mapped. + /// ask for, so one that does not is never handed an invalid id. The requests that do + /// create an inode still carry ids, and they are the owner the new inode should get, + /// already mapped. #[inline] pub fn uid(&self) -> u32 { self.header.uid From 1ae8a814787a2da69daf766e074b92897d3ccc6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:59:31 +0000 Subject: [PATCH 3/3] Give absent caller ids a type, and name the owner of a new inode Request::uid() and gid() return Option. The kernel withholds the caller's ids on an idmapped mount, from every request that does not create an inode, so a u32 was claiming something that is not always there. Reporting u32::MAX in their place asked every caller to know a sentinel, and to remember to test for it in code whose whole purpose is deciding who may do what. The requests that do create an inode are given an Owner instead. They are the only ones the kernel sends ids for, and what it sends is not the caller's ids but those ids mapped through the mount's idmapping - so on an idmapped mount, reading the owner off the request would have been wrong even where a value is present. Naming it separately keeps a single field from meaning two things. Which requests those are was measured rather than assumed: create, mkdir, mknod, symlink and tmpfile carry ids, and link does not, creating an entry rather than an inode. The simple example does not request FUSE_ALLOW_IDMAP, so it is always given ids. That invariant is stated once, in caller_uid and caller_gid, rather than at each of the ninety places it is relied on. Its five creating handlers take the owner from the argument. Its rename does not, since RENAME_WHITEOUT creates an inode but rename is not a request the kernel sends ids for; the caller's ids are the right answer there for any filesystem that has not asked for idmapped mounts. FUSE_INVALID_UIDGID goes back to being an implementation detail, which is all it is once the type says what it was standing in for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q4hiZwHE9fEYdn7DK3ZrV2 --- CHANGELOG.md | 9 ++ examples/simple.rs | 210 +++++++++++++++++++++++++------------------ src/experimental.rs | 16 ++-- src/lib.rs | 28 ++++-- src/ll/fuse_abi.rs | 2 +- src/request.rs | 58 +++++++++++- src/request_param.rs | 31 ++++--- src/session.rs | 75 ++++++++++------ 8 files changed, 288 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecf1726..276d5c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # FUSE for Rust - Changelog ## Unreleased +* **Breaking:** `Request::uid()` and `Request::gid()` return `Option`. An idmapped mount + has no caller ids to report, so there is not always a uid. A filesystem that does not request + `InitFlags::FUSE_ALLOW_IDMAP` is always given ids and can unwrap +* **Breaking:** `mknod()`, `mkdir()`, `symlink()`, `create()` and `tmpfile()` take an `Owner` + argument, which is who the inode they create belongs to, and `rename()` takes an + `Option` for the inode a `RENAME_WHITEOUT` leaves behind. Those are exactly the requests the + kernel sends ids for, and on an idmapped mount the owner is the caller's ids mapped through + the mount's idmapping rather than the ids themselves, so reading it off the request would + have been wrong * `KernelConfig::add_capabilities()` now accepts `InitFlags::FUSE_ALLOW_IDMAP` (ABI 7.41), which lets the mount be idmapped. It is accepted only where it can be honored - the session must allow other users, and `default_permissions` must be in force, whether from diff --git a/examples/simple.rs b/examples/simple.rs index a482d680..deac0c49 100644 --- a/examples/simple.rs +++ b/examples/simple.rs @@ -43,6 +43,7 @@ use fuser::LockOwner; use fuser::MountOption; use fuser::OpenAccMode; use fuser::OpenFlags; +use fuser::Owner; use fuser::RenameFlags; use fuser::ReplyAttr; use fuser::ReplyCreate; @@ -304,7 +305,7 @@ fn clear_suid_sgid(attr: &mut InodeAttributes, req: &Request, privilege: Privile let sgid_exec = attr.mode & (libc::S_ISGID | libc::S_IXGRP) as u16 == (libc::S_ISGID | libc::S_IXGRP) as u16; let outside_group = privilege.lacks_fsetid(req) - && req.gid() != attr.gid + && caller_gid(req) != attr.gid && !get_groups(req.pid()).contains(&attr.gid); if sgid_exec || outside_group { attr.mode &= !libc::S_ISGID as u16; @@ -327,7 +328,7 @@ impl Privilege { fn lacks_fsetid(&self, req: &Request) -> bool { match self { Privilege::KnownLacking => true, - Privilege::Unknown => !has_fsetid(req.pid(), req.uid()), + Privilege::Unknown => !has_fsetid(req.pid(), caller_uid(req)), } } } @@ -352,7 +353,7 @@ fn clear_file_capabilities(attr: &mut InodeAttributes) { /// Call this only when a killpriv capability was negotiated: without one the kernel still /// removes privileges itself, and clearing here as well would strip bits it had decided to keep fn clear_privileges_unprompted(attr: &mut InodeAttributes, req: &Request) { - if !has_fsetid(req.pid(), req.uid()) { + if !has_fsetid(req.pid(), caller_uid(req)) { // The capability set has just answered the question, so do not read it a second time clear_suid_sgid(attr, req, Privilege::KnownLacking); } @@ -381,12 +382,12 @@ fn xattr_access_check( match parse_xattr_namespace(key)? { XattrNamespace::Security => { - if access_mask != libc::R_OK && request.uid() != 0 { + if access_mask != libc::R_OK && caller_uid(request) != 0 { return Err(Errno::EPERM); } } XattrNamespace::Trusted => { - if request.uid() != 0 { + if caller_uid(request) != 0 { return Err(Errno::EPERM); } } @@ -396,13 +397,13 @@ fn xattr_access_check( inode_attrs.uid, inode_attrs.gid, inode_attrs.mode, - request.uid(), - request.gid(), + caller_uid(request), + caller_gid(request), AccessFlags::from_bits_retain(access_mask), ) { return Err(Errno::EPERM); } - } else if request.uid() != 0 { + } else if caller_uid(request) != 0 { return Err(Errno::EPERM); } } @@ -411,8 +412,8 @@ fn xattr_access_check( inode_attrs.uid, inode_attrs.gid, inode_attrs.mode, - request.uid(), - request.gid(), + caller_uid(request), + caller_gid(request), AccessFlags::from_bits_retain(access_mask), ) { return Err(Errno::EPERM); @@ -423,6 +424,19 @@ fn xattr_access_check( Ok(()) } +/// The caller's uid, which this filesystem is always given. The kernel withholds ids only on +/// an idmapped mount, which needs `FUSE_ALLOW_IDMAP`, and this example does not request it +fn caller_uid(req: &Request) -> u32 { + req.uid() + .expect("ids are withheld only on an idmapped mount") +} + +/// The caller's gid, always present for the same reason as [`caller_uid`] +fn caller_gid(req: &Request) -> u32 { + req.gid() + .expect("ids are withheld only on an idmapped mount") +} + fn time_now() -> (i64, u32) { time_from_system_time(&SystemTime::now()) } @@ -844,8 +858,8 @@ impl SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - req.uid(), - req.gid(), + caller_uid(req), + caller_gid(req), AccessFlags::W_OK, ) { return Err(Errno::EACCES); @@ -927,8 +941,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::X_OK, ) { reply.error(Errno::EACCES); @@ -1001,7 +1015,7 @@ impl Filesystem for SimpleFS { #[cfg(target_os = "freebsd")] { // FreeBSD: sticky bit only valid on directories; otherwise EFTYPE - if _req.uid() != 0 + if caller_uid(_req) != 0 && (mode as u16 & libc::S_ISVTX as u16) != 0 && attrs.kind != FileKind::Directory { @@ -1009,12 +1023,12 @@ impl Filesystem for SimpleFS { return; } } - if _req.uid() != 0 && _req.uid() != attrs.uid { + if caller_uid(_req) != 0 && caller_uid(_req) != attrs.uid { reply.error(Errno::EPERM); return; } - if _req.uid() != 0 - && _req.gid() != attrs.gid + if caller_uid(_req) != 0 + && caller_gid(_req) != attrs.gid && !get_groups(_req.pid()).contains(&attrs.gid) { // If SGID is set and the file belongs to a group that the caller is not part of @@ -1033,22 +1047,22 @@ impl Filesystem for SimpleFS { debug!("chown() called with {ino:?} {uid:?} {gid:?}"); if let Some(gid) = gid { // Non-root users can only change gid to a group they're in - if _req.uid() != 0 && !get_groups(_req.pid()).contains(&gid) { + if caller_uid(_req) != 0 && !get_groups(_req.pid()).contains(&gid) { reply.error(Errno::EPERM); return; } } if let Some(uid) = uid { - if _req.uid() != 0 + if caller_uid(_req) != 0 // but no-op changes by the owner are not an error - && !(uid == attrs.uid && _req.uid() == attrs.uid) + && !(uid == attrs.uid && caller_uid(_req) == attrs.uid) { reply.error(Errno::EPERM); return; } } // Only owner may change the group - if gid.is_some() && _req.uid() != 0 && _req.uid() != attrs.uid { + if gid.is_some() && caller_uid(_req) != 0 && caller_uid(_req) != attrs.uid { reply.error(Errno::EPERM); return; } @@ -1088,9 +1102,14 @@ impl Filesystem for SimpleFS { reply.error(Errno::EACCES); return; } - } else if let Err(error_code) = - self.truncate(_req, ino, size, _req.uid(), _req.gid(), kill_suid_gid) - { + } else if let Err(error_code) = self.truncate( + _req, + ino, + size, + caller_uid(_req), + caller_gid(_req), + kill_suid_gid, + ) { reply.error(error_code); return; } @@ -1100,18 +1119,18 @@ impl Filesystem for SimpleFS { if let Some(atime) = _atime { debug!("utimens() called with {ino:?}, atime={atime:?}"); - if attrs.uid != _req.uid() && _req.uid() != 0 && atime != Now { + if attrs.uid != caller_uid(_req) && caller_uid(_req) != 0 && atime != Now { reply.error(Errno::EPERM); return; } - if attrs.uid != _req.uid() + if attrs.uid != caller_uid(_req) && !check_access( attrs.uid, attrs.gid, attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { @@ -1129,18 +1148,18 @@ impl Filesystem for SimpleFS { if let Some(mtime) = _mtime { debug!("utimens() called with {ino:?}, mtime={mtime:?}"); - if attrs.uid != _req.uid() && _req.uid() != 0 && mtime != Now { + if attrs.uid != caller_uid(_req) && caller_uid(_req) != 0 && mtime != Now { reply.error(Errno::EPERM); return; } - if attrs.uid != _req.uid() + if attrs.uid != caller_uid(_req) && !check_access( attrs.uid, attrs.gid, attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { @@ -1188,6 +1207,7 @@ impl Filesystem for SimpleFS { mut mode: u32, _umask: u32, rdev: u32, + owner: Owner, reply: ReplyEntry, ) { let file_type = mode & libc::S_IFMT as u32; @@ -1227,8 +1247,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1239,7 +1259,7 @@ impl Filesystem for SimpleFS { parent_attrs.last_metadata_changed = now; self.write_inode(&parent_attrs); - if _req.uid() != 0 { + if caller_uid(_req) != 0 { mode &= !(libc::S_ISUID | libc::S_ISGID) as u32; } @@ -1247,7 +1267,7 @@ impl Filesystem for SimpleFS { { let kind = as_file_kind(mode); // FreeBSD: sticky bit only valid on directories; otherwise EFTYPE - if _req.uid() != 0 + if caller_uid(_req) != 0 && (mode as u16 & libc::S_ISVTX as u16) != 0 && kind != FileKind::Directory { @@ -1268,8 +1288,8 @@ impl Filesystem for SimpleFS { kind: as_file_kind(mode), mode: self.creation_mode(mode), hardlinks: 1, - uid: _req.uid(), - gid: creation_gid(&parent_attrs, _req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: match as_file_kind(mode) { FileKind::CharDevice | FileKind::BlockDevice => rdev, // Every other type reports 0, as a local filesystem does @@ -1310,6 +1330,7 @@ impl Filesystem for SimpleFS { name: &OsStr, mut mode: u32, _umask: u32, + owner: Owner, reply: ReplyEntry, ) { debug!("mkdir() called with {parent:?} {name:?} {mode:o}"); @@ -1335,8 +1356,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1347,7 +1368,7 @@ impl Filesystem for SimpleFS { parent_attrs.last_metadata_changed = now; self.write_inode(&parent_attrs); - if _req.uid() != 0 { + if caller_uid(_req) != 0 { mode &= !(libc::S_ISUID | libc::S_ISGID) as u32; } if parent_attrs.mode & libc::S_ISGID as u16 != 0 { @@ -1366,8 +1387,8 @@ impl Filesystem for SimpleFS { kind: FileKind::Directory, mode: self.creation_mode(mode), hardlinks: 2, // Directories start with link count of 2, since they have a self link - uid: _req.uid(), - gid: creation_gid(&parent_attrs, _req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: 0, flags: 0, xattrs: BTreeMap::default(), @@ -1420,15 +1441,15 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); return; } - let uid = _req.uid(); + let uid = caller_uid(_req); // "Sticky bit" handling if parent_attrs.mode & libc::S_ISVTX as u16 != 0 && uid != 0 @@ -1499,8 +1520,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1509,9 +1530,9 @@ impl Filesystem for SimpleFS { // "Sticky bit" handling if parent_attrs.mode & libc::S_ISVTX as u16 != 0 - && _req.uid() != 0 - && _req.uid() != parent_attrs.uid - && _req.uid() != attrs.uid + && caller_uid(_req) != 0 + && caller_uid(_req) != parent_attrs.uid + && caller_uid(_req) != attrs.uid { reply.error(Errno::EACCES); return; @@ -1540,6 +1561,7 @@ impl Filesystem for SimpleFS { parent: INodeNo, link_name: &OsStr, target: &Path, + owner: Owner, reply: ReplyEntry, ) { debug!("symlink() called with {parent:?} {link_name:?} {target:?}"); @@ -1560,8 +1582,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1584,8 +1606,8 @@ impl Filesystem for SimpleFS { kind: FileKind::Symlink, mode: 0o777, hardlinks: 1, - uid: _req.uid(), - gid: creation_gid(&parent_attrs, _req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: 0, flags: 0, xattrs: BTreeMap::default(), @@ -1622,6 +1644,7 @@ impl Filesystem for SimpleFS { newparent: INodeNo, newname: &OsStr, flags: RenameFlags, + owner: Option, reply: ReplyEmpty, ) { debug!( @@ -1648,8 +1671,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1658,9 +1681,9 @@ impl Filesystem for SimpleFS { // "Sticky bit" handling if parent_attrs.mode & libc::S_ISVTX as u16 != 0 - && _req.uid() != 0 - && _req.uid() != parent_attrs.uid - && _req.uid() != inode_attrs.uid + && caller_uid(_req) != 0 + && caller_uid(_req) != parent_attrs.uid + && caller_uid(_req) != inode_attrs.uid { reply.error(Errno::EACCES); return; @@ -1678,8 +1701,8 @@ impl Filesystem for SimpleFS { new_parent_attrs.uid, new_parent_attrs.gid, new_parent_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -1689,9 +1712,9 @@ impl Filesystem for SimpleFS { // "Sticky bit" handling in new_parent if new_parent_attrs.mode & libc::S_ISVTX as u16 != 0 { if let Ok(existing_attrs) = self.lookup_name(newparent, newname) { - if _req.uid() != 0 - && _req.uid() != new_parent_attrs.uid - && _req.uid() != existing_attrs.uid + if caller_uid(_req) != 0 + && caller_uid(_req) != new_parent_attrs.uid + && caller_uid(_req) != existing_attrs.uid { reply.error(Errno::EACCES); return; @@ -1832,8 +1855,8 @@ impl Filesystem for SimpleFS { inode_attrs.uid, inode_attrs.gid, inode_attrs.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::W_OK, ) { @@ -1866,6 +1889,12 @@ impl Filesystem for SimpleFS { // is a character device with device number 0 and no permission bits, which is not a // node anything can be read from or written to if whiteout { + // The kernel names the owner for exactly this case: a rename creates an inode + // only here, and this is the only rename it sends ids for + let Some(owner) = owner else { + reply.error(Errno::EIO); + return; + }; let whiteout_inode = self.allocate_next_inode(); let whiteout_attrs = InodeAttributes { inode: whiteout_inode.0, @@ -1878,8 +1907,8 @@ impl Filesystem for SimpleFS { kind: FileKind::CharDevice, mode: 0, hardlinks: 1, - uid: _req.uid(), - gid: creation_gid(&parent_attrs, _req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: 0, flags: 0, xattrs: BTreeMap::default(), @@ -1999,8 +2028,8 @@ impl Filesystem for SimpleFS { attr.uid, attr.gid, attr.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::from_bits_retain(access_mask), ) { attr.open_file_handles += 1; @@ -2176,8 +2205,8 @@ impl Filesystem for SimpleFS { attr.uid, attr.gid, attr.mode, - _req.uid(), - _req.gid(), + caller_uid(_req), + caller_gid(_req), AccessFlags::from_bits_retain(access_mask), ) { attr.open_file_handles += 1; @@ -2397,7 +2426,14 @@ impl Filesystem for SimpleFS { debug!("access() called with {ino:?} {mask:?}"); match self.get_inode(ino) { Ok(attr) => { - if check_access(attr.uid, attr.gid, attr.mode, _req.uid(), _req.gid(), mask) { + if check_access( + attr.uid, + attr.gid, + attr.mode, + caller_uid(_req), + caller_gid(_req), + mask, + ) { reply.ok(); } else { reply.error(Errno::EACCES); @@ -2416,6 +2452,7 @@ impl Filesystem for SimpleFS { _umask: u32, flags: i32, _kill_suid_gid: bool, + owner: Owner, reply: ReplyCreate, ) { debug!("create() called with {parent:?} {name:?}"); @@ -2452,8 +2489,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - req.uid(), - req.gid(), + caller_uid(req), + caller_gid(req), AccessFlags::W_OK, ) { reply.error(Errno::EACCES); @@ -2464,7 +2501,7 @@ impl Filesystem for SimpleFS { parent_attrs.last_metadata_changed = now; self.write_inode(&parent_attrs); - if req.uid() != 0 { + if caller_uid(req) != 0 { mode &= !(libc::S_ISUID | libc::S_ISGID) as u32; } @@ -2472,7 +2509,7 @@ impl Filesystem for SimpleFS { { let kind = as_file_kind(mode); // FreeBSD: sticky bit only valid on directories; otherwise EFTYPE - if req.uid() != 0 + if caller_uid(req) != 0 && (mode as u16 & libc::S_ISVTX as u16) != 0 && kind != FileKind::Directory { @@ -2493,8 +2530,8 @@ impl Filesystem for SimpleFS { kind: as_file_kind(mode), mode: self.creation_mode(mode), hardlinks: 1, - uid: req.uid(), - gid: creation_gid(&parent_attrs, req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: 0, flags: 0, xattrs: BTreeMap::default(), @@ -2659,6 +2696,7 @@ impl Filesystem for SimpleFS { _umask: u32, flags: i32, _kill_suid_gid: bool, + owner: Owner, reply: ReplyCreate, ) { debug!("tmpfile() called with {parent:?} {mode:o}"); @@ -2700,8 +2738,8 @@ impl Filesystem for SimpleFS { parent_attrs.uid, parent_attrs.gid, parent_attrs.mode, - req.uid(), - req.gid(), + caller_uid(req), + caller_gid(req), AccessFlags::W_OK | AccessFlags::X_OK, ) { reply.error(Errno::EACCES); @@ -2710,7 +2748,7 @@ impl Filesystem for SimpleFS { // The parent's timestamps are left alone, since no entry is added to it - if req.uid() != 0 { + if caller_uid(req) != 0 { mode &= !(libc::S_ISUID | libc::S_ISGID) as u32; } @@ -2730,8 +2768,8 @@ impl Filesystem for SimpleFS { // What makes it anonymous. Closing the last handle without linking it collects it, // and link() takes it from here to 1 hardlinks: 0, - uid: req.uid(), - gid: creation_gid(&parent_attrs, req.gid()), + uid: owner.uid, + gid: creation_gid(&parent_attrs, owner.gid), rdev: 0, flags: 0, xattrs: BTreeMap::default(), diff --git a/src/experimental.rs b/src/experimental.rs index fad85739..5fc282bd 100644 --- a/src/experimental.rs +++ b/src/experimental.rs @@ -24,14 +24,14 @@ pub type Result = std::result::Result; /// Standard request context for all filesystem operations pub struct RequestContext { - uid: u32, - gid: u32, + uid: Option, + gid: Option, pid: u32, request_id: RequestId, } impl RequestContext { - fn new(uid: u32, gid: u32, pid: u32, request_id: RequestId) -> Self { + fn new(uid: Option, gid: Option, pid: u32, request_id: RequestId) -> Self { Self { uid, gid, @@ -40,13 +40,15 @@ impl RequestContext { } } - /// The user making the request - pub fn user_id(&self) -> u32 { + /// The user making the request, or `None` where the kernel withheld it. See + /// [`Request::uid`] for when that is + pub fn user_id(&self) -> Option { self.uid } - /// The group the user belongs to - pub fn group_id(&self) -> u32 { + /// The group the user belongs to, or `None` where the kernel withheld it. See + /// [`Request::uid`] for when that is + pub fn group_id(&self) -> Option { self.gid } diff --git a/src/lib.rs b/src/lib.rs index ab413306..2e6b3a31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,11 +134,8 @@ fn default_init_flags(capabilities: InitFlags) -> InitFlags { flags } -/// The value [`Request::uid`] and [`Request::gid`] carry when the kernel withheld the caller's -/// ids, which it does on an idmapped mount for every request that does not create an inode. -/// -/// Only reachable once [`InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated. -pub const FUSE_INVALID_UIDGID: u32 = u32::MAX; +/// What the kernel puts in a request header in place of ids it is not sending. +pub(crate) const FUSE_INVALID_UIDGID: u32 = u32::MAX; /// Capabilities fuser has no implementation behind, whatever the kernel advertises. /// @@ -183,6 +180,21 @@ const ALIASED_UNSUPPORTED_CAPABILITIES: InitFlags = InitFlags::empty() #[cfg(target_os = "macos")] const ALIASED_UNSUPPORTED_CAPABILITIES: InitFlags = InitFlags::empty(); +/// Who a newly created inode belongs to. +/// +/// The kernel decides this rather than the filesystem, and on an idmapped mount it is not the +/// same as the caller's ids: it is those ids mapped through the mount's idmapping. It is given +/// to the requests that create an inode, and to no others, which is why it is an argument to +/// those rather than something [`Request`] carries. A rename is one of them only with +/// `RENAME_WHITEOUT`, so it takes an `Option`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct Owner { + /// Owning user + pub uid: u32, + /// Owning group, before any setgid inheritance the filesystem applies + pub gid: u32, +} + /// File types #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[cfg_attr(feature = "serializable", derive(Serialize, Deserialize))] @@ -742,6 +754,7 @@ pub trait Filesystem: Send + Sync + 'static { mode: u32, umask: u32, rdev: u32, + _owner: Owner, reply: ReplyEntry, ) { warn!( @@ -759,6 +772,7 @@ pub trait Filesystem: Send + Sync + 'static { name: &OsStr, mode: u32, umask: u32, + _owner: Owner, reply: ReplyEntry, ) { warn!( @@ -786,6 +800,7 @@ pub trait Filesystem: Send + Sync + 'static { parent: INodeNo, link_name: &OsStr, target: &Path, + _owner: Owner, reply: ReplyEntry, ) { warn!( @@ -806,6 +821,7 @@ pub trait Filesystem: Send + Sync + 'static { newparent: INodeNo, newname: &OsStr, flags: RenameFlags, + _owner: Option, reply: ReplyEmpty, ) { warn!( @@ -1137,6 +1153,7 @@ pub trait Filesystem: Send + Sync + 'static { umask: u32, flags: i32, kill_suid_gid: bool, + _owner: Owner, reply: ReplyCreate, ) { warn!( @@ -1314,6 +1331,7 @@ pub trait Filesystem: Send + Sync + 'static { umask: u32, flags: i32, kill_suid_gid: bool, + _owner: Owner, reply: ReplyCreate, ) { warn!( diff --git a/src/ll/fuse_abi.rs b/src/ll/fuse_abi.rs index 992a80e0..2babc74e 100644 --- a/src/ll/fuse_abi.rs +++ b/src/ll/fuse_abi.rs @@ -730,7 +730,7 @@ pub(crate) struct fuse_fallocate_in { } #[repr(C)] -#[derive(Debug, FromBytes, KnownLayout, Immutable)] +#[derive(Debug, Clone, FromBytes, KnownLayout, Immutable)] pub(crate) struct fuse_in_header { pub(crate) len: u32, pub(crate) opcode: u32, diff --git a/src/request.rs b/src/request.rs index fe3990f9..755303ac 100644 --- a/src/request.rs +++ b/src/request.rs @@ -12,6 +12,7 @@ use log::debug; use log::error; use crate::Filesystem; +use crate::Owner; use crate::PollNotifier; use crate::Request; use crate::channel::ChannelSender; @@ -21,6 +22,7 @@ use crate::ll::Errno; use crate::ll::ResponseData; use crate::ll::ResponseErrno; use crate::ll::flags::init_flags::InitFlags; +use crate::ll::fuse_abi::fuse_in_header; use crate::reply::Reply; use crate::reply::ReplyDirectory; use crate::reply::ReplyDirectoryPlus; @@ -36,6 +38,15 @@ pub(crate) struct RequestWithSender<'a> { ch: ChannelSender, /// Parsed request pub(crate) request: ll::AnyRequest<'a>, + /// The header as the filesystem sees it, where that differs from the one on the wire. + /// + /// An idmapped mount has no caller ids to report. The kernel already leaves them out of + /// most requests; the ones it does send ids for are sending the owner for an inode they + /// create, which is those ids mapped through the mount and so not the caller's. Reporting + /// them as the caller's would invite a filesystem to decide policy against a mapped id - + /// letting an idmapping that lands on uid 0 pass a check for root. They reach the + /// filesystem as an `Owner` argument instead, and the caller reads as unknown here + masked_header: Option, } impl<'a> RequestWithSender<'a> { @@ -54,7 +65,20 @@ impl<'a> RequestWithSender<'a> { }; request.set_negotiated(negotiated); - Some(Self { ch, request }) + let masked_header = + negotiated + .contains(InitFlags::FUSE_ALLOW_IDMAP) + .then(|| fuse_in_header { + uid: crate::FUSE_INVALID_UIDGID, + gid: crate::FUSE_INVALID_UIDGID, + ..request.header().clone() + }); + + Some(Self { + ch, + request, + masked_header, + }) } /// Dispatch request to the given filesystem. @@ -174,6 +198,7 @@ impl<'a> RequestWithSender<'a> { x.mode(), x.umask(), x.rdev(), + self.owner(), self.reply(), ); } @@ -184,6 +209,7 @@ impl<'a> RequestWithSender<'a> { x.name().as_ref(), x.mode(), x.umask(), + self.owner(), self.reply(), ); } @@ -209,6 +235,7 @@ impl<'a> RequestWithSender<'a> { self.request.nodeid(), x.link_name().as_ref(), Path::new(x.target()), + self.owner(), self.reply(), ); } @@ -226,6 +253,7 @@ impl<'a> RequestWithSender<'a> { x.dest().dir, x.dest().name.as_ref(), flags, + self.optional_owner(), self.reply(), ); } @@ -404,6 +432,7 @@ impl<'a> RequestWithSender<'a> { x.umask(), x.flags(), x.kill_suid_gid(), + self.owner(), self.reply(), ); } @@ -519,6 +548,7 @@ impl<'a> RequestWithSender<'a> { x.to().dir, x.to().name.as_ref(), x.flags(), + self.optional_owner(), self.reply(), ); } @@ -568,6 +598,7 @@ impl<'a> RequestWithSender<'a> { x.umask(), x.flags(), x.kill_suid_gid(), + self.owner(), self.reply(), ); } @@ -602,6 +633,29 @@ impl<'a> RequestWithSender<'a> { /// Create a reply object for this request that can be passed to the filesystem /// implementation and makes sure that a request is replied exactly once + /// The owner for an inode this request creates. + /// + /// On an idmapped mount these are the caller's ids mapped through the mount's idmapping, + /// which is why they are not the caller's ids; otherwise they are the caller's unchanged. + fn owner(&self) -> Owner { + Owner { + uid: self.request.header().uid, + gid: self.request.header().gid, + } + } + + /// The owner for an inode this request may create, where the kernel sent one. + /// + /// A rename creates an inode only with `RENAME_WHITEOUT`, and that is the only rename an + /// idmapped mount sends ids for. Off such a mount every request carries them, so this is + /// `Some` whatever the flags say, and it names the whiteout's owner only when the flags + /// call for one. + fn optional_owner(&self) -> Option { + let owner = self.owner(); + (owner.uid != crate::FUSE_INVALID_UIDGID && owner.gid != crate::FUSE_INVALID_UIDGID) + .then_some(owner) + } + pub(crate) fn reply(&self) -> T { Reply::new(self.request.unique(), ReplySender::Channel(self.ch.clone())) } @@ -609,6 +663,6 @@ impl<'a> RequestWithSender<'a> { /// Returns a Request reference for this request #[inline] fn request_header(&self) -> &Request { - Request::ref_cast(self.request.header()) + Request::ref_cast(self.masked_header.as_ref().unwrap_or(self.request.header())) } } diff --git a/src/request_param.rs b/src/request_param.rs index b2e1d1d8..0ed9be8c 100644 --- a/src/request_param.rs +++ b/src/request_param.rs @@ -21,24 +21,29 @@ impl Request { ll::RequestId(self.header.unique) } - /// Returns the uid of this request, or [`crate::FUSE_INVALID_UIDGID`] when the kernel - /// withheld it. + /// Returns the uid of the process that triggered this request, or `None` when the kernel + /// did not send one. /// - /// It withholds it on every request that does not create an inode, and only once - /// [`crate::InitFlags::FUSE_ALLOW_IDMAP`] has been negotiated - which a filesystem has to - /// ask for, so one that does not is never handed an invalid id. The requests that do - /// create an inode still carry ids, and they are the owner the new inode should get, - /// already mapped. + /// An idmapped mount has none to send: which ids a caller would have depends on the mount + /// it came through. Nothing is left needing them. The capability that allows such a mount, + /// [`crate::InitFlags::FUSE_ALLOW_IDMAP`], can only be negotiated where + /// `default_permissions` is in force, so the kernel makes the access checks these ids + /// would otherwise serve, and the requests that create an inode are given an + /// [`crate::Owner`] naming who it belongs to. Those ids reach the filesystem only that + /// way: they are the owner mapped through the mount rather than the caller's, so + /// reporting them here would invite a check against the wrong identity. + /// + /// A filesystem that does not request that capability is always given ids. #[inline] - pub fn uid(&self) -> u32 { - self.header.uid + pub fn uid(&self) -> Option { + (self.header.uid != crate::FUSE_INVALID_UIDGID).then_some(self.header.uid) } - /// Returns the gid of this request, or [`crate::FUSE_INVALID_UIDGID`] when the kernel - /// withheld it. See [`Request::uid`] for when that is. + /// Returns the gid of the process that triggered this request, or `None` when the kernel + /// did not send one. See [`Request::uid`] for when that is. #[inline] - pub fn gid(&self) -> u32 { - self.header.gid + pub fn gid(&self) -> Option { + (self.header.gid != crate::FUSE_INVALID_UIDGID).then_some(self.header.gid) } /// Returns the pid of this request diff --git a/src/session.rs b/src/session.rs index 0f81e92b..3e1dc359 100644 --- a/src/session.rs +++ b/src/session.rs @@ -833,20 +833,18 @@ mod test { ManuallyDrop::into_inner(tmp); } - /// Negotiating FUSE_ALLOW_IDMAP changes what the kernel puts in every request header, and - /// what it does to the connection if the mount is not set up for it. Both are the - /// kernel's rules rather than fuser's, so they are worth pinning against a real one: - /// requests that do not create an inode arrive with the caller's ids withheld, and the - /// ones that do still carry them, being the owner the new inode should get. + /// An idmapped mount has no caller ids to report. Pinned against a real kernel, since + /// what it sends is its rule rather than fuser's: nothing reports a caller, and the + /// requests that create an inode name its owner instead - the caller's ids mapped through + /// the mount, which are not the caller's. #[test] #[cfg(target_os = "linux")] - fn idmap_withholds_ids_except_when_creating() { + fn idmap_withholds_caller_ids_and_names_the_owner() { use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; use std::time::SystemTime; - use crate::FUSE_INVALID_UIDGID; use crate::FileAttr; use crate::FileType; use crate::INodeNo; @@ -854,8 +852,11 @@ mod test { struct IdmapFs { negotiated: Arc, - getattr_uid: Arc>>, - mkdir_uid: Arc>>, + /// Whether `getattr` was given the caller's uid at all + getattr_had_uid: Arc>>, + mkdir_owner: Arc>>, + /// Whether the request that names an owner also reported a caller + mkdir_had_uid: Arc>>, } impl Filesystem for IdmapFs { @@ -884,7 +885,7 @@ mod test { _fh: Option, reply: crate::ReplyAttr, ) { - *self.getattr_uid.lock() = Some(req.uid()); + *self.getattr_had_uid.lock() = Some(req.uid().is_some()); reply.attr( &Duration::from_secs(0), &FileAttr { @@ -908,15 +909,17 @@ mod test { } fn mkdir( &self, - req: &Request, + _req: &Request, _parent: INodeNo, _name: &std::ffi::OsStr, _mode: u32, _umask: u32, + owner: crate::Owner, reply: crate::ReplyEntry, ) { - *self.mkdir_uid.lock() = Some(req.uid()); - // Nothing is actually created; the ids are the whole point here + *self.mkdir_had_uid.lock() = Some(_req.uid().is_some()); + *self.mkdir_owner.lock() = Some(owner); + // Nothing is created; the owner is the whole point here reply.error(crate::Errno::ENOSPC); } } @@ -924,8 +927,9 @@ mod test { let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap()); let mountpoint = tmp.path().canonicalize().unwrap(); let negotiated = Arc::new(AtomicBool::new(false)); - let getattr_uid = Arc::new(Mutex::new(None)); - let mkdir_uid = Arc::new(Mutex::new(None)); + let getattr_had_uid = Arc::new(Mutex::new(None)); + let mkdir_owner = Arc::new(Mutex::new(None)); + let mkdir_had_uid = Arc::new(Mutex::new(None)); // Both are what the capability requires: without default_permissions the kernel // refuses the connection, and without allow_other fuser refuses the capability @@ -936,8 +940,9 @@ mod test { let session = match Session::new( IdmapFs { negotiated: negotiated.clone(), - getattr_uid: getattr_uid.clone(), - mkdir_uid: mkdir_uid.clone(), + getattr_had_uid: getattr_had_uid.clone(), + mkdir_owner: mkdir_owner.clone(), + mkdir_had_uid: mkdir_had_uid.clone(), }, &mountpoint, &config, @@ -957,8 +962,9 @@ mod test { let _ = std::fs::metadata(&mountpoint); let _ = std::fs::create_dir(mountpoint.join("newdir")); - let getattr_uid = getattr_uid.lock().take(); - let mkdir_uid = mkdir_uid.lock().take(); + let getattr_had_uid = getattr_had_uid.lock().take(); + let mkdir_owner = mkdir_owner.lock().take(); + let mkdir_had_uid = mkdir_had_uid.lock().take(); let negotiated = negotiated.load(Ordering::SeqCst); drop(bg); ManuallyDrop::into_inner(tmp); @@ -972,14 +978,25 @@ mod test { "a mount with default_permissions and allow_other must be allowed the capability" ); assert_eq!( - getattr_uid, - Some(FUSE_INVALID_UIDGID), + getattr_had_uid, + Some(false), "a request that creates nothing must arrive with the caller's ids withheld" ); assert_eq!( - mkdir_uid, - Some(nix::unistd::geteuid().as_raw()), - "a request that creates an inode must still carry the owner it should get" + mkdir_owner, + Some(crate::Owner { + uid: nix::unistd::geteuid().as_raw(), + gid: nix::unistd::getegid().as_raw(), + }), + "a request that creates an inode must name the owner it should get" + ); + // The header carries ids on this request, but they are the owner mapped through the + // mount rather than the caller's. Reporting them as the caller would let an idmapping + // that lands on uid 0 pass for root + assert_eq!( + mkdir_had_uid, + Some(false), + "the mapped owner must not be reported as the caller's id" ); } @@ -1262,6 +1279,7 @@ mod test { _umask: u32, flags: i32, _kill_suid_gid: bool, + _owner: crate::Owner, reply: ReplyCreate, ) { *self.seen.lock() = Some((mode, flags)); @@ -1349,8 +1367,11 @@ mod test { /// from there would carry uid 0 and pid 0. #[test] fn requests_carry_the_calling_task() { + /// The caller's uid, absent on an idmapped mount, and the calling thread's id + type Caller = (Option, u32); + struct CallerFs { - seen: Arc>>, + seen: Arc>>, } impl Filesystem for CallerFs { fn getattr( @@ -1386,8 +1407,8 @@ mod test { let (uid, pid) = seen.expect("the filesystem must have been asked for the root inode"); assert_eq!( uid, - geteuid().as_raw(), - "the request must carry the caller's uid" + Some(geteuid().as_raw()), + "the request must carry the caller's uid, this mount not being idmapped" ); // Discriminating even when the test runs as root, where the uid above is 0 either // way. The kernel takes this from task_pid(current), so it is the id of the thread