From f34747dd27e411d1166c47ab66720239305d3c47 Mon Sep 17 00:00:00 2001 From: Ryan El Kochta Date: Mon, 13 Jul 2026 15:36:35 -0700 Subject: [PATCH] tmpfs: implement POSIX ACLs Implements POSIX ACLs for gVisor's tmpfs. As a summary: - Adds `acl` and `defaultACL` fields to tmpfs's inode - Allows `system.posix_acl_access` and `system.posix_acl_default` xattrs to set and retrieve POSIX ACLs - Updates tmpfs's stat implementation to account for the ACL in setting/retrieving a file's mode - Updates `GenericCheckPermission` to take an optional ACL. All non-tmpfs filesystems pass the `nil` ACL. - Updates tmpfs's tar save-and-restore logic to store the base64-encoded POSIX ACL xattrs in the tar header - Adds unit tests for POSIX ACL parsing/serialization - Adds integration tests for POSIX ACLs - Adds a container test to verify the tmpfs tar save/restore logic with ACLs. This required a new subcommand for `test_app` to set and fetch xattrs in the container The motivation for POSIX ACLs is for journald (which uses POSIX ACLs to give non-root users access to their own `systemctl --user` services' logs). This commit is not actually sufficient to get this working -- we will need to plumb ACLs to overlayfs, too, which will be done in a follow-up commit. The implementation is very similar to the implementation in Linux, although it is filesystem-specific rather than at the VFS layer (much like we store `mode`, `uid`, `gid`, etc at the FS layer currently). An optional access and default ACL is stored in each inode. If present, it is used for permission checks and is kept in sync with the `mode`. The `mode` is computed from the ACL when permissions are updated. If the ACL is simple enough to be stored equivalently as a `mode`, an ACL is not stored. One limitation at the moment is that upon file creation, the process's umask is masked against the creation mode regardless of the presence of a default ACL, whereas Linux only masks against umask when *not* inheriting from a default ACL. Fixing this would require a fair bit of refactoring since umask isn't passed through to filesystems at all, and is instead masked with the creation mode at the syscall layer. This is not a security issue since the resulting permissions are always of equal-or-greater restrictiveness to those of Linux, but is tracked in --- pkg/abi/linux/xattr.go | 114 +++++ pkg/sentry/fsimpl/erofs/erofs.go | 2 +- pkg/sentry/fsimpl/fuse/file.go | 2 +- pkg/sentry/fsimpl/fuse/inode.go | 6 +- pkg/sentry/fsimpl/gofer/filesystem.go | 3 +- pkg/sentry/fsimpl/gofer/gofer.go | 7 +- pkg/sentry/fsimpl/host/host.go | 5 +- pkg/sentry/fsimpl/kernfs/filesystem.go | 8 +- pkg/sentry/fsimpl/kernfs/inode_impl_util.go | 3 +- pkg/sentry/fsimpl/overlay/filesystem.go | 2 +- pkg/sentry/fsimpl/overlay/overlay.go | 4 +- pkg/sentry/fsimpl/overlay/regular_file.go | 2 +- pkg/sentry/fsimpl/pipefs/pipefs.go | 4 +- pkg/sentry/fsimpl/proc/task.go | 2 +- pkg/sentry/fsimpl/tmpfs/BUILD | 1 + pkg/sentry/fsimpl/tmpfs/filesystem.go | 2 +- pkg/sentry/fsimpl/tmpfs/save_restore.go | 20 + pkg/sentry/fsimpl/tmpfs/tar.go | 85 ++++ pkg/sentry/fsimpl/tmpfs/tmpfs.go | 213 ++++++++- pkg/sentry/vfs/BUILD | 3 + pkg/sentry/vfs/anonfs.go | 4 +- pkg/sentry/vfs/permissions.go | 43 +- pkg/sentry/vfs/posix_acl.go | 390 ++++++++++++++++ pkg/sentry/vfs/posix_acl_test.go | 350 +++++++++++++++ runsc/container/container_test.go | 156 +++++++ test/cmd/test_app/BUILD | 1 + test/cmd/test_app/main.go | 2 + test/cmd/test_app/xattr.go | 115 +++++ test/syscalls/BUILD | 4 + test/syscalls/linux/BUILD | 18 + test/syscalls/linux/posix_acl.cc | 464 ++++++++++++++++++++ 31 files changed, 1973 insertions(+), 62 deletions(-) create mode 100644 pkg/sentry/vfs/posix_acl.go create mode 100644 pkg/sentry/vfs/posix_acl_test.go create mode 100644 test/cmd/test_app/xattr.go create mode 100644 test/syscalls/linux/posix_acl.cc diff --git a/pkg/abi/linux/xattr.go b/pkg/abi/linux/xattr.go index 6d6606e625..937c57c498 100644 --- a/pkg/abi/linux/xattr.go +++ b/pkg/abi/linux/xattr.go @@ -14,6 +14,11 @@ package linux +import ( + "encoding/binary" + "structs" +) + // Constants for extended attributes. const ( XATTR_NAME_MAX = 255 @@ -37,3 +42,112 @@ const ( XATTR_USER_PREFIX = "user." XATTR_USER_PREFIX_LEN = len(XATTR_USER_PREFIX) ) + +// Constants for POSIX ACL extended attributes. +const ( + // Extended attribute names for POSIX ACLs. + XATTR_NAME_POSIX_ACL_ACCESS = XATTR_SYSTEM_PREFIX + "posix_acl_access" + XATTR_NAME_POSIX_ACL_DEFAULT = XATTR_SYSTEM_PREFIX + "posix_acl_default" + + POSIX_ACL_XATTR_VERSION = 2 + + // ACL_UNDEFINED_ID is the ID for entries that do not contain a + // named user or group. + ACL_UNDEFINED_ID = 0xffffffff + + // ACL entry tags. + ACL_USER_OBJ = 0x01 + ACL_USER = 0x02 + ACL_GROUP_OBJ = 0x04 + ACL_GROUP = 0x08 + ACL_MASK = 0x10 + ACL_OTHER = 0x20 + + // ACL entry permission bits. + ACL_READ = 0x04 + ACL_WRITE = 0x02 + ACL_EXECUTE = 0x01 +) + +// PosixACLXattrEntry is a single entry in the userspace representation +// of a POSIX ACL. It corresponds to Linux's struct posix_acl_xattr_entry. +// +// All fields in PosixACLXattrEntry are stored as little-endian. +// +// +marshal dynamic +type PosixACLXattrEntry struct { + _ structs.HostLayout + Tag uint16 + Perm uint16 + ID uint32 +} + +// SizeBytes implements marshal.Marshallable.SizeBytes. +func (a *PosixACLXattrEntry) SizeBytes() int { + return 8 +} + +// MarshalBytes implements marshal.Marshallable.MarshalBytes. +func (a *PosixACLXattrEntry) MarshalBytes(dst []byte) []byte { + binary.LittleEndian.PutUint16(dst[0:], a.Tag) + binary.LittleEndian.PutUint16(dst[2:], a.Perm) + binary.LittleEndian.PutUint32(dst[4:], a.ID) + + return dst[8:] +} + +// UnmarshalBytes implements marshal.Marshallable.UnmarshalBytes. +func (a *PosixACLXattrEntry) UnmarshalBytes(src []byte) []byte { + a.Tag = binary.LittleEndian.Uint16(src[0:]) + a.Perm = binary.LittleEndian.Uint16(src[2:]) + a.ID = binary.LittleEndian.Uint32(src[4:]) + + return src[8:] +} + +// PosixACLXattr is the userspace representation of a POSIX ACL. +// +// +marshal dynamic +type PosixACLXattr struct { + _ structs.HostLayout + + // Version is the POSIX ACL version, stored as little-endian. + Version uint32 + + // Entries contains the ACL entries. + Entries []PosixACLXattrEntry `hostlayout:"ignore"` +} + +// posixACLXattrHeaderSize is the size in bytes of the header. +const posixACLXattrHeaderSize = 4 + +// SizeBytes implements marshal.Marshallable.SizeBytes. +func (a *PosixACLXattr) SizeBytes() int { + return posixACLXattrHeaderSize + len(a.Entries)*(*PosixACLXattrEntry)(nil).SizeBytes() +} + +// MarshalBytes implements marshal.Marshallable.MarshalBytes. +func (a *PosixACLXattr) MarshalBytes(dst []byte) []byte { + binary.LittleEndian.PutUint32(dst, a.Version) + + dst = dst[posixACLXattrHeaderSize:] + for _, entry := range a.Entries { + dst = entry.MarshalBytes(dst) + } + + return dst +} + +// UnmarshalBytes implements marshal.Marshallable.UnmarshalBytes. +func (a *PosixACLXattr) UnmarshalBytes(src []byte) []byte { + a.Version = binary.LittleEndian.Uint32(src) + + src = src[posixACLXattrHeaderSize:] + for len(src) >= (*PosixACLXattrEntry)(nil).SizeBytes() { + var entry PosixACLXattrEntry + src = entry.UnmarshalBytes(src) + a.Entries = append(a.Entries, entry) + } + + return src +} diff --git a/pkg/sentry/fsimpl/erofs/erofs.go b/pkg/sentry/fsimpl/erofs/erofs.go index 7e1bff57b3..4402072ad9 100644 --- a/pkg/sentry/fsimpl/erofs/erofs.go +++ b/pkg/sentry/fsimpl/erofs/erofs.go @@ -327,7 +327,7 @@ func (i *inode) DecRef(ctx context.Context) { } func (i *inode) checkPermissions(creds *auth.Credentials, ats vfs.AccessTypes) error { - return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.Mode()), auth.KUID(i.UID()), auth.KGID(i.GID())) + return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.Mode()), nil, auth.KUID(i.UID()), auth.KGID(i.GID())) } func (i *inode) statTo(stat *linux.Statx) { diff --git a/pkg/sentry/fsimpl/fuse/file.go b/pkg/sentry/fsimpl/fuse/file.go index 8a3c103b76..f304a1a827 100644 --- a/pkg/sentry/fsimpl/fuse/file.go +++ b/pkg/sentry/fsimpl/fuse/file.go @@ -159,7 +159,7 @@ func (fd *fileDescription) SetStat(ctx context.Context, opts vfs.SetStatOptions) inode := fd.inode() inode.attrMu.Lock() defer inode.attrMu.Unlock() - if err := vfs.CheckSetStat(ctx, creds, &opts, inode.filemode(), auth.KUID(inode.uid.Load()), auth.KGID(inode.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, creds, &opts, inode.filemode(), nil, auth.KUID(inode.uid.Load()), auth.KGID(inode.gid.Load())); err != nil { return err } return inode.setAttr(ctx, fs, creds, opts, fhOptions{useFh: true, fh: fd.Fh}) diff --git a/pkg/sentry/fsimpl/fuse/inode.go b/pkg/sentry/fsimpl/fuse/inode.go index f3c233fa2a..7646f27240 100644 --- a/pkg/sentry/fsimpl/fuse/inode.go +++ b/pkg/sentry/fsimpl/fuse/inode.go @@ -476,12 +476,12 @@ func (i *inode) CheckPermissions(ctx context.Context, creds *auth.Credentials, a } if i.fs.opts.defaultPermissions || (ats.MayExec() && i.filemode().FileType() == linux.S_IFREG) { - err := vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.mode.Load()), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) + err := vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.mode.Load()), nil, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) if linuxerr.Equals(linuxerr.EACCES, err) && !refreshed { if _, err := i.getAttr(ctx, creds, i.fs.VFSFilesystem(), opts, 0, 0); err != nil { return err } - return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.mode.Load()), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) + return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(i.mode.Load()), nil, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) } return err } @@ -854,7 +854,7 @@ func (i *inode) SetStat(ctx context.Context, fs *vfs.Filesystem, creds *auth.Cre i.attrMu.Lock() defer i.attrMu.Unlock() - if err := vfs.CheckSetStat(ctx, creds, &opts, i.filemode(), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, creds, &opts, i.filemode(), nil, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { return err } if opts.Stat.Mask == 0 { diff --git a/pkg/sentry/fsimpl/gofer/filesystem.go b/pkg/sentry/fsimpl/gofer/filesystem.go index f11c1fb58e..43a4491d14 100644 --- a/pkg/sentry/fsimpl/gofer/filesystem.go +++ b/pkg/sentry/fsimpl/gofer/filesystem.go @@ -21,6 +21,7 @@ import ( "sync" "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/atomicbitops" "gvisor.dev/gvisor/pkg/context" @@ -828,7 +829,7 @@ func (fs *filesystem) LinkAt(ctx context.Context, rp *vfs.ResolvingPath, vd vfs. gid := auth.KGID(d.inode.gid.Load()) uid := auth.KUID(d.inode.uid.Load()) mode := linux.FileMode(d.inode.mode.Load()) - if err := vfs.MayLink(rp.Credentials(), mode, uid, gid); err != nil { + if err := vfs.MayLink(rp.Credentials(), mode, nil, uid, gid); err != nil { return nil, err } if d.inode.nlink.Load() == 0 { diff --git a/pkg/sentry/fsimpl/gofer/gofer.go b/pkg/sentry/fsimpl/gofer/gofer.go index 8777d8aa56..a4f5bd551b 100644 --- a/pkg/sentry/fsimpl/gofer/gofer.go +++ b/pkg/sentry/fsimpl/gofer/gofer.go @@ -49,6 +49,7 @@ import ( "sync/atomic" "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/atomicbitops" "gvisor.dev/gvisor/pkg/cleanup" @@ -1292,7 +1293,7 @@ func (d *dentry) setStat(ctx context.Context, creds *auth.Credentials, opts *vfs return linuxerr.EPERM } mode := linux.FileMode(d.inode.mode.Load()) - if err := vfs.CheckSetStat(ctx, creds, opts, mode, auth.KUID(d.inode.uid.Load()), auth.KGID(d.inode.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, creds, opts, mode, nil, auth.KUID(d.inode.uid.Load()), auth.KGID(d.inode.gid.Load())); err != nil { return err } if err := mnt.CheckBeginWrite(); err != nil { @@ -1505,7 +1506,7 @@ func (i *inode) updateSizeAndUnlockDataMuLocked(newSize uint64) { } func (d *dentry) checkPermissions(creds *auth.Credentials, ats vfs.AccessTypes) error { - return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(d.inode.mode.Load()), auth.KUID(d.inode.uid.Load()), auth.KGID(d.inode.gid.Load())) + return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(d.inode.mode.Load()), nil, auth.KUID(d.inode.uid.Load()), auth.KGID(d.inode.gid.Load())) } // Preconditions: d.inode.metadataMu must be locked. @@ -1529,7 +1530,7 @@ func (d *dentry) checkXattrPermissions(creds *auth.Credentials, name string, ats mode := linux.FileMode(d.inode.mode.RacyLoad()) kuid := auth.KUID(d.inode.uid.RacyLoad()) kgid := auth.KGID(d.inode.gid.RacyLoad()) - if err := vfs.GenericCheckPermissions(creds, ats, mode, kuid, kgid); err != nil { + if err := vfs.GenericCheckPermissions(creds, ats, mode, nil, kuid, kgid); err != nil { return err } return vfs.CheckXattrPermissions(creds, ats, mode, kuid, name) diff --git a/pkg/sentry/fsimpl/host/host.go b/pkg/sentry/fsimpl/host/host.go index a7d374ae86..235128860e 100644 --- a/pkg/sentry/fsimpl/host/host.go +++ b/pkg/sentry/fsimpl/host/host.go @@ -21,6 +21,7 @@ import ( "math" "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/atomicbitops" "gvisor.dev/gvisor/pkg/context" @@ -412,7 +413,7 @@ func (i *inode) CheckPermissions(ctx context.Context, creds *auth.Credentials, a if err := i.stat(&s); err != nil { return err } - return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(s.Mode), auth.KUID(s.Uid), auth.KGID(s.Gid)) + return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(s.Mode), nil, auth.KUID(s.Uid), auth.KGID(s.Gid)) } // Mode implements kernfs.Inode.Mode. @@ -607,7 +608,7 @@ func (i *inode) SetStat(ctx context.Context, fs *vfs.Filesystem, creds *auth.Cre if err := i.stat(&hostStat); err != nil { return err } - if err := vfs.CheckSetStat(ctx, creds, &opts, linux.FileMode(hostStat.Mode), auth.KUID(hostStat.Uid), auth.KGID(hostStat.Gid)); err != nil { + if err := vfs.CheckSetStat(ctx, creds, &opts, linux.FileMode(hostStat.Mode), nil, auth.KUID(hostStat.Uid), auth.KGID(hostStat.Gid)); err != nil { return err } diff --git a/pkg/sentry/fsimpl/kernfs/filesystem.go b/pkg/sentry/fsimpl/kernfs/filesystem.go index 20d351cb99..074f606d40 100644 --- a/pkg/sentry/fsimpl/kernfs/filesystem.go +++ b/pkg/sentry/fsimpl/kernfs/filesystem.go @@ -415,7 +415,7 @@ func (fs *Filesystem) LinkAt(ctx context.Context, rp *vfs.ResolvingPath, vd vfs. if inode.Mode().IsDir() { return linuxerr.EPERM } - if err := vfs.MayLink(rp.Credentials(), inode.Mode(), inode.UID(), inode.GID()); err != nil { + if err := vfs.MayLink(rp.Credentials(), inode.Mode(), nil, inode.UID(), inode.GID()); err != nil { return err } parent.dirMu.Lock() @@ -1129,7 +1129,7 @@ func (fs *Filesystem) GetXattrAt(ctx context.Context, rp *vfs.ResolvingPath, opt mode := d.inode.Mode() kuid := d.inode.UID() kgid := d.inode.GID() - if err := vfs.GenericCheckPermissions(creds, vfs.MayRead, mode, kuid, kgid); err != nil { + if err := vfs.GenericCheckPermissions(creds, vfs.MayRead, mode, nil, kuid, kgid); err != nil { return "", err } if err := vfs.CheckXattrPermissions(creds, vfs.MayRead, mode, kuid, opts.Name); err != nil { @@ -1155,7 +1155,7 @@ func (fs *Filesystem) SetXattrAt(ctx context.Context, rp *vfs.ResolvingPath, opt mode := d.inode.Mode() kuid := d.inode.UID() kgid := d.inode.GID() - if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, kuid, kgid); err != nil { + if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, nil, kuid, kgid); err != nil { return err } if err := vfs.CheckXattrPermissions(creds, vfs.MayWrite, mode, kuid, opts.Name); err != nil { @@ -1181,7 +1181,7 @@ func (fs *Filesystem) RemoveXattrAt(ctx context.Context, rp *vfs.ResolvingPath, mode := d.inode.Mode() kuid := d.inode.UID() kgid := d.inode.GID() - if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, kuid, kgid); err != nil { + if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, nil, kuid, kgid); err != nil { return err } if err := vfs.CheckXattrPermissions(creds, vfs.MayWrite, mode, kuid, name); err != nil { diff --git a/pkg/sentry/fsimpl/kernfs/inode_impl_util.go b/pkg/sentry/fsimpl/kernfs/inode_impl_util.go index 9308cddae4..5defec6ae3 100644 --- a/pkg/sentry/fsimpl/kernfs/inode_impl_util.go +++ b/pkg/sentry/fsimpl/kernfs/inode_impl_util.go @@ -310,7 +310,7 @@ func (a *InodeAttrs) SetStat(ctx context.Context, fs *vfs.Filesystem, creds *aut if opts.Stat.Mask&linux.STATX_SIZE != 0 && a.Mode().IsDir() { return linuxerr.EISDIR } - if err := vfs.CheckSetStat(ctx, creds, &opts, a.Mode(), auth.KUID(a.uid.Load()), auth.KGID(a.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, creds, &opts, a.Mode(), nil, auth.KUID(a.uid.Load()), auth.KGID(a.gid.Load())); err != nil { return err } @@ -373,6 +373,7 @@ func (a *InodeAttrs) CheckPermissions(_ context.Context, creds *auth.Credentials creds, ats, a.Mode(), + nil, auth.KUID(a.uid.Load()), auth.KGID(a.gid.Load()), ) diff --git a/pkg/sentry/fsimpl/overlay/filesystem.go b/pkg/sentry/fsimpl/overlay/filesystem.go index c3cd9711e1..17bdfc3717 100644 --- a/pkg/sentry/fsimpl/overlay/filesystem.go +++ b/pkg/sentry/fsimpl/overlay/filesystem.go @@ -1500,7 +1500,7 @@ func (fs *filesystem) SetStatAt(ctx context.Context, rp *vfs.ResolvingPath, opts // Precondition: d.fs.renameMu must be held for reading. func (d *dentry) setStatLocked(ctx context.Context, rp *vfs.ResolvingPath, opts vfs.SetStatOptions) error { mode := linux.FileMode(d.mode.Load()) - if err := vfs.CheckSetStat(ctx, rp.Credentials(), &opts, mode, auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, rp.Credentials(), &opts, mode, nil, auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())); err != nil { return err } mnt := rp.Mount() diff --git a/pkg/sentry/fsimpl/overlay/overlay.go b/pkg/sentry/fsimpl/overlay/overlay.go index 4d20bded24..113617ad19 100644 --- a/pkg/sentry/fsimpl/overlay/overlay.go +++ b/pkg/sentry/fsimpl/overlay/overlay.go @@ -872,14 +872,14 @@ func (d *dentry) topLookupLayer() lookupLayer { } func (d *dentry) checkPermissions(creds *auth.Credentials, ats vfs.AccessTypes) error { - return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(d.mode.Load()), auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())) + return vfs.GenericCheckPermissions(creds, ats, linux.FileMode(d.mode.Load()), nil, auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())) } func (d *dentry) checkXattrPermissions(creds *auth.Credentials, name string, ats vfs.AccessTypes) error { mode := linux.FileMode(d.mode.Load()) kuid := auth.KUID(d.uid.Load()) kgid := auth.KGID(d.gid.Load()) - if err := vfs.GenericCheckPermissions(creds, ats, mode, kuid, kgid); err != nil { + if err := vfs.GenericCheckPermissions(creds, ats, mode, nil, kuid, kgid); err != nil { return err } return vfs.CheckXattrPermissions(creds, ats, mode, kuid, name) diff --git a/pkg/sentry/fsimpl/overlay/regular_file.go b/pkg/sentry/fsimpl/overlay/regular_file.go index c752ca657b..df5c0be759 100644 --- a/pkg/sentry/fsimpl/overlay/regular_file.go +++ b/pkg/sentry/fsimpl/overlay/regular_file.go @@ -167,7 +167,7 @@ func (fd *regularFileFD) Allocate(ctx context.Context, mode, offset, length uint func (fd *regularFileFD) SetStat(ctx context.Context, opts vfs.SetStatOptions) error { d := fd.dentry() mode := linux.FileMode(d.mode.Load()) - if err := vfs.CheckSetStat(ctx, auth.CredentialsFromContext(ctx), &opts, mode, auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, auth.CredentialsFromContext(ctx), &opts, mode, nil, auth.KUID(d.uid.Load()), auth.KGID(d.gid.Load())); err != nil { return err } mnt := fd.vfsfd.Mount() diff --git a/pkg/sentry/fsimpl/pipefs/pipefs.go b/pkg/sentry/fsimpl/pipefs/pipefs.go index b332fdbfbb..c1c7f4cd1d 100644 --- a/pkg/sentry/fsimpl/pipefs/pipefs.go +++ b/pkg/sentry/fsimpl/pipefs/pipefs.go @@ -125,7 +125,7 @@ const pipeMode = 0600 | linux.S_IFIFO func (i *inode) CheckPermissions(ctx context.Context, creds *auth.Credentials, ats vfs.AccessTypes) error { i.attrMu.Lock() defer i.attrMu.Unlock() - return vfs.GenericCheckPermissions(creds, ats, pipeMode, i.uid, i.gid) + return vfs.GenericCheckPermissions(creds, ats, pipeMode, nil, i.uid, i.gid) } // Mode implements kernfs.Inode.Mode. @@ -177,7 +177,7 @@ func (i *inode) SetStat(ctx context.Context, vfsfs *vfs.Filesystem, creds *auth. } i.attrMu.Lock() defer i.attrMu.Unlock() - if err := vfs.CheckSetStat(ctx, creds, &opts, pipeMode, auth.KUID(i.uid), auth.KGID(i.gid)); err != nil { + if err := vfs.CheckSetStat(ctx, creds, &opts, pipeMode, nil, auth.KUID(i.uid), auth.KGID(i.gid)); err != nil { return err } if opts.Stat.Mask&linux.STATX_UID != 0 { diff --git a/pkg/sentry/fsimpl/proc/task.go b/pkg/sentry/fsimpl/proc/task.go index 1e16a2ff14..0aa7b5f2df 100644 --- a/pkg/sentry/fsimpl/proc/task.go +++ b/pkg/sentry/fsimpl/proc/task.go @@ -262,7 +262,7 @@ func (i *taskOwnedInode) Stat(ctx context.Context, fs *vfs.Filesystem, opts vfs. func (i *taskOwnedInode) CheckPermissions(ctx context.Context, creds *auth.Credentials, ats vfs.AccessTypes) error { mode := i.Mode() uid, gid := i.getOwner(mode) - return vfs.GenericCheckPermissions(creds, ats, mode, uid, gid) + return vfs.GenericCheckPermissions(creds, ats, mode, nil, uid, gid) } func (i *taskOwnedInode) getOwner(mode linux.FileMode) (auth.KUID, auth.KGID) { diff --git a/pkg/sentry/fsimpl/tmpfs/BUILD b/pkg/sentry/fsimpl/tmpfs/BUILD index 9b35b60145..164d8f459f 100644 --- a/pkg/sentry/fsimpl/tmpfs/BUILD +++ b/pkg/sentry/fsimpl/tmpfs/BUILD @@ -101,6 +101,7 @@ go_library( ], imports = [ "gvisor.dev/gvisor/pkg/sentry/checkpoint", + "gvisor.dev/gvisor/pkg/sentry/vfs", ], marshal = True, visibility = ["//pkg/sentry:internal"], diff --git a/pkg/sentry/fsimpl/tmpfs/filesystem.go b/pkg/sentry/fsimpl/tmpfs/filesystem.go index beb297783c..099aeee38f 100644 --- a/pkg/sentry/fsimpl/tmpfs/filesystem.go +++ b/pkg/sentry/fsimpl/tmpfs/filesystem.go @@ -280,7 +280,7 @@ func (fs *filesystem) LinkAt(ctx context.Context, rp *vfs.ResolvingPath, vd vfs. if i.isDir() { return linuxerr.EPERM } - if err := vfs.MayLink(auth.CredentialsFromContext(ctx), linux.FileMode(i.mode.Load()), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { + if err := vfs.MayLink(auth.CredentialsFromContext(ctx), linux.FileMode(i.mode.Load()), i.acl.Load(), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { return err } if i.nlink.Load() == 0 { diff --git a/pkg/sentry/fsimpl/tmpfs/save_restore.go b/pkg/sentry/fsimpl/tmpfs/save_restore.go index f7763bfc96..b50a435efc 100644 --- a/pkg/sentry/fsimpl/tmpfs/save_restore.go +++ b/pkg/sentry/fsimpl/tmpfs/save_restore.go @@ -61,6 +61,26 @@ func (d *dentry) loadParent(_ goContext.Context, parent *dentry) { d.parent.Store(parent) } +// saveAcl is called by stateify. +func (i *inode) saveAcl() *vfs.PosixACL { + return i.acl.Load() +} + +// loadAcl is called by stateify. +func (i *inode) loadAcl(_ goContext.Context, acl *vfs.PosixACL) { + i.acl.Store(acl) +} + +// saveDefaultACL is called by stateify. +func (i *inode) saveDefaultACL() *vfs.PosixACL { + return i.defaultACL.Load() +} + +// loadDefaultACL is called by stateify. +func (i *inode) loadDefaultACL(_ goContext.Context, defaultACL *vfs.PosixACL) { + i.defaultACL.Store(defaultACL) +} + // PrepareSave implements vfs.FilesystemImplSaveRestoreExtension.PrepareSave. func (fs *filesystem) PrepareSave(ctx context.Context) error { resourceID := fs.mf.ResourceID() diff --git a/pkg/sentry/fsimpl/tmpfs/tar.go b/pkg/sentry/fsimpl/tmpfs/tar.go index 8ca79344bf..4e98435dd3 100644 --- a/pkg/sentry/fsimpl/tmpfs/tar.go +++ b/pkg/sentry/fsimpl/tmpfs/tar.go @@ -17,6 +17,7 @@ package tmpfs import ( "archive/tar" "bytes" + "encoding/base64" "fmt" "io" "os" @@ -142,6 +143,12 @@ func (fs *filesystem) mkdirFromTar(hdr *tar.Header, pathToInode map[string]*inod ino := fs.root.inode ino.uid.Store(uint32(hdr.Uid)) ino.gid.Store(uint32(hdr.Gid)) + acl, defaultACL, err := getACLsFromHeader(hdr) + if err != nil { + return nil, err + } + ino.acl.Store(acl) + ino.defaultACL.Store(defaultACL) ino.mode.Store(uint32(hdr.Mode) | linux.S_IFDIR) ino.mtime.Store(hdr.ModTime.UnixNano()) ino.setXattrsFromPAXRecords(hdr) @@ -172,6 +179,12 @@ func (fs *filesystem) mkdirFromTar(hdr *tar.Header, pathToInode map[string]*inod if err != nil { return nil, fmt.Errorf("failed to create new directory inode: %v", err) } + acl, defaultACL, err := getACLsFromHeader(hdr) + if err != nil { + return nil, err + } + childDir.inode.acl.Store(acl) + childDir.inode.defaultACL.Store(defaultACL) parentDir.inode.incLinksLocked() // from child's ".." parentDir.inode.incRef() // child directory holds a reference to parent childDir.inode.mtime.Store(hdr.ModTime.UnixNano()) @@ -211,6 +224,12 @@ func (fs *filesystem) mknodFromTar(ctx context.Context, hdr *tar.Header, pathToI if err != nil { return err } + acl, defaultACL, err := getACLsFromHeader(hdr) + if err != nil { + return err + } + childInode.acl.Store(acl) + childInode.defaultACL.Store(defaultACL) childInode.mtime.Store(hdr.ModTime.UnixNano()) childInode.setXattrsFromPAXRecords(hdr) child := fs.newDentry(childInode) @@ -278,6 +297,12 @@ func (fs *filesystem) symlinkFromTar(hdr *tar.Header, pathToInode map[string]*in return fmt.Errorf("failed to create inode from tar: %v", err) } child := fs.newDentry(childInode) + acl, defaultACL, err := getACLsFromHeader(hdr) + if err != nil { + return err + } + child.inode.acl.Store(acl) + child.inode.defaultACL.Store(defaultACL) child.inode.mtime.Store(hdr.ModTime.UnixNano()) child.inode.setXattrsFromPAXRecords(hdr) parentDir.insertChildLocked(child, name) @@ -469,6 +494,20 @@ func (d *dentry) createTarHeader(path string, inoToPath map[uint64]string, cb ta } } + // Serialize POSIX ACLs to PAXRecords. + if acl := d.inode.acl.Load(); acl != nil { + if header.PAXRecords == nil { + header.PAXRecords = make(map[string]string) + } + header.PAXRecords[paxXattrPrefix+linux.XATTR_NAME_POSIX_ACL_ACCESS] = aclToPAXRecord(acl) + } + if acl := d.inode.defaultACL.Load(); acl != nil { + if header.PAXRecords == nil { + header.PAXRecords = make(map[string]string) + } + header.PAXRecords[paxXattrPrefix+linux.XATTR_NAME_POSIX_ACL_DEFAULT] = aclToPAXRecord(acl) + } + inoToPath[d.inode.ino] = path return header, nil } @@ -511,3 +550,49 @@ func (tarDefaultWriterCallbacks) regularFileWrite(ctx context.Context, rf *regul } return nil } + +func aclToPAXRecord(acl *vfs.PosixACL) string { + // We can use a "fake" root userns for this serialization + // since the values are KUIDs anyway. + data := acl.Serialize(auth.NewRootUserNamespace()) + + // Base64 encode the ACLs since PaxRecords must be UTF-8 strings. + // Omit padding characters (RawStdEncoding) to avoid '=' chars + // as well (which tar PAXRecords disallows). + dataEncoded := base64.RawStdEncoding.EncodeToString(data) + + return dataEncoded +} + +func aclFromHeader(hdr *tar.Header, aclName string) (*vfs.PosixACL, error) { + record, ok := hdr.PAXRecords[paxXattrPrefix+aclName] + if !ok { + return nil, nil + } + + dataDecoded, err := base64.RawStdEncoding.DecodeString(record) + if err != nil { + return &vfs.PosixACL{}, err + } + + acl, err := vfs.ParsePosixACL(dataDecoded, auth.NewRootUserNamespace()) + if err != nil { + return &vfs.PosixACL{}, err + } + + return &acl, nil +} + +func getACLsFromHeader(hdr *tar.Header) (*vfs.PosixACL, *vfs.PosixACL, error) { + acl, err := aclFromHeader(hdr, linux.XATTR_NAME_POSIX_ACL_ACCESS) + if err != nil { + return nil, nil, err + } + + defaultACL, err := aclFromHeader(hdr, linux.XATTR_NAME_POSIX_ACL_DEFAULT) + if err != nil { + return nil, nil, err + } + + return acl, defaultACL, nil +} diff --git a/pkg/sentry/fsimpl/tmpfs/tmpfs.go b/pkg/sentry/fsimpl/tmpfs/tmpfs.go index 911463d570..821b06b2ec 100644 --- a/pkg/sentry/fsimpl/tmpfs/tmpfs.go +++ b/pkg/sentry/fsimpl/tmpfs/tmpfs.go @@ -221,14 +221,16 @@ func (fstype FilesystemType) GetFilesystem(ctx context.Context, vfsObj *vfs.Virt disableDefaultSizeLimit := false newFSType := vfs.FilesystemType(&fstype) - // By default we support only "trusted" and "user" namespaces. Linux - // also supports "security" and (if configured) POSIX ACL namespaces - // "system.posix_acl_access" and "system.posix_acl_default". + // By default we support only "trusted", "user", and "system" namespaces. + // Linux also supports "security". allowXattrPrefix := map[string]struct{}{ linux.XATTR_TRUSTED_PREFIX: {}, linux.XATTR_USER_PREFIX: {}, // Only the "security.capability" xattr is supported. linux.XATTR_SECURITY_PREFIX: {}, + // Only the system.posix_acl_access and system.posix_acl_default + // xattrs are supported. + linux.XATTR_SYSTEM_PREFIX: {}, } tmpfsOpts, tmpfsOptsOk := opts.InternalData.(FilesystemOpts) @@ -611,12 +613,14 @@ type inode struct { // Inode metadata. Writing multiple fields atomically requires holding // mu, otherwise atomic operations can be used. - mu inodeMutex `state:"nosave"` - mode atomicbitops.Uint32 // file type and mode - nlink atomicbitops.Uint32 // protected by filesystem.mu instead of inode.mu - uid atomicbitops.Uint32 // auth.KUID, but stored as raw uint32 for sync/atomic - gid atomicbitops.Uint32 // auth.KGID, but ... - ino uint64 // immutable + mu inodeMutex `state:"nosave"` + mode atomicbitops.Uint32 // file type and mode + acl atomic.Pointer[vfs.PosixACL] `state:".(*vfs.PosixACL)"` + defaultACL atomic.Pointer[vfs.PosixACL] `state:".(*vfs.PosixACL)"` + nlink atomicbitops.Uint32 // protected by filesystem.mu instead of inode.mu + uid atomicbitops.Uint32 // auth.KUID, but stored as raw uint32 for sync/atomic + gid atomicbitops.Uint32 // auth.KGID, but ... + ino uint64 // immutable atime atomicbitops.Int64 // nanoseconds btime atomicbitops.Int64 // nanoseconds @@ -653,8 +657,35 @@ func (i *inode) init(impl any, fs *filesystem, kuid auth.KUID, kgid auth.KGID, m } } - i.fs = fs + // Inherit the parent's default mode and ACL as appropriate i.mode = atomicbitops.FromUint32(uint32(mode)) + if parentDir != nil && mode.FileType() != linux.ModeSymlink { + parentDefaultACL := parentDir.inode.defaultACL.Load() + + if parentDefaultACL != nil { + // TODO(gvisor.dev/issues/13688): Linux only masks in the process's umask when creating + // in a directory with no default ACL. In gVisor, the umask is masked in by the syscall + // layer and not passed along to VFS and the filesystems, so skipping the umask in this + // case will require some refactoring. + // + // This will never lead to overly-permissive file permissions, only too-strict. + + newACL := parentDefaultACL.MaskNewFileMode(uint16(mode)) + equivMode, equiv := newACL.Mode() + if !equiv { + i.acl.Store(&newACL) + } + newMode := (uint16(equivMode) & linux.PermissionsMask) | (uint16(mode) &^ linux.PermissionsMask) + i.mode = atomicbitops.FromUint32(uint32(newMode)) + + if mode.IsDir() { + i.defaultACL.Store(parentDefaultACL) + } + + } + } + + i.fs = fs i.uid = atomicbitops.FromUint32(uint32(kuid)) i.gid = atomicbitops.FromUint32(uint32(kgid)) i.ino = fs.nextInoMinusOne.Add(1) @@ -739,7 +770,8 @@ func (i *inode) decRef(ctx context.Context) { func (i *inode) checkPermissions(creds *auth.Credentials, ats vfs.AccessTypes) error { mode := linux.FileMode(i.mode.Load()) - return vfs.GenericCheckPermissions(creds, ats, mode, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) + acl := i.acl.Load() + return vfs.GenericCheckPermissions(creds, ats, mode, acl, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())) } // Go won't inline this function, and returning linux.Statx (which is quite @@ -797,7 +829,7 @@ func (i *inode) setStat(ctx context.Context, creds *auth.Credentials, opts *vfs. return linuxerr.EPERM } mode := linux.FileMode(i.mode.Load()) - if err := vfs.CheckSetStat(ctx, creds, opts, mode, auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { + if err := vfs.CheckSetStat(ctx, creds, opts, mode, i.acl.Load(), auth.KUID(i.uid.Load()), auth.KGID(i.gid.Load())); err != nil { return err } @@ -833,18 +865,34 @@ func (i *inode) setStat(ctx context.Context, creds *auth.Credentials, opts *vfs. clearSID := opts.ClearPrivs if mask&linux.STATX_MODE != 0 { + // Compute a new ACL (and equivalent mode) from the specified stat.Mode. + acl := i.acl.Load() + newPerm := stat.Mode + if acl != nil { + newAcl := acl.Chmod(uint16(stat.Mode & linux.PermissionsMask)) + var equiv bool + newPerm, equiv = newAcl.Mode() + + if !equiv { + i.acl.Store(&newAcl) + } else { + i.acl.Store(nil) + } + } + + // Swap in the new (or equivalent, if updating an ACL) mode. for { - old := i.mode.Load() - ft := old & linux.S_IFMT - newMode := ft | uint32(stat.Mode & ^uint16(linux.S_IFMT)) + oldMode := i.mode.Load() + newMode := uint32(oldMode&^linux.PermissionsMask) | uint32(newPerm&linux.PermissionsMask) if clearSID { newMode = vfs.ClearSUIDAndSGID(newMode) } - if swapped := i.mode.CompareAndSwap(old, newMode); swapped { + if swapped := i.mode.CompareAndSwap(oldMode, newMode); swapped { clearSID = false break } } + needsCtimeBump = true } now := i.fs.clock.Now().Nanoseconds() @@ -992,7 +1040,19 @@ func (i *inode) checkXattrPrefix(name string) error { } func (i *inode) listXattr(creds *auth.Credentials, size uint64) ([]string, error) { - return i.xattrs.ListXattr(creds, size) + xattrs, err := i.xattrs.ListXattr(creds, size) + if err != nil { + return nil, err + } + + if i.acl.Load() != nil { + xattrs = append(xattrs, linux.XATTR_NAME_POSIX_ACL_ACCESS) + } + if i.defaultACL.Load() != nil { + xattrs = append(xattrs, linux.XATTR_NAME_POSIX_ACL_DEFAULT) + } + + return xattrs, nil } func (i *inode) getXattr(creds *auth.Credentials, opts *vfs.GetXattrOptions) (string, error) { @@ -1000,11 +1060,37 @@ func (i *inode) getXattr(creds *auth.Credentials, opts *vfs.GetXattrOptions) (st return "", err } mode := linux.FileMode(i.mode.Load()) + acl := i.acl.Load() kuid := auth.KUID(i.uid.Load()) kgid := auth.KGID(i.gid.Load()) - if err := vfs.GenericCheckPermissions(creds, vfs.MayRead, mode, kuid, kgid); err != nil { + + // Handle POSIX ACL xattrs + if strings.HasPrefix(opts.Name, linux.XATTR_SYSTEM_PREFIX) { + switch opts.Name { + case linux.XATTR_NAME_POSIX_ACL_ACCESS: + if acl == nil { + return "", linuxerr.ENODATA + } + + // Serialize the access ACL for userspace + return string(acl.Serialize(creds.UserNamespace)), nil + case linux.XATTR_NAME_POSIX_ACL_DEFAULT: + defaultAcl := i.defaultACL.Load() + if defaultAcl == nil { + return "", linuxerr.ENODATA + } + + // Serialize the default ACL for userspace + return string(defaultAcl.Serialize(creds.UserNamespace)), nil + default: + return "", linuxerr.EOPNOTSUPP + } + } + + if err := vfs.GenericCheckPermissions(creds, vfs.MayRead, mode, acl, kuid, kgid); err != nil { return "", err } + return i.xattrs.GetXattr(creds, mode, kuid, opts) } @@ -1013,9 +1099,70 @@ func (i *inode) setXattr(creds *auth.Credentials, opts *vfs.SetXattrOptions) err return err } mode := linux.FileMode(i.mode.Load()) + acl := i.acl.Load() kuid := auth.KUID(i.uid.Load()) kgid := auth.KGID(i.gid.Load()) - if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, kuid, kgid); err != nil { + + if strings.HasPrefix(opts.Name, linux.XATTR_SYSTEM_PREFIX) { + switch opts.Name { + case linux.XATTR_NAME_POSIX_ACL_ACCESS: + // POSIX Access ACL + + if !vfs.CanActAsOwner(creds, kuid) { + return linuxerr.EPERM + } + + // POSIX ACL: parse from userspace + acl, err := vfs.ParsePosixACL([]byte(opts.Value), creds.UserNamespace) + if err != nil { + return err + } + + // Then, update the inode's mode + mode, equiv := acl.Mode() + i.mu.Lock() + defer i.mu.Unlock() + for { + old := i.mode.Load() + newMode := (old &^ linux.PermissionsMask) | uint32(mode) + if swapped := i.mode.CompareAndSwap(old, newMode); swapped { + break + } + } + if equiv { + // If the ACL can be represented simply as a mode, no need for the ACL. + i.acl.Store(nil) + } else { + // Otherwise, store the ACL too for permission checking. + i.acl.Store(&acl) + } + case linux.XATTR_NAME_POSIX_ACL_DEFAULT: + // POSIX Default ACL + + if !mode.IsDir() { + // Default ACL can only be set on directories + return linuxerr.EACCES + } + + if !vfs.CanActAsOwner(creds, kuid) { + return linuxerr.EPERM + } + + // POSIX ACL: parse from userspace + defaultAcl, err := vfs.ParsePosixACL([]byte(opts.Value), creds.UserNamespace) + if err != nil { + return err + } + + i.defaultACL.Store(&defaultAcl) + default: + return linuxerr.EOPNOTSUPP + } + + return nil + } + + if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, acl, kuid, kgid); err != nil { return err } return i.xattrs.SetXattr(creds, mode, kuid, kgid, opts) @@ -1026,11 +1173,37 @@ func (i *inode) removeXattr(creds *auth.Credentials, name string) error { return err } mode := linux.FileMode(i.mode.Load()) + acl := i.acl.Load() kuid := auth.KUID(i.uid.Load()) kgid := auth.KGID(i.gid.Load()) - if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, kuid, kgid); err != nil { + + if strings.HasPrefix(name, linux.XATTR_SYSTEM_PREFIX) { + switch name { + case linux.XATTR_NAME_POSIX_ACL_ACCESS: + if !vfs.CanActAsOwner(creds, kuid) { + return linuxerr.EPERM + } + + // Clear the access ACL. + i.acl.Store(nil) + case linux.XATTR_NAME_POSIX_ACL_DEFAULT: + if !vfs.CanActAsOwner(creds, kuid) { + return linuxerr.EPERM + } + + // Clear the default ACL. + i.defaultACL.Store(nil) + default: + return linuxerr.EOPNOTSUPP + } + + return nil + } + + if err := vfs.GenericCheckPermissions(creds, vfs.MayWrite, mode, acl, kuid, kgid); err != nil { return err } + return i.xattrs.RemoveXattr(creds, mode, kuid, name) } diff --git a/pkg/sentry/vfs/BUILD b/pkg/sentry/vfs/BUILD index e46acc3222..207c4313c6 100644 --- a/pkg/sentry/vfs/BUILD +++ b/pkg/sentry/vfs/BUILD @@ -163,6 +163,7 @@ go_library( "options.go", "pathname.go", "permissions.go", + "posix_acl.go", "propagation.go", "resolving_path.go", "save_restore.go", @@ -212,12 +213,14 @@ go_test( srcs = [ "file_description_impl_util_test.go", "mount_test.go", + "posix_acl_test.go", ], library = ":vfs", deps = [ "//pkg/abi/linux", "//pkg/atomicbitops", "//pkg/context", + "//pkg/errors", "//pkg/errors/linuxerr", "//pkg/sentry/contexttest", "//pkg/sentry/kernel/auth", diff --git a/pkg/sentry/vfs/anonfs.go b/pkg/sentry/vfs/anonfs.go index f42b7f7464..b7d5e80a1e 100644 --- a/pkg/sentry/vfs/anonfs.go +++ b/pkg/sentry/vfs/anonfs.go @@ -108,7 +108,7 @@ func (fs *anonFilesystem) AccessAt(ctx context.Context, rp *ResolvingPath, creds if !rp.Done() || rp.MustBeDir() { return linuxerr.ENOTDIR } - return GenericCheckPermissions(creds, ats, anonFileMode, anonFileUID, anonFileGID) + return GenericCheckPermissions(creds, ats, anonFileMode, nil, anonFileUID, anonFileGID) } // GetDentryAt implements FilesystemImpl.GetDentryAt. @@ -252,7 +252,7 @@ func (fs *anonFilesystem) BoundEndpointAt(ctx context.Context, rp *ResolvingPath if !rp.Final() { return nil, linuxerr.ENOTDIR } - if err := GenericCheckPermissions(rp.Credentials(), MayWrite, anonFileMode, anonFileUID, anonFileGID); err != nil { + if err := GenericCheckPermissions(rp.Credentials(), MayWrite, anonFileMode, nil, anonFileUID, anonFileGID); err != nil { return nil, err } return nil, linuxerr.ECONNREFUSED diff --git a/pkg/sentry/vfs/permissions.go b/pkg/sentry/vfs/permissions.go index b69562d5a0..38df0e3558 100644 --- a/pkg/sentry/vfs/permissions.go +++ b/pkg/sentry/vfs/permissions.go @@ -57,20 +57,31 @@ func (a AccessTypes) MayExec() bool { return a&MayExec != 0 } +func (a AccessTypes) checkPerms(perms AccessTypes) bool { + return uint16(a)&uint16(perms) == uint16(a) +} + // GenericCheckPermissions checks that creds has the given access rights on a // file with the given permissions, UID, and GID, subject to the rules of // fs/namei.c:generic_permission(). -func GenericCheckPermissions(creds *auth.Credentials, ats AccessTypes, mode linux.FileMode, kuid auth.KUID, kgid auth.KGID) error { - // Check permission bits. - perms := uint16(mode.Permissions()) - if creds.EffectiveKUID == kuid { - perms >>= 6 - } else if creds.InGroup(kgid) { - perms >>= 3 - } - if uint16(ats)&perms == uint16(ats) { - // All permission bits match, access granted. - return nil +func GenericCheckPermissions(creds *auth.Credentials, ats AccessTypes, mode linux.FileMode, acl *PosixACL, kuid auth.KUID, kgid auth.KGID) error { + if acl != nil { + // Check against the ACL (if present) + if acl.checkPermissions(creds, ats, kuid, kgid) { + return nil + } + } else { + // Fallback: check permission bits. + perms := uint16(mode.Permissions()) + if creds.EffectiveKUID == kuid { + perms >>= 6 + } else if creds.InGroup(kgid) { + perms >>= 3 + } + if uint16(ats)&perms == uint16(ats) { + // All permission bits match, access granted. + return nil + } } // CAP_DAC_READ_SEARCH allows the caller to read and search arbitrary @@ -95,7 +106,7 @@ func GenericCheckPermissions(creds *auth.Credentials, ats AccessTypes, mode linu // mode, kuid, and kgid is permitted. // // This corresponds to Linux's fs/namei.c:may_linkat. -func MayLink(creds *auth.Credentials, mode linux.FileMode, kuid auth.KUID, kgid auth.KGID) error { +func MayLink(creds *auth.Credentials, mode linux.FileMode, acl *PosixACL, kuid auth.KUID, kgid auth.KGID) error { // Source inode owner can hardlink all they like; otherwise, it must be a // safe source. if CanActAsOwner(creds, kuid) { @@ -116,7 +127,7 @@ func MayLink(creds *auth.Credentials, mode linux.FileMode, kuid auth.KUID, kgid // don't support S_IXGRP anyway. // Hardlinking to unreadable or unwritable sources is dangerous. - if err := GenericCheckPermissions(creds, MayRead|MayWrite, mode, kuid, kgid); err != nil { + if err := GenericCheckPermissions(creds, MayRead|MayWrite, mode, acl, kuid, kgid); err != nil { return linuxerr.EPERM } return nil @@ -180,7 +191,7 @@ func MayWriteFileWithOpenFlags(flags uint32) bool { // CheckSetStat checks that creds has permission to change the metadata of a // file with the given permissions, UID, and GID as specified by stat, subject // to the rules of Linux's fs/attr.c:setattr_prepare(). Might mutate `opts`. -func CheckSetStat(ctx context.Context, creds *auth.Credentials, opts *SetStatOptions, mode linux.FileMode, kuid auth.KUID, kgid auth.KGID) error { +func CheckSetStat(ctx context.Context, creds *auth.Credentials, opts *SetStatOptions, mode linux.FileMode, acl *PosixACL, kuid auth.KUID, kgid auth.KGID) error { stat := &opts.Stat if stat.Mask&linux.STATX_SIZE != 0 { limit, err := CheckLimit(ctx, 0, int64(stat.Size)) @@ -216,7 +227,7 @@ func CheckSetStat(ctx context.Context, creds *auth.Credentials, opts *SetStatOpt } } if opts.NeedWritePerm { - if err := GenericCheckPermissions(creds, MayWrite, mode, kuid, kgid); err != nil { + if err := GenericCheckPermissions(creds, MayWrite, mode, acl, kuid, kgid); err != nil { return err } } @@ -227,7 +238,7 @@ func CheckSetStat(ctx context.Context, creds *auth.Credentials, opts *SetStatOpt (stat.Mask&linux.STATX_CTIME != 0 && stat.Ctime.Nsec != linux.UTIME_NOW) { return linuxerr.EPERM } - if err := GenericCheckPermissions(creds, MayWrite, mode, kuid, kgid); err != nil { + if err := GenericCheckPermissions(creds, MayWrite, mode, acl, kuid, kgid); err != nil { return err } } diff --git a/pkg/sentry/vfs/posix_acl.go b/pkg/sentry/vfs/posix_acl.go new file mode 100644 index 0000000000..1161d2bac8 --- /dev/null +++ b/pkg/sentry/vfs/posix_acl.go @@ -0,0 +1,390 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vfs + +import ( + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/errors/linuxerr" + "gvisor.dev/gvisor/pkg/sentry/kernel/auth" +) + +// ACLUser represents a named ACL user. +type ACLUser struct { + // UID is the named user. + UID auth.KUID + + // Perms is the permissions granted to the named user. + Perms AccessTypes +} + +// ACLGroup represents a named ACL group. +type ACLGroup struct { + // GID is the named group. + GID auth.KGID + + // Perms is the permissions granted to the named group. + Perms AccessTypes +} + +// PosixACL represents a POSIX ACL, analogous to Linux's struct posix_acl. +// +// +stateify savable +type PosixACL struct { + // UGOPerms represent the ACL_USER_OBJ, ACL_GROUP_OBJ, and ACL_OTHER permissions + // as specified in the ACL (all of which are required). + UGOPerms uint16 + + // Mask contains the ACL_MASK field as specified in the ACL (or nil if not present). + Mask *AccessTypes + + // Users is an immutable list of named users in the ACL. + Users []ACLUser + + // Groups is an immutable list of named groups in the ACL. + Groups []ACLGroup +} + +func (a *PosixACL) masked(perms AccessTypes) AccessTypes { + if a.Mask == nil { + return perms + } + return perms & *a.Mask +} + +// UserPerms returns the permissions granted to the owning user by the ACL. +func (a *PosixACL) UserPerms() AccessTypes { + return AccessTypes((a.UGOPerms & linux.ModeUserAll) >> 6) +} + +// UserPerms returns the permissions granted to the owning group (without +// consideration for the mask) by the ACL. +func (a *PosixACL) GroupPerms() AccessTypes { + return AccessTypes((a.UGOPerms & linux.ModeGroupAll) >> 3) +} + +// OtherPerms returns the permissions granted to "other" by the ACL. +func (a *PosixACL) OtherPerms() AccessTypes { + return AccessTypes(a.UGOPerms & linux.ModeOtherAll) +} + +// Chmod returns a new PosixACL updated based on the new mode's permission bits. +// +// It is roughly analogous to Linux's fs/posix_acl.c:posix_acl_chmod(). +func (a *PosixACL) Chmod(mode uint16) PosixACL { + new := *a + + // User/Other come directly from the new mode + new.UGOPerms = (new.UGOPerms &^ linux.ModeUserAll) | (mode & linux.ModeUserAll) + new.UGOPerms = (new.UGOPerms &^ linux.ModeOtherAll) | (mode & linux.ModeOtherAll) + + // Group updates the mask if present + if new.Mask != nil { + newMask := AccessTypes((mode & linux.ModeGroupAll) >> 3) + new.Mask = &newMask + } else { + new.UGOPerms = (new.UGOPerms &^ linux.ModeGroupAll) | (mode & linux.ModeGroupAll) + } + + return new +} + +// MaskNewFileMode returns a PosixACL adjusted with the open mode when creating a new file. +// +// It is roughly analogous to Linux's fs/posix_acl.c:posix_acl_create(). +func (a *PosixACL) MaskNewFileMode(mode uint16) PosixACL { + newACL := *a + + // User + newACL.UGOPerms &= (mode & linux.ModeUserAll) | (newACL.UGOPerms &^ linux.ModeUserAll) + // Other + newACL.UGOPerms &= (mode & linux.ModeOtherAll) | (newACL.UGOPerms &^ linux.ModeOtherAll) + + if a.Mask != nil { + // Mask + newMask := *newACL.Mask + newMask &= AccessTypes((mode & linux.ModeGroupAll) >> 3) + newACL.Mask = &newMask + } else { + // Group + newACL.UGOPerms &= (mode & linux.ModeGroupAll) | (newACL.UGOPerms &^ linux.ModeGroupAll) + } + + return newACL +} + +// Mode returns the userspace-facing permission bits of the mode from the ACL, +// along with a bool indicating whether the ACL is fully equivalent to the mode +// (in other words, storing the ACL is not necessary). +// +// It is analogous to fs/posix_acl.c:posix_acl_equiv_mode() in Linux. +func (a *PosixACL) Mode() (uint16, bool) { + mode := a.UGOPerms + if a.Mask != nil { + // The mask, if present, appears to userspace as the group bits + mode = (mode &^ linux.ModeGroupAll) | (uint16(*a.Mask) << 3) + } + + equiv := len(a.Users) == 0 && len(a.Groups) == 0 && a.Mask == nil + + return mode, equiv +} + +// checkUserWithMask is used to check named users as part of the ACL access check algorithm. +// +// found indicates whether a named user determines the permissions for this check. +// passes indicates whether the permission check succeeds. +func (a *PosixACL) checkUserWithMask(creds *auth.Credentials, ats AccessTypes) (found bool, passes bool) { + for _, user := range a.Users { + if user.UID == creds.EffectiveKUID { + found = true + if ats.checkPerms(a.masked(user.Perms)) { + passes = true + } + break + } + } + return +} + +// checkGroupsWithMask is used to check named groups as part of the ACL access check algorithm. +// +// found indicates whether a named group determines the permissions for this check. +// passes indicates whether the permission check succeeds. +func (a *PosixACL) checkGroupsWithMask(creds *auth.Credentials, ats AccessTypes) (found bool, passes bool) { + for _, group := range a.Groups { + if creds.InGroup(group.GID) { + found = true + if ats.checkPerms(a.masked(group.Perms)) { + passes = true + break + } + } + } + return +} + +// checkPermissions implements the ACL portion of the access check algorithm as described in acl(5). +func (a *PosixACL) checkPermissions(creds *auth.Credentials, ats AccessTypes, kuid auth.KUID, kgid auth.KGID) bool { + if creds.EffectiveKUID == kuid { + // First, are we the owning user? + if ats.checkPerms(a.UserPerms()) { + return true + } + } else if found, passes := a.checkUserWithMask(creds, ats); found { + // If we're not the owning user, are we a named user? + // If so, check against the permissions specified there. + if passes { + return true + } + } else if found, passes := a.checkGroupsWithMask(creds, ats); creds.InGroup(kgid) || found { + // Otherwise, are we *either* in the owning group or a named group? + // If so, if any group grants access, allow the access. + if creds.InGroup(kgid) && ats.checkPerms(a.masked(a.GroupPerms())) { + return true + } + if found && passes { + return true + } + } else if ats.checkPerms(a.OtherPerms()) { + // Otherwise, use the permissions granted to other. + return true + } + + return false +} + +// Serialize returns the userspace xattr representation of the specified PosixACL. +// Specifying a user namespace is required since UIDs and GIDs for named users in +// the ACL's xattr representation are transformed into the process's user namespace. +func (a *PosixACL) Serialize(userns *auth.UserNamespace) []byte { + // Version header + ret := linux.PosixACLXattr{ + Version: linux.POSIX_ACL_XATTR_VERSION, + Entries: make([]linux.PosixACLXattrEntry, 0), + } + + // Owning user's permissions + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_USER_OBJ, + Perm: uint16(a.UserPerms()), + ID: linux.ACL_UNDEFINED_ID, + }) + + // Named users + for _, user := range a.Users { + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_USER, + Perm: uint16(user.Perms), + ID: uint32(userns.MapFromKUID(user.UID)), + }) + } + + // Owning group's permissions + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_GROUP_OBJ, + Perm: uint16(a.GroupPerms()), + ID: linux.ACL_UNDEFINED_ID, + }) + + // Named groups + for _, group := range a.Groups { + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_GROUP, + Perm: uint16(group.Perms), + ID: uint32(userns.MapFromKGID(group.GID)), + }) + } + + // Mask + if a.Mask != nil { + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_MASK, + Perm: uint16(*a.Mask), + ID: linux.ACL_UNDEFINED_ID, + }) + } + + // Other permissions + ret.Entries = append(ret.Entries, linux.PosixACLXattrEntry{ + Tag: linux.ACL_OTHER, + Perm: uint16(a.OtherPerms()), + ID: linux.ACL_UNDEFINED_ID, + }) + + buf := make([]byte, ret.SizeBytes()) + ret.MarshalBytes(buf) + + return buf +} + +// ParsePosixACL parses a userspace-specified xattr into a Posix ACL. +func ParsePosixACL(src []byte, userns *auth.UserNamespace) (PosixACL, error) { + // First, parse into a Linux.PosixACLXattr (the uabi type). + if len(src) < (&linux.PosixACLXattr{}).SizeBytes() { + // Header missing + return PosixACL{}, linuxerr.EINVAL + } + acl := &linux.PosixACLXattr{} + if remaining := acl.UnmarshalBytes(src); len(remaining) != 0 { + // Should contain a whole number of entries + return PosixACL{}, linuxerr.EINVAL + } + + if acl.Version != linux.POSIX_ACL_XATTR_VERSION { + // Version was incorrect + return PosixACL{}, linuxerr.EOPNOTSUPP + } + + // Now, take a look at the entries. + var userObj, groupObj, other, mask *uint16 + users := make([]ACLUser, 0) + groups := make([]ACLGroup, 0) + seenKUIDs := make(map[auth.KUID]struct{}) + seenKGIDs := make(map[auth.KGID]struct{}) + for _, entry := range acl.Entries { + if entry.Perm&^(linux.ACL_READ|linux.ACL_WRITE|linux.ACL_EXECUTE) != 0 { + // Perm must always be a valid set of permission bits + return PosixACL{}, linuxerr.EINVAL + } + + switch entry.Tag { + case linux.ACL_USER_OBJ: + if userObj != nil { + // Only one ACL_USER_OBJ allowed + return PosixACL{}, linuxerr.EINVAL + } + userObj = &entry.Perm + case linux.ACL_GROUP_OBJ: + if groupObj != nil { + // Only one ACL_GROUP_OBJ allowed + return PosixACL{}, linuxerr.EINVAL + } + groupObj = &entry.Perm + case linux.ACL_OTHER: + if other != nil { + // Only one ACL_OTHER allowed + return PosixACL{}, linuxerr.EINVAL + } + other = &entry.Perm + case linux.ACL_USER: + kuid := userns.MapToKUID(auth.UID(entry.ID)) + if !kuid.Ok() { + return PosixACL{}, linuxerr.EINVAL + } + + if _, ok := seenKUIDs[kuid]; ok { + // UIDs must be unique + return PosixACL{}, linuxerr.EINVAL + } + seenKUIDs[kuid] = struct{}{} + + aclUser := ACLUser{ + UID: kuid, + Perms: AccessTypes(entry.Perm), + } + users = append(users, aclUser) + case linux.ACL_GROUP: + kgid := userns.MapToKGID(auth.GID(entry.ID)) + if !kgid.Ok() { + return PosixACL{}, linuxerr.EINVAL + } + + if _, ok := seenKGIDs[kgid]; ok { + // GIDs must be unique + return PosixACL{}, linuxerr.EINVAL + } + seenKGIDs[kgid] = struct{}{} + + aclGroup := ACLGroup{ + GID: kgid, + Perms: AccessTypes(entry.Perm), + } + groups = append(groups, aclGroup) + case linux.ACL_MASK: + if mask != nil { + // Only one ACL_MASK allowed + return PosixACL{}, linuxerr.EINVAL + } + mask = &entry.Perm + default: + // Unknown tag + return PosixACL{}, linuxerr.EINVAL + } + } + + // A valid ACL: + // (a) must contain no *fewer* than one entry each of ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER + if userObj == nil || groupObj == nil || other == nil { + return PosixACL{}, linuxerr.EINVAL + } + // (b) must contain no *more* than one entry each of ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER + // (checked in the loop) + // (c) if named users or groups are present, no *fewer* than one ACL_MASK must be present + if (len(users) > 0 || len(groups) > 0) && mask == nil { + return PosixACL{}, linuxerr.EINVAL + } + // (d) no *more* than one ACL_MASK must be present + // (checked in the loop) + // (e) must have unique UIDs and GIDs for named users and groups + // (checked in the loop) + + ret := PosixACL{ + UGOPerms: (*userObj << 6) | (*groupObj << 3) | *other, + Mask: (*AccessTypes)(mask), + Users: users, + Groups: groups, + } + return ret, nil +} diff --git a/pkg/sentry/vfs/posix_acl_test.go b/pkg/sentry/vfs/posix_acl_test.go new file mode 100644 index 0000000000..6f7ebcf6ee --- /dev/null +++ b/pkg/sentry/vfs/posix_acl_test.go @@ -0,0 +1,350 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vfs + +import ( + "bytes" + "testing" + + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/errors" + "gvisor.dev/gvisor/pkg/errors/linuxerr" + "gvisor.dev/gvisor/pkg/sentry/kernel/auth" +) + +// Permission bit shorthands. +const ( + permR = linux.ACL_READ + permW = linux.ACL_WRITE + permX = linux.ACL_EXECUTE + permRW = permR | permW + permRX = permR | permX + permRWX = permR | permW | permX +) + +const aclUndef = uint32(linux.ACL_UNDEFINED_ID) + +// aclEntry builds a single on-wire POSIX ACL entry. +func aclEntry(tag, perm uint16, id uint32) linux.PosixACLXattrEntry { + return linux.PosixACLXattrEntry{Tag: tag, Perm: perm, ID: id} +} + +// marshalACLXattr builds the userspace representation of a +// POSIX ACL. +func marshalACLXattr(version uint32, entries ...linux.PosixACLXattrEntry) []byte { + x := linux.PosixACLXattr{Version: version, Entries: entries} + buf := make([]byte, x.SizeBytes()) + x.MarshalBytes(buf) + return buf +} + +// aclMask returns p as a pointer. +func aclMask(p AccessTypes) *AccessTypes { return &p } + +// aclEqual reports whether two PosixACLs are equal. +func aclEqual(a, b PosixACL) bool { + if a.UGOPerms != b.UGOPerms { + return false + } + if (a.Mask == nil) != (b.Mask == nil) { + return false + } + if a.Mask != nil && *a.Mask != *b.Mask { + return false + } + if len(a.Users) != len(b.Users) { + return false + } + for i := range a.Users { + if a.Users[i] != b.Users[i] { + return false + } + } + if len(a.Groups) != len(b.Groups) { + return false + } + for i := range a.Groups { + if a.Groups[i] != b.Groups[i] { + return false + } + } + return true +} + +func TestParsePosixACL(t *testing.T) { + ns := auth.NewRootUserNamespace() + for _, tc := range []struct { + name string + src []byte + // wantErr is the expected error; nil means success, in which case want + // is compared against the parsed result. + wantErr *errors.Error + want PosixACL + }{ + { + name: "minimal", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + want: PosixACL{UGOPerms: 0o644}, + }, + { + name: "named user with mask", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER, permR, 982), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_MASK, permRW, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + want: PosixACL{ + UGOPerms: 0o644, + Mask: aclMask(permRW), + Users: []ACLUser{{UID: 982, Perms: permR}}, + }, + }, + { + name: "named group with mask", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRWX, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permRX, aclUndef), + aclEntry(linux.ACL_GROUP, permRWX, 100), + aclEntry(linux.ACL_MASK, permRX, aclUndef), + aclEntry(linux.ACL_OTHER, 0, aclUndef), + ), + want: PosixACL{ + UGOPerms: 0o750, + Mask: aclMask(permRX), + Groups: []ACLGroup{{GID: 100, Perms: permRWX}}, + }, + }, + { + name: "too short for header", + src: []byte{0x02, 0x00, 0x00}, + wantErr: linuxerr.EINVAL, + }, + { + name: "unsupported version", + src: marshalACLXattr(1, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EOPNOTSUPP, + }, + { + name: "trailing partial entry", + src: append(marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), 0xff), + wantErr: linuxerr.EINVAL, + }, + { + name: "header only, no entries", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION), + wantErr: linuxerr.EINVAL, + }, + { + name: "missing USER_OBJ", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "missing GROUP_OBJ", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "missing OTHER", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "duplicate USER_OBJ", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER_OBJ, permR, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "duplicate MASK", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER, permR, 982), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_MASK, permRW, aclUndef), + aclEntry(linux.ACL_MASK, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "named user without mask", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER, permR, 982), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "invalid permission bits", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, 0x08, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "unknown tag", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + aclEntry(0x40, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + { + name: "duplicate named user", + src: marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER, permR, 982), + aclEntry(linux.ACL_USER, permW, 982), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_MASK, permRW, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ), + wantErr: linuxerr.EINVAL, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePosixACL(tc.src, ns) + if tc.wantErr != nil { + if !linuxerr.Equals(tc.wantErr, err) { + t.Fatalf("ParsePosixACL: got error %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("ParsePosixACL: unexpected error: %v", err) + } + if !aclEqual(got, tc.want) { + t.Errorf("ParsePosixACL = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestSerializePosixACL(t *testing.T) { + ns := auth.NewRootUserNamespace() + acl := PosixACL{ + UGOPerms: 0o644, + Mask: aclMask(permRW), + Users: []ACLUser{{UID: 982, Perms: permR}}, + } + // Serialize emits entries in canonical order: USER_OBJ, named users, + // GROUP_OBJ, named groups, MASK, OTHER; base entries carry + // ACL_UNDEFINED_ID, matching fs/posix_acl.c:posix_acl_to_xattr(). + want := marshalACLXattr(linux.POSIX_ACL_XATTR_VERSION, + aclEntry(linux.ACL_USER_OBJ, permRW, aclUndef), + aclEntry(linux.ACL_USER, permR, 982), + aclEntry(linux.ACL_GROUP_OBJ, permR, aclUndef), + aclEntry(linux.ACL_MASK, permRW, aclUndef), + aclEntry(linux.ACL_OTHER, permR, aclUndef), + ) + if got := acl.Serialize(ns); !bytes.Equal(got, want) { + t.Errorf("Serialize = %x, want %x", got, want) + } +} + +func TestPosixACLRoundTrip(t *testing.T) { + ns := auth.NewRootUserNamespace() + for _, acl := range []PosixACL{ + {UGOPerms: 0o640}, + {UGOPerms: 0o644, Mask: aclMask(permRWX), Users: []ACLUser{{UID: 982, Perms: permR}}}, + {UGOPerms: 0o600, Mask: aclMask(permRX), Groups: []ACLGroup{{GID: 100, Perms: permRX}}}, + { + UGOPerms: 0o750, + Mask: aclMask(permRW), + Users: []ACLUser{{UID: 1000, Perms: permRWX}, {UID: 1001, Perms: permR}}, + Groups: []ACLGroup{{GID: 50, Perms: permR}}, + }, + } { + got, err := ParsePosixACL(acl.Serialize(ns), ns) + if err != nil { + t.Fatalf("round-trip ParsePosixACL(Serialize(%+v)): unexpected error: %v", acl, err) + } + if !aclEqual(got, acl) { + t.Errorf("round-trip: got %+v, want %+v", got, acl) + } + } +} + +// TestPosixACLModeEquivalence checks Mode(), which reports the userspace-facing +// mode bits (mask surfaced as the group bits) and whether the ACL is fully +// representable by the mode alone. +func TestPosixACLModeEquivalence(t *testing.T) { + for _, tc := range []struct { + name string + acl PosixACL + wantMode uint16 + wantEquiv bool + }{ + { + name: "minimal is equivalent", + acl: PosixACL{UGOPerms: 0o751}, + wantMode: 0o751, + wantEquiv: true, + }, + { + name: "mask surfaces as group bits", + acl: PosixACL{UGOPerms: 0o644, Mask: aclMask(permRWX)}, + wantMode: 0o674, + wantEquiv: false, + }, + { + name: "named user is not equivalent", + acl: PosixACL{UGOPerms: 0o644, Mask: aclMask(permR), Users: []ACLUser{{UID: 1, Perms: permR}}}, + wantMode: 0o644, + wantEquiv: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mode, equiv := tc.acl.Mode() + if mode != tc.wantMode || equiv != tc.wantEquiv { + t.Errorf("Mode() = (%#o, %t), want (%#o, %t)", mode, equiv, tc.wantMode, tc.wantEquiv) + } + }) + } +} diff --git a/runsc/container/container_test.go b/runsc/container/container_test.go index 8b282c4fe0..fb74661615 100644 --- a/runsc/container/container_test.go +++ b/runsc/container/container_test.go @@ -17,6 +17,7 @@ package container import ( "bufio" "bytes" + "encoding/base64" "fmt" "io" "math" @@ -36,6 +37,7 @@ import ( "github.com/cenkalti/backoff" specs "github.com/opencontainers/runtime-spec/specs-go" "golang.org/x/sys/unix" + "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/cleanup" "gvisor.dev/gvisor/pkg/hostos" @@ -5283,6 +5285,160 @@ func TestTarRootfsUpperLayerOpaqueDir(t *testing.T) { t.Logf("/usr/share in restored container correctly contains only: %v", restoredFiles) } +// buildACLXattr serializes entries into the system.posix_acl_access / +// system.posix_acl_default xattr wire format (a little-endian version header +// followed by 8-byte entries). +func buildACLXattr(entries []linux.PosixACLXattrEntry) []byte { + x := linux.PosixACLXattr{Version: linux.POSIX_ACL_XATTR_VERSION, Entries: entries} + buf := make([]byte, x.SizeBytes()) + x.MarshalBytes(buf) + return buf +} + +// TestTarRootfsUpperLayerACL verifies that POSIX ACLs set on files in the overlay's +// tmpfs layer are preserved across tar serialization and restoration. +func TestTarRootfsUpperLayerACL(t *testing.T) { + conf := testutil.TestConfig(t) + conf.Overlay2.Set("root:memory") + + spec, _ := sleepSpecConf(t) + spec.Root.Readonly = false + + app, err := testutil.FindFile("test/cmd/test_app/test_app") + if err != nil { + t.Fatalf("error finding test_app: %v", err) + } + + _, bundleDir, cleanup, err := testutil.SetupContainer(spec, conf) + if err != nil { + t.Fatalf("error setting up container: %v", err) + } + defer cleanup() + + // Create and start the container. + args := Args{ + ID: testutil.RandomContainerID(), + Spec: spec, + BundleDir: bundleDir, + } + cont, err := New(conf, args) + if err != nil { + t.Fatalf("error creating container: %v", err) + } + defer cont.Destroy() + if err := cont.Start(conf); err != nil { + t.Fatalf("error starting container: %v", err) + } + + // Paths in the writable (upper) layer that will receive ACLs. + const ( + aclFile = "/acltest-file" + aclDir = "/acltest-dir" + ) + + // Use ACLs with named users to ensure that the ACL is actually stored as an ACL + // rather than folded into the mode + accessACL := base64.StdEncoding.EncodeToString(buildACLXattr([]linux.PosixACLXattrEntry{ + {Tag: linux.ACL_USER_OBJ, Perm: linux.ACL_READ | linux.ACL_WRITE, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_USER, Perm: linux.ACL_READ, ID: 1000}, + {Tag: linux.ACL_GROUP_OBJ, Perm: linux.ACL_READ, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_MASK, Perm: linux.ACL_READ | linux.ACL_WRITE, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_OTHER, Perm: linux.ACL_READ, ID: linux.ACL_UNDEFINED_ID}, + })) + defaultACL := base64.StdEncoding.EncodeToString(buildACLXattr([]linux.PosixACLXattrEntry{ + {Tag: linux.ACL_USER_OBJ, Perm: linux.ACL_READ | linux.ACL_WRITE | linux.ACL_EXECUTE, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_USER, Perm: linux.ACL_READ | linux.ACL_EXECUTE, ID: 1001}, + {Tag: linux.ACL_GROUP_OBJ, Perm: linux.ACL_READ | linux.ACL_EXECUTE, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_MASK, Perm: linux.ACL_READ | linux.ACL_WRITE | linux.ACL_EXECUTE, ID: linux.ACL_UNDEFINED_ID}, + {Tag: linux.ACL_OTHER, Perm: 0, ID: linux.ACL_UNDEFINED_ID}, + })) + + // Create the file and directory in the writable (upper) layer. + if out, err := executeCombinedOutput(conf, cont, nil, "/bin/sh", "-c", + fmt.Sprintf("touch %s && mkdir %s", aclFile, aclDir)); err != nil { + t.Fatalf("error creating ACL test files: %v, output: %s", err, out) + } + + // setACL sets an ACL xattr on path via test_app. + setACL := func(path, name, b64 string) { + t.Helper() + if out, err := executeCombinedOutput(conf, cont, nil, app, "setxattr", + "--path="+path, "--name="+name, "--value="+b64); err != nil { + t.Fatalf("error setting %s on %s: %v, output: %s", name, path, err, out) + } + } + // getACL returns the base64-encoded ACL xattr on path in container c. + getACL := func(c *Container, path, name string) string { + t.Helper() + out, err := executeCombinedOutput(conf, c, nil, app, "getxattr", + "--path="+path, "--name="+name) + if err != nil { + t.Fatalf("error getting %s on %s: %v, output: %s", name, path, err, out) + } + return strings.TrimSpace(string(out)) + } + + setACL(aclFile, linux.XATTR_NAME_POSIX_ACL_ACCESS, accessACL) + wantFileAccess := getACL(cont, aclFile, linux.XATTR_NAME_POSIX_ACL_ACCESS) + + setACL(aclDir, linux.XATTR_NAME_POSIX_ACL_ACCESS, accessACL) + wantDirAccess := getACL(cont, aclDir, linux.XATTR_NAME_POSIX_ACL_ACCESS) + + setACL(aclDir, linux.XATTR_NAME_POSIX_ACL_DEFAULT, defaultACL) + wantDirDefault := getACL(cont, aclDir, linux.XATTR_NAME_POSIX_ACL_DEFAULT) + + for name, v := range map[string]string{"file access": wantFileAccess, "dir access": wantDirAccess, "dir default": wantDirDefault} { + if v == "" { + t.Fatalf("%s ACL was unexpectedly empty", name) + } + } + + // Tar the upper layer. + tarFile, err := os.CreateTemp(testutil.TmpDir(), "tarfile-acl-*.tar") + if err != nil { + t.Fatalf("error creating temp file: %v", err) + } + defer os.Remove(tarFile.Name()) + defer tarFile.Close() + if err := cont.TarRootfsUpperLayer(tarFile); err != nil { + t.Fatalf("error serializing rootfs upper layer to tar: %v", err) + } + + // Restore the tar into a new container. + spec.Annotations[specutils.AnnotationRootfsUpperTar] = tarFile.Name() + conf.AllowRootfsTarAnnotation = true + _, bundleDir2, cleanup2, err := testutil.SetupContainer(spec, conf) + if err != nil { + t.Fatalf("error setting up restored container: %v", err) + } + defer cleanup2() + + args2 := Args{ + ID: testutil.RandomContainerID(), + Spec: spec, + BundleDir: bundleDir2, + } + newCont, err := New(conf, args2) + if err != nil { + t.Fatalf("error creating restored container: %v", err) + } + defer newCont.Destroy() + if err := newCont.Start(conf); err != nil { + t.Fatalf("error starting restored container: %v", err) + } + + // Verify the ACLs survived the round-trip. + if got := getACL(newCont, aclFile, linux.XATTR_NAME_POSIX_ACL_ACCESS); got != wantFileAccess { + t.Errorf("file access ACL mismatch after restore:\n got %q\nwant %q", got, wantFileAccess) + } + if got := getACL(newCont, aclDir, linux.XATTR_NAME_POSIX_ACL_ACCESS); got != wantDirAccess { + t.Errorf("dir access ACL mismatch after restore:\n got %q\nwant %q", got, wantDirAccess) + } + if got := getACL(newCont, aclDir, linux.XATTR_NAME_POSIX_ACL_DEFAULT); got != wantDirDefault { + t.Errorf("dir default ACL mismatch after restore:\n got %q\nwant %q", got, wantDirDefault) + } +} + func TestSpecValidationIgnore(t *testing.T) { conf := testutil.TestConfig(t) if err := conf.RestoreSpecValidation.Set("ignore"); err != nil { diff --git a/test/cmd/test_app/BUILD b/test/cmd/test_app/BUILD index bfecf6c480..4b634565de 100644 --- a/test/cmd/test_app/BUILD +++ b/test/cmd/test_app/BUILD @@ -11,6 +11,7 @@ go_binary( srcs = [ "fds.go", "main.go", + "xattr.go", "zombies.go", ], features = ["fully_static_link"], diff --git a/test/cmd/test_app/main.go b/test/cmd/test_app/main.go index c9cdd4fc68..d2dda5a17f 100644 --- a/test/cmd/test_app/main.go +++ b/test/cmd/test_app/main.go @@ -60,6 +60,8 @@ func main() { subcommands.Register(new(uds), "") subcommands.Register(new(zombieTest), "") subcommands.Register(new(fsCheckpoint), "") + subcommands.Register(new(setXattr), "") + subcommands.Register(new(getXattr), "") flag.Parse() diff --git a/test/cmd/test_app/xattr.go b/test/cmd/test_app/xattr.go new file mode 100644 index 0000000000..33ea93d694 --- /dev/null +++ b/test/cmd/test_app/xattr.go @@ -0,0 +1,115 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/base64" + "fmt" + "log" + + "github.com/google/subcommands" + "golang.org/x/sys/unix" + + "gvisor.dev/gvisor/runsc/flag" +) + +const xattrSizeMax = 65536 + +// setXattr sets an extended attribute on a file. The value is passed as a +// base64-encoded string so that arbitrary binary blobs can be specified on +// the command line. +type setXattr struct { + path string + name string + value string +} + +// Name implements subcommands.Command.Name. +func (*setXattr) Name() string { return "setxattr" } + +// Synopsis implements subcommands.Command.Synopsis. +func (*setXattr) Synopsis() string { return "sets an extended attribute (value is base64-encoded)" } + +// Usage implements subcommands.Command.Usage. +func (*setXattr) Usage() string { + return "setxattr --path= --name= --value=\n" +} + +// SetFlags implements subcommands.Command.SetFlags. +func (c *setXattr) SetFlags(f *flag.FlagSet) { + f.StringVar(&c.path, "path", "", "path of the file to set the xattr on") + f.StringVar(&c.name, "name", "", "name of the xattr") + f.StringVar(&c.value, "value", "", "base64-encoded value of the xattr") +} + +// Execute implements subcommands.Command.Execute. +func (c *setXattr) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { + if c.path == "" || c.name == "" { + log.Print("--path and --name must be set") + return subcommands.ExitUsageError + } + value, err := base64.StdEncoding.DecodeString(c.value) + if err != nil { + log.Printf("failed to decode --value %q: %v", c.value, err) + return subcommands.ExitUsageError + } + if err := unix.Setxattr(c.path, c.name, value, 0); err != nil { + log.Printf("setxattr(%q, %q) failed: %v", c.path, c.name, err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess +} + +// getXattr prints the base64-encoded value of an extended attribute to stdout. +type getXattr struct { + path string + name string +} + +// Name implements subcommands.Command.Name. +func (*getXattr) Name() string { return "getxattr" } + +// Synopsis implements subcommands.Command.Synopsis. +func (*getXattr) Synopsis() string { + return "prints the base64-encoded value of an extended attribute to stdout" +} + +// Usage implements subcommands.Command.Usage. +func (*getXattr) Usage() string { + return "getxattr --path= --name=\n" +} + +// SetFlags implements subcommands.Command.SetFlags. +func (c *getXattr) SetFlags(f *flag.FlagSet) { + f.StringVar(&c.path, "path", "", "path of the file to get the xattr from") + f.StringVar(&c.name, "name", "", "name of the xattr") +} + +// Execute implements subcommands.Command.Execute. +func (c *getXattr) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { + if c.path == "" || c.name == "" { + log.Print("--path and --name must be set") + return subcommands.ExitUsageError + } + buf := make([]byte, xattrSizeMax) + n, err := unix.Getxattr(c.path, c.name, buf) + if err != nil { + log.Printf("getxattr(%q, %q) failed: %v", c.path, c.name, err) + return subcommands.ExitFailure + } + fmt.Println(base64.StdEncoding.EncodeToString(buf[:n])) + return subcommands.ExitSuccess +} diff --git a/test/syscalls/BUILD b/test/syscalls/BUILD index c7c3628714..7dd394ff00 100644 --- a/test/syscalls/BUILD +++ b/test/syscalls/BUILD @@ -1338,3 +1338,7 @@ syscall_test( add_overlay = True, test = "//test/syscalls/linux:xattr_test", ) + +syscall_test( + test = "//test/syscalls/linux:posix_acl_test", +) diff --git a/test/syscalls/linux/BUILD b/test/syscalls/linux/BUILD index 37f8d56828..2a4bfae248 100644 --- a/test/syscalls/linux/BUILD +++ b/test/syscalls/linux/BUILD @@ -4936,6 +4936,24 @@ cc_binary( ], ) +cc_binary( + name = "posix_acl_test", + testonly = 1, + srcs = ["posix_acl.cc"], + linkstatic = 1, + malloc = "//test/util:errno_safe_allocator", + deps = select_gtest() + [ + "//test/util:capability_util", + "//test/util:file_descriptor", + "//test/util:fs_util", + "//test/util:posix_error", + "//test/util:temp_path", + "//test/util:test_main", + "//test/util:test_util", + "//test/util:thread_util", + ], +) + cc_binary( name = "cgroup_test", testonly = 1, diff --git a/test/syscalls/linux/posix_acl.cc b/test/syscalls/linux/posix_acl.cc new file mode 100644 index 0000000000..8110c088a4 --- /dev/null +++ b/test/syscalls/linux/posix_acl.cc @@ -0,0 +1,464 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "test/util/file_descriptor.h" +#include "test/util/fs_util.h" +#include "test/util/linux_capability_util.h" +#include "test/util/posix_error.h" +#include "test/util/temp_path.h" +#include "test/util/test_util.h" +#include "test/util/thread_util.h" + +namespace gvisor { +namespace testing { + +namespace { + +// Extended attribute names for POSIX ACLs. +constexpr char kAccessACL[] = "system.posix_acl_access"; +constexpr char kDefaultACL[] = "system.posix_acl_default"; + +// POSIX ACL constants. +constexpr uint32_t kACLVersion = 2; +constexpr uint16_t kUserObj = 0x01; +constexpr uint16_t kUser = 0x02; +constexpr uint16_t kGroupObj = 0x04; +constexpr uint16_t kGroup = 0x08; +constexpr uint16_t kMask = 0x10; +constexpr uint16_t kOther = 0x20; +constexpr uint32_t kUndef = 0xffffffff; + +// Permission bits. +constexpr uint16_t kR = 0x04; +constexpr uint16_t kW = 0x02; +constexpr uint16_t kX = 0x01; + +constexpr uid_t kNobody = 65534; + +// ACLEntry mirrors struct posix_acl_xattr_entry. +struct ACLEntry { + uint16_t tag; + uint16_t perm; + uint32_t id; +}; +static_assert(sizeof(ACLEntry) == 8, "unexpected ACLEntry size"); + +ACLEntry Ent(int tag, int perm, uint32_t id) { + return ACLEntry{static_cast(tag), static_cast(perm), id}; +} + +// BuildACL builds the raw xattr representation for a POSIX ACL. +std::string BuildACL(const std::vector& entries) { + uint32_t version = kACLVersion; + std::string buf(reinterpret_cast(&version), sizeof(version)); + for (const ACLEntry& e : entries) { + buf.append(reinterpret_cast(&e), sizeof(e)); + } + return buf; +} + +// ParseACL parses a raw ACL value into its entries. +std::vector ParseACL(const std::string& blob) { + std::vector out; + for (size_t off = sizeof(uint32_t); off + sizeof(ACLEntry) <= blob.size(); + off += sizeof(ACLEntry)) { + ACLEntry e; + memcpy(&e, blob.data() + off, sizeof(e)); + out.push_back(e); + } + return out; +} + +// FindEntryPerm returns the permission of the first entry matching tag (and id +// for named user/group tags), or -1 if not found. +int FindEntryPerm(const std::string& blob, uint16_t tag, uint32_t id) { + for (const ACLEntry& e : ParseACL(blob)) { + if (e.tag != tag) continue; + if ((tag == kUser || tag == kGroup) && e.id != id) continue; + return e.perm; + } + return -1; +} + +// GetXattrString reads an xattr into a string. +PosixErrorOr GetXattrString(const std::string& path, + const char* name) { + char buf[512]; + int n = getxattr(path.c_str(), name, buf, sizeof(buf)); + if (n < 0) { + return PosixError(errno, "getxattr"); + } + return std::string(buf, n); +} + +// ListContainsName reports whether a listxattr(2) result contains name. +bool ListContainsName(const char* list, int len, const char* name) { + for (int i = 0; i < len; i += strlen(list + i) + 1) { + if (strcmp(list + i, name) == 0) { + return true; + } + } + return false; +} + +class PosixACLTest : public ::testing::Test { + protected: + void SetUp() override { + // Use /dev/shm to allow the native tests to run without privilege + dir_ = ASSERT_NO_ERRNO_AND_VALUE(TempPath::CreateDirIn("/dev/shm")); + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(IsTmpfs(dir_.path()))); + + file_ = JoinPath(dir_.path(), "posix_acl_test_file"); + ASSERT_NO_ERRNO_AND_VALUE(Open(file_, O_CREAT | O_RDWR, 0644)); + ASSERT_THAT(chmod(file_.c_str(), 0644), SyscallSucceeds()); + + subdir_ = JoinPath(dir_.path(), "subdir"); + ASSERT_THAT(mkdir(subdir_.c_str(), 0755), SyscallSucceeds()); + + uid_ = getuid(); + gid_ = getgid(); + } + + TempPath dir_; + std::string file_; + std::string subdir_; + uid_t uid_; + gid_t gid_; +}; + +// Setting an access ACL and reading it back returns an identical blob. +TEST_F(PosixACLTest, SetGetAccessACL) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR, uid_), + Ent(kGroupObj, kR, kUndef), + Ent(kMask, kR | kW, kUndef), + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + const std::string got = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(file_, kAccessACL)); + EXPECT_EQ(got, acl); +} + +// Setting an access ACL with a named-group entry round-trips, and the stored +// entry is keyed by group ID. +TEST_F(PosixACLTest, SetGetNamedGroupACL) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kGroupObj, kR, kUndef), + Ent(kGroup, kR | kW, gid_), + Ent(kMask, kR | kW, kUndef), + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + const std::string got = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(file_, kAccessACL)); + EXPECT_EQ(got, acl); + EXPECT_EQ(FindEntryPerm(got, kGroup, gid_), kR | kW); +} + +// getxattr reports the size when passed a zero-length buffer. +TEST_F(PosixACLTest, GetAccessACLSize) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR, uid_), + Ent(kGroupObj, kR, kUndef), + Ent(kMask, kR | kW, kUndef), + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + EXPECT_THAT(getxattr(file_.c_str(), kAccessACL, nullptr, 0), + SyscallSucceedsWithValue(acl.size())); +} + +// getxattr on a file with no ACL returns ENODATA. +TEST_F(PosixACLTest, GetAccessACLNoData) { + EXPECT_THAT(getxattr(file_.c_str(), kAccessACL, nullptr, 0), + SyscallFailsWithErrno(ENODATA)); +} + +// Setting an extended access ACL updates the file mode: the owner/other bits +// come from USER_OBJ/OTHER, and the group bits reflect the mask. +TEST_F(PosixACLTest, AccessACLUpdatesMode) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), // owner rw- + Ent(kUser, kR, uid_), // + Ent(kGroupObj, kR, kUndef), // group_obj r-- + Ent(kMask, kR | kW | kX, kUndef), // mask rwx (surfaces as group bits) + Ent(kOther, kR, kUndef), // other r-- + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + struct stat st = {}; + ASSERT_THAT(stat(file_.c_str(), &st), SyscallSucceeds()); + EXPECT_EQ(st.st_mode & 0777, 0674); +} + +// A minimal ACL (only the three base entries, no mask/named entries) is +// equivalent to a mode: it is folded into the mode and not stored. +TEST_F(PosixACLTest, MinimalAccessACLFoldsToMode) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW | kX, kUndef), + Ent(kGroupObj, kR | kX, kUndef), + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + struct stat st = {}; + ASSERT_THAT(stat(file_.c_str(), &st), SyscallSucceeds()); + EXPECT_EQ(st.st_mode & 0777, 0754); + + // No extended ACL is stored. + EXPECT_THAT(getxattr(file_.c_str(), kAccessACL, nullptr, 0), + SyscallFailsWithErrno(ENODATA)); +} + +// An extended access ACL appears in listxattr; removing it makes it disappear. +TEST_F(PosixACLTest, ListAndRemoveAccessACL) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR, uid_), + Ent(kGroupObj, kR, kUndef), + Ent(kMask, kR, kUndef), + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + char list[512]; + int n = listxattr(file_.c_str(), list, sizeof(list)); + ASSERT_THAT(n, SyscallSucceeds()); + EXPECT_TRUE(ListContainsName(list, n, kAccessACL)); + + ASSERT_THAT(removexattr(file_.c_str(), kAccessACL), SyscallSucceeds()); + EXPECT_THAT(getxattr(file_.c_str(), kAccessACL, nullptr, 0), + SyscallFailsWithErrno(ENODATA)); + + n = listxattr(file_.c_str(), list, sizeof(list)); + ASSERT_THAT(n, SyscallSucceeds()); + EXPECT_FALSE(ListContainsName(list, n, kAccessACL)); +} + +// A default ACL cannot be set on a non-directory. +TEST_F(PosixACLTest, DefaultACLOnFileFails) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kGroupObj, kR, kUndef), + Ent(kOther, kR, kUndef), + }); + EXPECT_THAT(setxattr(file_.c_str(), kDefaultACL, acl.data(), acl.size(), 0), + SyscallFailsWithErrno(EACCES)); +} + +// A default ACL can be set on and read back from a directory, and appears in +// listxattr. +TEST_F(PosixACLTest, SetGetDefaultACLOnDir) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW | kX, kUndef), + Ent(kUser, kR | kX, uid_), + Ent(kGroupObj, kR | kX, kUndef), + Ent(kMask, kR | kX, kUndef), + Ent(kOther, kR | kX, kUndef), + }); + ASSERT_THAT(setxattr(subdir_.c_str(), kDefaultACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + const std::string got = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(subdir_, kDefaultACL)); + EXPECT_EQ(got, acl); + + char list[512]; + int n = listxattr(subdir_.c_str(), list, sizeof(list)); + ASSERT_THAT(n, SyscallSucceeds()); + EXPECT_TRUE(ListContainsName(list, n, kDefaultACL)); +} + +// A file created in a directory with a default ACL inherits an access ACL +// derived from that default ACL. A subdirectory also inherits the default ACL. +TEST_F(PosixACLTest, DefaultACLInheritance) { + const std::string dacl = BuildACL({ + Ent(kUserObj, kR | kW | kX, kUndef), + Ent(kUser, kR | kX, uid_), + Ent(kGroupObj, kR | kX, kUndef), + Ent(kMask, kR | kW | kX, kUndef), + Ent(kOther, kR | kX, kUndef), + }); + ASSERT_THAT( + setxattr(subdir_.c_str(), kDefaultACL, dacl.data(), dacl.size(), 0), + SyscallSucceeds()); + + // A regular child inherits an access ACL that names the user, but no default + // ACL. + const std::string child = JoinPath(subdir_, "child"); + { + ASSERT_NO_ERRNO_AND_VALUE(Open(child, O_CREAT | O_RDWR, 0666)); + } + + const std::string cacl = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(child, kAccessACL)); + EXPECT_EQ(FindEntryPerm(cacl, kUser, uid_), kR | kX) + << "child access ACL should inherit the named user from the default ACL"; + EXPECT_THAT(getxattr(child.c_str(), kDefaultACL, nullptr, 0), + SyscallFailsWithErrno(ENODATA)); + + // A child directory inherits the default ACL verbatim as its own default ACL. + const std::string childdir = JoinPath(subdir_, "childdir"); + ASSERT_THAT(mkdir(childdir.c_str(), 0777), SyscallSucceeds()); + const std::string cdacl = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(childdir, kDefaultACL)); + EXPECT_EQ(cdacl, dacl); +} + +// chmod on a file with an extended ACL updates the mask (the group bits), not +// the GROUP_OBJ entry. +TEST_F(PosixACLTest, ChmodUpdatesMask) { + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR | kW, uid_), + Ent(kGroupObj, kR, kUndef), // group_obj r-- + Ent(kMask, kR, kUndef), // mask r-- + Ent(kOther, kR, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + // Give the group class read+write. This must move the mask to rw-, leaving + // GROUP_OBJ untouched. + ASSERT_THAT(chmod(file_.c_str(), 0664), SyscallSucceeds()); + + const std::string got = + ASSERT_NO_ERRNO_AND_VALUE(GetXattrString(file_, kAccessACL)); + EXPECT_EQ(FindEntryPerm(got, kMask, kUndef), kR | kW); + EXPECT_EQ(FindEntryPerm(got, kGroupObj, kUndef), kR); +} + +// A named-user ACL entry grants access that the mode bits alone would deny, and +// the mask caps that access. +TEST_F(PosixACLTest, NamedUserEnforcement) { + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SETUID))); + + // Without an ACL granting access, "nobody" cannot read a 0600 file. + ASSERT_THAT(chmod(file_.c_str(), 0600), SyscallSucceeds()); + ScopedThread([&] { + EXPECT_THAT(syscall(SYS_setuid, kNobody), SyscallSucceeds()); + EXPECT_THAT(open(file_.c_str(), O_RDONLY), SyscallFailsWithErrno(EACCES)); + }); + + // Grant "nobody" read via a named-user entry; the mask allows only read. + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR | kW | kX, kNobody), // capped by the mask + Ent(kGroupObj, 0, kUndef), + Ent(kMask, kR, kUndef), // mask r-- : nobody effectively gets r-- only + Ent(kOther, 0, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + ScopedThread([&] { + EXPECT_THAT(syscall(SYS_setuid, kNobody), SyscallSucceeds()); + // Read is granted by the ACL. + int rfd = open(file_.c_str(), O_RDONLY); + EXPECT_THAT(rfd, SyscallSucceeds()); + if (rfd >= 0) close(rfd); + // Write is denied because the mask limits nobody to read. + int wfd = open(file_.c_str(), O_WRONLY); + EXPECT_THAT(wfd, SyscallFailsWithErrno(EACCES)); + if (wfd >= 0) close(wfd); + }); +} + +// A named-group ACL entry grants access to a process in that group that the +// mode bits alone would deny, capped by the mask. +TEST_F(PosixACLTest, NamedGroupEnforcement) { + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SETUID))); + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SETGID))); + + // Grant group "nobody" read+write, but the mask caps the group class to read. + // Owner keeps rw; group_obj and other get nothing. + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kGroupObj, 0, kUndef), + Ent(kGroup, kR | kW, kNobody), // capped by the mask + Ent(kMask, kR, kUndef), // mask r-- : group class limited to read + Ent(kOther, 0, kUndef), + }); + ASSERT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallSucceeds()); + + ScopedThread([&] { + // Become a member of only the named group and a non-owner user. + EXPECT_THAT(syscall(SYS_setgroups, 0, nullptr), SyscallSucceeds()); + EXPECT_THAT(syscall(SYS_setgid, kNobody), SyscallSucceeds()); + EXPECT_THAT(syscall(SYS_setuid, kNobody), SyscallSucceeds()); + // Read is granted via the named group. + int rfd = open(file_.c_str(), O_RDONLY); + EXPECT_THAT(rfd, SyscallSucceeds()); + if (rfd >= 0) close(rfd); + // Write is denied due to the mask. + int wfd = open(file_.c_str(), O_WRONLY); + EXPECT_THAT(wfd, SyscallFailsWithErrno(EACCES)); + if (wfd >= 0) close(wfd); + }); +} + +// Only the file owner (or a suitably privileged process) may set an ACL, even +// with write permission on the file. +TEST_F(PosixACLTest, SetACLRequiresOwnership) { + SKIP_IF(!ASSERT_NO_ERRNO_AND_VALUE(HaveCapability(CAP_SETUID))); + + // World-writable file owned by the (root) test process. + ASSERT_THAT(chmod(file_.c_str(), 0666), SyscallSucceeds()); + const std::string acl = BuildACL({ + Ent(kUserObj, kR | kW, kUndef), + Ent(kUser, kR, kNobody), + Ent(kGroupObj, kR, kUndef), + Ent(kMask, kR, kUndef), + Ent(kOther, kR, kUndef), + }); + + ScopedThread([&] { + EXPECT_THAT(syscall(SYS_setuid, kNobody), SyscallSucceeds()); + EXPECT_THAT(setxattr(file_.c_str(), kAccessACL, acl.data(), acl.size(), 0), + SyscallFailsWithErrno(EPERM)); + }); +} + +} // namespace + +} // namespace testing +} // namespace gvisor