Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ jobs:
# make test
# make test-teardown

test-cgroup-integration:
name: Test cgroup integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v2
with:
go-version: '1.25'
- name: Build test binary
run: go test -c -o cgroup.test ./cgroup/
- name: Run cgroup integration test
run: |
docker run --rm \
--cpus=0.5 --memory=128m \
-e CGROUP_EXPECTED_CPU_QUOTA=0.5 \
-e CGROUP_EXPECTED_MEMORY_LIMIT=134217728 \
-v "$PWD/cgroup.test":/cgroup.test:ro \
debian:bookworm-slim \
/cgroup.test -test.run TestIntegrationCgroupLimits -test.v

run-cli-tests:
name: Run command-line interface tests
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ lint: $(BIN)/staticcheck $(BIN)/misspell
clean:
rm -f $(BIN)/$(NAME) $(PAM_MODULE) $(TOOLS) coverage.out $(COVERAGE_FILES) $(PAM_CONFIG)

###### Cgroup testdata ######
.PHONY: gen-cgroup-testdata
gen-cgroup-testdata:
bin/gen-cgroup-testdata

###### Go tests ######
.PHONY: test test-setup test-teardown

Expand Down
30 changes: 27 additions & 3 deletions actions/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ import (
"bytes"
"fmt"
"log"
"math"
"os"
"runtime"
"time"

"golang.org/x/sys/unix"
"google.golang.org/protobuf/proto"

"github.com/google/fscrypt/cgroup"
"github.com/google/fscrypt/crypto"
"github.com/google/fscrypt/filesystem"
"github.com/google/fscrypt/metadata"
Expand Down Expand Up @@ -186,8 +188,9 @@ func getConfig() (*metadata.Config, error) {
func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
log.Printf("Finding hashing costs that take %v\n", target)

// Start out with the minimal possible costs that use all the CPUs.
parallelism := int64(runtime.NumCPU())
// Start out with the minimal possible costs that use all the available
// CPUs, respecting cgroup limits when present.
parallelism := int64(effectiveCPUCount())
// golang.org/x/crypto/argon2 only supports parallelism up to 255.
// For compatibility, don't use more than that amount.
if parallelism > metadata.MaxParallelism {
Expand Down Expand Up @@ -248,16 +251,37 @@ func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
}
}

// effectiveCPUCount returns the number of CPUs available to this process,
// taking cgroup limits into account. Falls back to runtime.NumCPU() when
// cgroup information is unavailable.
func effectiveCPUCount() int {
cg, err := cgroup.New()
if err != nil {
return runtime.NumCPU()
}
quota, err := cg.CPUQuota()
if err != nil || quota <= 0 {
return runtime.NumCPU()
}
cpus := int(math.Ceil(quota))
return min(cpus, runtime.NumCPU())
}

// memoryBytesLimit returns the maximum amount of memory we will use for
// passphrase hashing. This will never be more than a reasonable maximum (for
// compatibility) or an 8th the available system RAM.
// compatibility) or an 8th the available RAM (considering cgroup limits).
func memoryBytesLimit() int64 {
// The sysinfo syscall only fails if given a bad address
var info unix.Sysinfo_t
err := unix.Sysinfo(&info)
util.NeverError(err)

totalRAMBytes := int64(info.Totalram)
if cg, err := cgroup.New(); err == nil {
if cgroupMem, err := cg.MemoryLimit(); err == nil && cgroupMem > 0 {
totalRAMBytes = util.MinInt64(totalRAMBytes, cgroupMem)
}
}
return util.MinInt64(totalRAMBytes/8, maxMemoryBytes)
}

Expand Down
48 changes: 48 additions & 0 deletions bin/gen-cgroup-testdata
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# gen-cgroup-testdata - Generate cgroup testdata by running
# bin/snapshot-cgroup inside Docker containers with known resource limits.
#
# Usage: gen-cgroup-testdata
#
# Prerequisites: Docker on a host running cgroup v2.
#
# Each testdata directory contains:
# expected.json - {"cpu_quota": <float>, "memory_limit": <int>}
# proc/ - snapshot of /proc/self/cgroup
# sys/ - snapshot of cgroup control files

set -euo pipefail

cd "$(dirname "$0")/.."

testdata="cgroup/testdata"
snapshot_script="bin/snapshot-cgroup"

generate() {
local name="$1" cpu_quota="$2" memory_limit="$3"
shift 3
local outdir="$testdata/$name"

echo "Generating $name..."
rm -rf "$outdir"
mkdir -p "$outdir"

docker run --rm \
--user "$(id -u):$(id -g)" \
"$@" \
-v "$PWD/$snapshot_script:/snapshot:ro" \
-v "$PWD/$outdir:/out" \
debian:bookworm-slim \
/snapshot /out

cat > "$outdir/expected.json" <<EOF
{"cpu_quota": $cpu_quota, "memory_limit": $memory_limit}
EOF
}

generate "v2-two-cores-256m" 2.0 268435456 --cpus=2 --memory=256m
generate "v2-quarter-core-64m" 0.25 67108864 --cpus=0.25 --memory=64m
generate "v2-no-limit" null null

echo "v2 testdata generated successfully."
42 changes: 42 additions & 0 deletions bin/snapshot-cgroup
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# snapshot-cgroup - Copy cgroup v2 files from the live system into a
# directory tree suitable for use with TestIntegrationCgroupLimits.
#
# Usage: snapshot-cgroup <output-dir>
#
# The script reads /proc/self/cgroup to find the v2 group path and copies
# exactly the files that the cgroup package needs:
#
# proc/self/cgroup
# sys/fs/cgroup/<group>/cpu.max
# sys/fs/cgroup/<group>/memory.max

set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "Usage: $0 <output-dir>" >&2
exit 1
fi

out="$1"
mkdir -p "$out"

copy_file() {
local src="$1" dst="$out/$2"
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
}

copy_file /proc/self/cgroup proc/self/cgroup

group=$(awk -F: '/^0::/ { print $3 }' /proc/self/cgroup)
cgdir="/sys/fs/cgroup${group}"

for f in cpu.max memory.max; do
if [[ -f "$cgdir/$f" ]]; then
copy_file "$cgdir/$f" "sys/fs/cgroup${group}/$f"
fi
done

echo "Snapshot written to $out"
175 changes: 175 additions & 0 deletions cgroup/cgroup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* cgroup.go - Read CPU and memory limits from Linux cgroups v2.
*
* Copyright 2026 Google LLC
*
* 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 cgroup reads CPU and memory resource limits from Linux control
// groups (cgroup v2).
//
// References:
// - cgroups(7): https://man7.org/linux/man-pages/man7/cgroups.7.html
// - cgroup v2 (cpu.max, memory.max): https://docs.kernel.org/admin-guide/cgroup-v2.html
// - /proc/self/cgroup: https://man7.org/linux/man-pages/man7/cgroups.7.html (see "/proc files")
package cgroup

import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

// Errors.
var (
// ErrNoLimit indicates that no cgroup limit is set.
ErrNoLimit = errors.New("no cgroup limit set")

// ErrV1Detected indicates that cgroup v1 controllers were found. Only v2 is
// supported.
ErrV1Detected = errors.New("cgroup v1 detected; only v2 is supported")
)

// Cgroup provides access to cgroup v2 resource limits. Create one with
// New or NewFromRoot.
type Cgroup struct {
// cgroupDir is the resolved filesystem path to the cgroup directory
// (e.g. /sys/fs/cgroup/user.slice/...).
cgroupDir string
}

// New returns a Cgroup by reading /proc/self/cgroup on the live system.
func New() (Cgroup, error) {
return NewFromRoot("/")
}

// NewFromRoot is like New but resolves all filesystem paths relative to
// root instead of "/". This is useful for testing with a mock filesystem.
func NewFromRoot(root string) (Cgroup, error) {
groupPath, err := parseProcCgroup(filepath.Join(root, "proc/self/cgroup"))
if err != nil {
return Cgroup{}, err
}
return Cgroup{
cgroupDir: filepath.Join(root, "sys/fs/cgroup", groupPath),
}, nil
}

// CPUQuota returns the CPU quota as a fractional number of CPUs (e.g. 0.5
// means half a core). Returns ErrNoLimit if no CPU limit is configured.
func (c Cgroup) CPUQuota() (float64, error) {
data, err := c.readFile("cpu.max")
if err != nil {
return 0, err
}
return parseCPUMax(data)
}

// MemoryLimit returns the cgroup memory limit in bytes. Returns ErrNoLimit
// if no memory limit is configured.
func (c Cgroup) MemoryLimit() (int64, error) {
data, err := c.readFile("memory.max")
if err != nil {
return 0, err
}
return parseMemoryMax(data)
}

func (c Cgroup) readFile(path string) (string, error) {
data, err := os.ReadFile(filepath.Join(c.cgroupDir, path))
if err != nil {
if os.IsNotExist(err) {
return "", ErrNoLimit
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}

// parseProcCgroup parses /proc/self/cgroup and returns the cgroup v2 group
// path. The v2 entry is the line with hierarchy-ID "0" and an empty
// controller list: "0::<path>".
//
// Returns an error if v1 controllers are detected or no v2 entry is found.
//
// https://man7.org/linux/man-pages/man7/cgroups.7.html
func parseProcCgroup(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()

var v2Path string

scanner := bufio.NewScanner(f)
for scanner.Scan() {
parts := strings.SplitN(scanner.Text(), ":", 3)
if len(parts) != 3 {
continue
}
if parts[0] == "0" && parts[1] == "" {
v2Path = parts[2]
} else if parts[1] != "" {
return "", ErrV1Detected
}
}
if err := scanner.Err(); err != nil {
return "", err
}
if v2Path == "" {
return "", fmt.Errorf("no cgroup v2 entry found in %s", path)
}
return v2Path, nil
}

func parseCPUMax(content string) (float64, error) {
fields := strings.Fields(content)
if len(fields) == 0 || len(fields) > 2 {
return 0, fmt.Errorf("unexpected cpu.max format: %q", content)
}
if fields[0] == "max" {
return 0, ErrNoLimit
}
quota, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, fmt.Errorf("parsing cpu.max quota: %w", err)
}
period := 100000.0
if len(fields) == 2 {
period, err = strconv.ParseFloat(fields[1], 64)
if err != nil {
return 0, fmt.Errorf("parsing cpu.max period: %w", err)
}
if period == 0 {
return 0, fmt.Errorf("cpu.max period is zero")
}
}
return quota / period, nil
}

func parseMemoryMax(content string) (int64, error) {
if content == "max" {
return 0, ErrNoLimit
}
v, err := strconv.ParseInt(content, 10, 64)
if err != nil {
return 0, fmt.Errorf("parsing memory.max: %w", err)
}
return v, nil
}
Loading
Loading