Introduce fsquota package

This introduces a new fsquota package and
a few low-level support packages to simplify the
management of filesystem quotas.

To expose an API that's nice to use while staying
performant and safe the new fsinfo syscall is being
used. Since that syscall is not yet in mainline it has
been backported to our 5.6 kernel.

Test Plan:
Manually validated on our kernel, automated
tests are pending some Bazel work to be able to run them
inside our own kernel.

X-Origin-Diff: phab/D462
GitOrigin-RevId: bb463056589d2b13b7cf32d48ab0b884e70b1bad
diff --git a/core/pkg/fsquota/BUILD.bazel b/core/pkg/fsquota/BUILD.bazel
new file mode 100644
index 0000000..6971929
--- /dev/null
+++ b/core/pkg/fsquota/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "fsinfo.go",
+        "fsquota.go",
+    ],
+    importpath = "git.monogon.dev/source/nexantic.git/core/pkg/fsquota",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//core/pkg/fsquota/fsxattrs:go_default_library",
+        "//core/pkg/fsquota/quotactl:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
+    ],
+)
diff --git a/core/pkg/fsquota/fsinfo.go b/core/pkg/fsquota/fsinfo.go
new file mode 100644
index 0000000..e40a533
--- /dev/null
+++ b/core/pkg/fsquota/fsinfo.go
@@ -0,0 +1,59 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 fsquota
+
+import (
+	"fmt"
+	"os"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+// This requires fsinfo() support, which is not yet in any stable kernel.
+// Our kernel has that syscall backported. This would otherwise be an extremely expensive
+// operation and also involve lots of logic from our side.
+
+// From syscall_64.tbl
+const sys_fsinfo = 441
+
+// From uapi/linux/fsinfo.h
+const fsinfo_attr_source = 0x09
+const fsinfo_flags_query_path = 0x0000
+const fsinfo_flags_query_fd = 0x0001
+
+type fsinfoParams struct {
+	resolveFlags uint64
+	atFlags      uint32
+	flags        uint32
+	request      uint32
+	nth          uint32
+	mth          uint32
+}
+
+func fsinfoGetSource(dir *os.File) (string, error) {
+	buf := make([]byte, 256)
+	params := fsinfoParams{
+		flags:   fsinfo_flags_query_fd,
+		request: fsinfo_attr_source,
+	}
+	n, _, err := unix.Syscall6(sys_fsinfo, dir.Fd(), 0, uintptr(unsafe.Pointer(&params)), unsafe.Sizeof(params), uintptr(unsafe.Pointer(&buf[0])), 128)
+	if err != unix.Errno(0) {
+		return "", fmt.Errorf("failed to call fsinfo: %w", err)
+	}
+	return string(buf[:n]), nil
+}
diff --git a/core/pkg/fsquota/fsquota.go b/core/pkg/fsquota/fsquota.go
new file mode 100644
index 0000000..f4f4050
--- /dev/null
+++ b/core/pkg/fsquota/fsquota.go
@@ -0,0 +1,145 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 fsquota provides a simplified interface to interact with Linux's filesystem qouta API.
+// It only supports setting quotas on directories, not groups or users.
+// Quotas need to be already enabled on the filesystem to be able to use them using this package.
+// See the quotactl package if you intend to use this on a filesystem where quotas need to be
+// enabled manually.
+package fsquota
+
+import (
+	"fmt"
+	"math"
+	"os"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/fsquota/fsxattrs"
+	"git.monogon.dev/source/nexantic.git/core/pkg/fsquota/quotactl"
+	"golang.org/x/sys/unix"
+)
+
+// SetQuota sets the quota of bytes and/or inodes in a given path. To not set a limit, set the
+// corresponding argument to zero. Setting both arguments to zero removes the quota entirely.
+// This function can only be called on an empty directory. It can't be used to create a quota
+// below a directory which already has a quota since Linux doesn't offer hierarchical quotas.
+func SetQuota(path string, maxBytes uint64, maxInodes uint64) error {
+	dir, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer dir.Close()
+	source, err := fsinfoGetSource(dir)
+	if err != nil {
+		return err
+	}
+	var valid uint32
+	if maxBytes > 0 {
+		valid |= quotactl.FlagBLimitsValid
+	}
+	if maxInodes > 0 {
+		valid |= quotactl.FlagILimitsValid
+	}
+
+	attrs, err := fsxattrs.Get(dir)
+	if err != nil {
+		return err
+	}
+
+	var lastID uint32 = attrs.ProjectID
+	if lastID == 0 {
+		// No project/quota exists for this directory, assign a new project quota
+		// TODO(lorenz): This is racy, but the kernel does not support atomically assigning
+		// quotas. So this needs to be added to the kernels setquota interface. Due to the short
+		// time window and infrequent calls this should not be an immediate issue.
+		for {
+			quota, err := quotactl.GetNextQuota(source, quotactl.QuotaTypeProject, lastID)
+			if err == unix.ENOENT || err == unix.ESRCH {
+				// We have enumerated all quotas, nothing exists here
+				break
+			} else if err != nil {
+				return fmt.Errorf("failed to call GetNextQuota: %w", err)
+			}
+			if quota.ID > lastID+1 {
+				// Take the first ID in the quota ID gap
+				lastID++
+				break
+			}
+			lastID++
+		}
+	}
+
+	// If both limits are zero, this is a delete operation, process it as such
+	if maxBytes == 0 && maxInodes == 0 {
+		valid = quotactl.FlagBLimitsValid | quotactl.FlagILimitsValid
+		attrs.ProjectID = 0
+		attrs.Flags &= ^fsxattrs.FlagProjectInherit
+	} else {
+		attrs.ProjectID = lastID
+		attrs.Flags |= fsxattrs.FlagProjectInherit
+	}
+
+	if err := fsxattrs.Set(dir, attrs); err != nil {
+		return err
+	}
+
+	// Always round up to the nearest block size
+	bytesLimitBlocks := uint64(math.Ceil(float64(maxBytes) / float64(1024)))
+
+	return quotactl.SetQuota(source, quotactl.QuotaTypeProject, lastID, &quotactl.Quota{
+		BHardLimit: bytesLimitBlocks,
+		BSoftLimit: bytesLimitBlocks,
+		IHardLimit: maxInodes,
+		ISoftLimit: maxInodes,
+		Valid:      valid,
+	})
+}
+
+type Quota struct {
+	Bytes      uint64
+	BytesUsed  uint64
+	Inodes     uint64
+	InodesUsed uint64
+}
+
+// GetQuota returns the current active quota and its utilization at the given path
+func GetQuota(path string) (*Quota, error) {
+	dir, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer dir.Close()
+	source, err := fsinfoGetSource(dir)
+	if err != nil {
+		return nil, err
+	}
+	attrs, err := fsxattrs.Get(dir)
+	if err != nil {
+		return nil, err
+	}
+	if attrs.ProjectID == 0 {
+		return nil, os.ErrNotExist
+	}
+	quota, err := quotactl.GetQuota(source, quotactl.QuotaTypeProject, attrs.ProjectID)
+	if err != nil {
+		return nil, err
+	}
+	return &Quota{
+		Bytes:      quota.BHardLimit,
+		BytesUsed:  quota.CurSpace,
+		Inodes:     quota.IHardLimit,
+		InodesUsed: quota.CurInodes,
+	}, nil
+}
diff --git a/core/pkg/fsquota/fsxattrs/BUILD.bazel b/core/pkg/fsquota/fsxattrs/BUILD.bazel
new file mode 100644
index 0000000..64cbf9a
--- /dev/null
+++ b/core/pkg/fsquota/fsxattrs/BUILD.bazel
@@ -0,0 +1,9 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["fsxattrs.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/core/pkg/fsquota/fsxattrs",
+    visibility = ["//visibility:public"],
+    deps = ["@org_golang_x_sys//unix:go_default_library"],
+)
diff --git a/core/pkg/fsquota/fsxattrs/fsxattrs.go b/core/pkg/fsquota/fsxattrs/fsxattrs.go
new file mode 100644
index 0000000..1d455eb
--- /dev/null
+++ b/core/pkg/fsquota/fsxattrs/fsxattrs.go
@@ -0,0 +1,77 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 fsxattrs
+
+import (
+	"fmt"
+	"os"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+type FSXAttrFlag uint32
+
+// Defined in uapi/linux/fs.h
+const (
+	FlagRealtime        FSXAttrFlag = 0x00000001
+	FlagPreallocated    FSXAttrFlag = 0x00000002
+	FlagImmutable       FSXAttrFlag = 0x00000008
+	FlagAppend          FSXAttrFlag = 0x00000010
+	FlagSync            FSXAttrFlag = 0x00000020
+	FlagNoATime         FSXAttrFlag = 0x00000040
+	FlagNoDump          FSXAttrFlag = 0x00000080
+	FlagRealtimeInherit FSXAttrFlag = 0x00000100
+	FlagProjectInherit  FSXAttrFlag = 0x00000200
+	FlagNoSymlinks      FSXAttrFlag = 0x00000400
+	FlagExtentSize      FSXAttrFlag = 0x00000800
+	FlagNoDefragment    FSXAttrFlag = 0x00002000
+	FlagFilestream      FSXAttrFlag = 0x00004000
+	FlagDAX             FSXAttrFlag = 0x00008000
+	FlagCOWExtentSize   FSXAttrFlag = 0x00010000
+	FlagHasAttribute    FSXAttrFlag = 0x80000000
+)
+
+// FS_IOC_FSGETXATTR/FS_IOC_FSSETXATTR are defined in uapi/linux/fs.h
+const FS_IOC_FSGETXATTR = 0x801c581f
+const FS_IOC_FSSETXATTR = 0x401c5820
+
+type FSXAttrs struct {
+	Flags         FSXAttrFlag
+	ExtentSize    uint32
+	ExtentCount   uint32
+	ProjectID     uint32
+	CoWExtentSize uint32
+	_pad          [8]byte
+}
+
+func Get(file *os.File) (*FSXAttrs, error) {
+	var attrs FSXAttrs
+	_, _, errno := unix.Syscall(unix.SYS_IOCTL, file.Fd(), FS_IOC_FSGETXATTR, uintptr(unsafe.Pointer(&attrs)))
+	if errno != 0 {
+		return nil, fmt.Errorf("failed to execute getFSXAttrs: %v", errno)
+	}
+	return &attrs, nil
+}
+
+func Set(file *os.File, attrs *FSXAttrs) error {
+	_, _, errno := unix.Syscall(unix.SYS_IOCTL, file.Fd(), FS_IOC_FSSETXATTR, uintptr(unsafe.Pointer(attrs)))
+	if errno != 0 {
+		return fmt.Errorf("failed to execute setFSXAttrs: %v", errno)
+	}
+	return nil
+}
diff --git a/core/pkg/fsquota/quotactl/BUILD.bazel b/core/pkg/fsquota/quotactl/BUILD.bazel
new file mode 100644
index 0000000..de5d085
--- /dev/null
+++ b/core/pkg/fsquota/quotactl/BUILD.bazel
@@ -0,0 +1,9 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["quotactl.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/core/pkg/fsquota/quotactl",
+    visibility = ["//visibility:public"],
+    deps = ["@org_golang_x_sys//unix:go_default_library"],
+)
diff --git a/core/pkg/fsquota/quotactl/quotactl.go b/core/pkg/fsquota/quotactl/quotactl.go
new file mode 100644
index 0000000..5ed77d7
--- /dev/null
+++ b/core/pkg/fsquota/quotactl/quotactl.go
@@ -0,0 +1,233 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 quotactl implements a low-level wrapper around the modern portion of Linux's
+// quotactl() syscall. See the fsquota package for a nicer interface to the most common part
+// of this API.
+package quotactl
+
+import (
+	"fmt"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+type QuotaType uint
+
+const (
+	QuotaTypeUser QuotaType = iota
+	QuotaTypeGroup
+	QuotaTypeProject
+)
+
+const (
+	Q_SYNC uint = ((0x800001 + iota) << 8)
+	Q_QUOTAON
+	Q_QUOTAOFF
+	Q_GETFMT
+	Q_GETINFO
+	Q_SETINFO
+	Q_GETQUOTA
+	Q_SETQUOTA
+	Q_GETNEXTQUOTA
+)
+
+const (
+	FlagBLimitsValid = 1 << iota
+	FlagSpaceValid
+	FlagILimitsValid
+	FlagInodesValid
+	FlagBTimeValid
+	FlagITimeValid
+)
+
+type DQInfo struct {
+	Bgrace uint64
+	Igrace uint64
+	Flags  uint32
+	Valid  uint32
+}
+
+type Quota struct {
+	BHardLimit uint64 // Both Byte limits are prescaled by 1024 (so are in KiB), but CurSpace is in B
+	BSoftLimit uint64
+	CurSpace   uint64
+	IHardLimit uint64
+	ISoftLimit uint64
+	CurInodes  uint64
+	BTime      uint64
+	ITime      uint64
+	Valid      uint32
+}
+
+type NextDQBlk struct {
+	HardLimitBytes  uint64
+	SoftLimitBytes  uint64
+	CurrentBytes    uint64
+	HardLimitInodes uint64
+	SoftLimitInodes uint64
+	CurrentInodes   uint64
+	BTime           uint64
+	ITime           uint64
+	Valid           uint32
+	ID              uint32
+}
+
+type QuotaFormat uint32
+
+// Collected from quota_format_type structs
+const (
+	// QuotaFormatNone is a special case where all quota information is
+	// stored inside filesystem metadata and thus requires no quotaFilePath.
+	QuotaFormatNone   QuotaFormat = 0
+	QuotaFormatVFSOld QuotaFormat = 1
+	QuotaFormatVFSV0  QuotaFormat = 2
+	QuotaFormatOCFS2  QuotaFormat = 3
+	QuotaFormatVFSV1  QuotaFormat = 4
+)
+
+// QuotaOn turns quota accounting and enforcement on
+func QuotaOn(device string, qtype QuotaType, quotaFormat QuotaFormat, quotaFilePath string) error {
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return err
+	}
+	pathArg, err := unix.BytePtrFromString(quotaFilePath)
+	if err != nil {
+		return err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_QUOTAON|uint(qtype)), uintptr(unsafe.Pointer(devArg)), uintptr(quotaFormat), uintptr(unsafe.Pointer(pathArg)), 0, 0)
+	if err != unix.Errno(0) {
+		return err
+	}
+	return nil
+}
+
+// QuotaOff turns quotas off
+func QuotaOff(device string, qtype QuotaType) error {
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_QUOTAOFF|uint(qtype)), uintptr(unsafe.Pointer(devArg)), 0, 0, 0, 0)
+	if err != unix.Errno(0) {
+		return err
+	}
+	return nil
+}
+
+// GetFmt gets the quota format used on given filesystem
+func GetFmt(device string, qtype QuotaType) (uint32, error) {
+	var fmt uint32
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return 0, err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_GETFMT|uint(qtype)), uintptr(unsafe.Pointer(devArg)), 0, uintptr(unsafe.Pointer(&fmt)), 0, 0)
+	if err != unix.Errno(0) {
+		return 0, err
+	}
+	return fmt, nil
+}
+
+// GetInfo gets information about quota files
+func GetInfo(device string, qtype QuotaType) (*DQInfo, error) {
+	var info DQInfo
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return nil, err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_GETINFO|uint(qtype)), uintptr(unsafe.Pointer(devArg)), 0, uintptr(unsafe.Pointer(&info)), 0, 0)
+	if err != unix.Errno(0) {
+		return nil, err
+	}
+	return &info, nil
+}
+
+// SetInfo sets information about quota files
+func SetInfo(device string, qtype QuotaType, info *DQInfo) error {
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_SETINFO|uint(qtype)), uintptr(unsafe.Pointer(devArg)), 0, uintptr(unsafe.Pointer(info)), 0, 0)
+	if err != unix.Errno(0) {
+		return err
+	}
+	return nil
+}
+
+// GetQuota gets user quota structure
+func GetQuota(device string, qtype QuotaType, id uint32) (*Quota, error) {
+	var info Quota
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return nil, err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_GETQUOTA|uint(qtype)), uintptr(unsafe.Pointer(devArg)), uintptr(id), uintptr(unsafe.Pointer(&info)), 0, 0)
+	if err != unix.Errno(0) {
+		return nil, err
+	}
+	return &info, nil
+}
+
+// GetNextQuota gets disk limits and usage > ID
+func GetNextQuota(device string, qtype QuotaType, id uint32) (*NextDQBlk, error) {
+	var info NextDQBlk
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return nil, err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_GETNEXTQUOTA|uint(qtype)), uintptr(unsafe.Pointer(devArg)), uintptr(id), uintptr(unsafe.Pointer(&info)), 0, 0)
+	if err != unix.Errno(0) {
+		return nil, err
+	}
+	return &info, nil
+}
+
+// SetQuota sets the given quota
+func SetQuota(device string, qtype QuotaType, id uint32, quota *Quota) error {
+	devArg, err := unix.BytePtrFromString(device)
+	if err != nil {
+		return err
+	}
+	_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_SETQUOTA|uint(qtype)), uintptr(unsafe.Pointer(devArg)), uintptr(id), uintptr(unsafe.Pointer(quota)), 0, 0)
+	if err != unix.Errno(0) {
+		return fmt.Errorf("failed to set quota: %w", err)
+	}
+	return nil
+}
+
+// Sync syncs disk copy of filesystems quotas. If device is empty it syncs all filesystems.
+func Sync(device string) error {
+	if device != "" {
+		devArg, err := unix.BytePtrFromString(device)
+		if err != nil {
+			return err
+		}
+		_, _, err = unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_SYNC), uintptr(unsafe.Pointer(devArg)), 0, 0, 0, 0)
+		if err != unix.Errno(0) {
+			return err
+		}
+	} else {
+		_, _, err := unix.Syscall6(unix.SYS_QUOTACTL, uintptr(Q_SYNC), 0, 0, 0, 0, 0)
+		if err != unix.Errno(0) {
+			return err
+		}
+	}
+	return nil
+}