A hard link swapped in for ~/.claude would have had root chown another account's file; fstat on the descriptor now refuses a second link, a fifo or an unexpected kind before anything is changed (hq ADR 0266, the re-review). The judge also finds polkit rules for every account, a runtime's API on TCP, setgid-to-root programs whoever owns them, setuid programs on every suid filesystem, and unprotected links; the rest is listed as not judged.
598 lines
20 KiB
Go
598 lines
20 KiB
Go
package accounts
|
|
|
|
// The ways to root the judge looks for beyond uid, groups and sudo (novox/hq ADR 0266, the review of
|
|
// 2026-10-08), and the ones it does not. Judged and NotJudged are the one written list: the record quotes it,
|
|
// and a way added here is a line added there.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// Judged is every way to root the judge looks for, in the words its reasons use.
|
|
var Judged = []string{
|
|
"its uid is 0",
|
|
"membership of a group that grants root by membership: " + strings.Join(RootGroups, ", "),
|
|
"any sudo rule for it or one of its groups, as `sudo -l -U` lists it (with or without a password)",
|
|
"any doas rule permitting it or one of its groups (/etc/doas.conf, /etc/opendoas.conf)",
|
|
"any polkit rule naming it or one of its groups (/etc/polkit-1/rules.d, /usr/share/polkit-1/rules.d, " +
|
|
"/etc/polkit-1/localauthority), which is how pkexec and systemd's own actions are granted",
|
|
"a polkit rule that grants every account: a .rules file that answers polkit.Result.YES and names no user " +
|
|
"and no group, or a .pkla whose Identity is unix-user:* or unix-group:* with a Result of yes",
|
|
"write access to a container runtime's socket (docker, podman, containerd), by owner, group, other or " +
|
|
"POSIX ACL",
|
|
"a container runtime's API listening on TCP port 2375 or 2376 (/proc/net/tcp, /proc/net/tcp6), which any " +
|
|
"local account reaches",
|
|
"read access to a secret the mesh placed for another account, by owner, group, other or POSIX ACL",
|
|
"a program that no installed package owns and is setuid with owner root, or setgid with group root, " +
|
|
"whoever owns it, on every local filesystem mounted without nosuid (container and image layers, " +
|
|
"network and pseudo filesystems excepted)",
|
|
"hard links or symbolic links to other accounts' files left unprotected by the kernel " +
|
|
"(fs.protected_hardlinks or fs.protected_symlinks is 0)",
|
|
}
|
|
|
|
// NotJudged is what the judge does not look for: each is a way to root it would miss.
|
|
var NotJudged = []string{
|
|
"a root-run unit, timer, cron entry or script the account can write",
|
|
"a directory on root's PATH, or in /etc/profile.d, the account can write",
|
|
"root's or the operator's ssh keys or authorized_keys readable or writable by it",
|
|
"a file root writes or reads, in a directory the account owns, other than below its home (the node-engine " +
|
|
"refuses links there)",
|
|
"file capabilities (setcap) on a program",
|
|
"a setuid program a package installed that has a flaw of its own",
|
|
"a terminal the account shares with a root process (TIOCSTI, sudo without use_pty)",
|
|
"a polkit rule that grants every account by logic the text does not show (a JavaScript condition " +
|
|
"other than a named user or group), or every account with an active local session",
|
|
"a container runtime's API on a TCP port other than 2375 and 2376, or on a unix socket not named here",
|
|
"a setuid program on a filesystem not judged: a container or image layer (overlay, squashfs), a network " +
|
|
"or FUSE filesystem",
|
|
}
|
|
|
|
// RuntimeSockets are the container runtimes' sockets: write access to one runs a container as root.
|
|
var RuntimeSockets = []string{"/run/docker.sock", "/var/run/docker.sock", "/run/podman/podman.sock",
|
|
"/run/containerd/containerd.sock"}
|
|
|
|
// DoasConfigs and PolkitDirs are where those grants live.
|
|
var (
|
|
DoasConfigs = []string{"/etc/doas.conf", "/etc/opendoas.conf"}
|
|
PolkitDirs = []string{"/etc/polkit-1/rules.d", "/usr/share/polkit-1/rules.d", "/etc/polkit-1/localauthority"}
|
|
)
|
|
|
|
// ACLEntry is one named entry of a POSIX ACL: a user's or a group's, and whether it grants read and write
|
|
// once the ACL's mask is applied.
|
|
type ACLEntry struct {
|
|
User bool
|
|
ID int
|
|
Read, Write bool
|
|
}
|
|
|
|
// The tags and bits of the kernel's ACL xattr.
|
|
const (
|
|
aclUser = 0x02
|
|
aclGroup = 0x08
|
|
aclMask = 0x10
|
|
)
|
|
|
|
// ParseACL reads a system.posix_acl_access value: a version, then entries of tag, permission and id; the
|
|
// named users' and groups' entries are answered with the mask applied.
|
|
func ParseACL(raw []byte) ([]ACLEntry, error) {
|
|
if len(raw) < 4 || (len(raw)-4)%8 != 0 {
|
|
return nil, fmt.Errorf("an ACL of %d bytes is not one", len(raw))
|
|
}
|
|
type entry struct {
|
|
tag, perm uint16
|
|
id uint32
|
|
}
|
|
var es []entry
|
|
mask := uint16(7)
|
|
for at := 4; at < len(raw); at += 8 {
|
|
e := entry{binary.LittleEndian.Uint16(raw[at:]), binary.LittleEndian.Uint16(raw[at+2:]),
|
|
binary.LittleEndian.Uint32(raw[at+4:])}
|
|
if e.tag == aclMask {
|
|
mask = e.perm
|
|
}
|
|
es = append(es, e)
|
|
}
|
|
var out []ACLEntry
|
|
for _, e := range es {
|
|
if e.tag != aclUser && e.tag != aclGroup {
|
|
continue
|
|
}
|
|
p := e.perm & mask
|
|
out = append(out, ACLEntry{User: e.tag == aclUser, ID: int(e.id), Read: p&4 != 0, Write: p&2 != 0})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// aclOf is a file's ACL from the machine: none when it has none.
|
|
func aclOf(path string) ([]ACLEntry, error) {
|
|
buf := make([]byte, 1024)
|
|
n, err := unix.Getxattr(path, "system.posix_acl_access", buf)
|
|
if errors.Is(err, unix.ENODATA) || errors.Is(err, unix.EOPNOTSUPP) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, &os.PathError{Op: "getxattr", Path: path, Err: err}
|
|
}
|
|
return ParseACL(buf[:n])
|
|
}
|
|
|
|
// grantsByACL says whether an ACL entry gives the account read (or write).
|
|
func grantsByACL(acl []ACLEntry, uid int, gids map[int]bool, write bool) bool {
|
|
for _, e := range acl {
|
|
if (e.User && e.ID == uid) || (!e.User && gids[e.ID]) {
|
|
if (write && e.Write) || (!write && e.Read) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Writable is Readable's twin for the write bits.
|
|
func Writable(m FileMode, uid int, gids map[int]bool) bool {
|
|
switch {
|
|
case uid == 0:
|
|
return true
|
|
case m.UID == uid:
|
|
return m.Perm&0o200 != 0
|
|
case gids[m.GID]:
|
|
return m.Perm&0o020 != 0
|
|
default:
|
|
return m.Perm&0o002 != 0
|
|
}
|
|
}
|
|
|
|
// DoasRules is every line of a doas configuration that permits the account or one of its groups.
|
|
func DoasRules(conf string, account string, groups []string) []string {
|
|
var out []string
|
|
for _, line := range strings.Split(conf, "\n") {
|
|
if i := strings.IndexByte(line, '#'); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
f := strings.Fields(line)
|
|
if len(f) < 2 || f[0] != "permit" {
|
|
continue
|
|
}
|
|
i := 1
|
|
for i < len(f) && (f[i] == "nopass" || f[i] == "persist" || f[i] == "keepenv" || f[i] == "nolog" ||
|
|
f[i] == "setenv" || strings.HasPrefix(f[i], "{")) {
|
|
if strings.HasPrefix(f[i], "{") {
|
|
for i < len(f) && !strings.HasSuffix(f[i], "}") {
|
|
i++
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
if i >= len(f) {
|
|
continue
|
|
}
|
|
who := f[i]
|
|
if who == account || (strings.HasPrefix(who, ":") && contains(groups, who[1:])) {
|
|
out = append(out, strings.TrimSpace(line))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PolkitNames says whether a polkit rule or authority file names the account or one of its groups.
|
|
func PolkitNames(text, account string, groups []string) bool {
|
|
needles := []string{"unix-user:" + account, `"` + account + `"`, `'` + account + `'`}
|
|
for _, g := range groups {
|
|
needles = append(needles, "unix-group:"+g, `isInGroup("`+g+`")`, `isInGroup('`+g+`')`)
|
|
}
|
|
for _, n := range needles {
|
|
if strings.Contains(text, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PolkitGrantsEveryone says whether a polkit rule or authority file grants every account, naming none: a .rules
|
|
// file whose function answers polkit.Result.YES with no condition on a user or a group, or a .pkla whose
|
|
// Identity is every user or every group with a Result of yes.
|
|
func PolkitGrantsEveryone(path, text string) bool {
|
|
if strings.HasSuffix(path, ".pkla") {
|
|
for _, section := range strings.Split(text, "[") {
|
|
var every, yes bool
|
|
for _, line := range strings.Split(section, "\n") {
|
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
switch strings.TrimSpace(k) {
|
|
case "Identity":
|
|
for _, id := range strings.Split(v, ";") {
|
|
if id == "unix-user:*" || id == "unix-group:*" {
|
|
every = true
|
|
}
|
|
}
|
|
case "ResultAny", "ResultActive", "ResultInactive":
|
|
if v == "yes" {
|
|
yes = true
|
|
}
|
|
}
|
|
}
|
|
if every && yes {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if !strings.Contains(text, "polkit.Result.YES") {
|
|
return false
|
|
}
|
|
// A condition on a user or a group names whom it grants; one on an active local session grants only an
|
|
// account with a seat, which an agent started through sudo has not (listed under NotJudged).
|
|
for _, condition := range []string{".user", "isInGroup", "unix-user:", "unix-group:", ".active", ".local"} {
|
|
if strings.Contains(text, condition) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// RuntimeAPIPorts are the ports a container runtime's API listens on by convention: 2375 plain, 2376 TLS.
|
|
var RuntimeAPIPorts = []int{2375, 2376}
|
|
|
|
// ListeningPorts is every local TCP port in the listening state, from /proc/net/tcp's or tcp6's text.
|
|
func ListeningPorts(text string) []int {
|
|
var out []int
|
|
for i, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if i == 0 || len(f) < 4 || f[3] != "0A" {
|
|
continue
|
|
}
|
|
_, port, ok := strings.Cut(f[1], ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(port, "%X", &n); err == nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pseudoFS are filesystem types no setuid program is installed on, or that are a container's or an image's
|
|
// own layers: not searched.
|
|
var pseudoFS = map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true,
|
|
"cgroup": true, "cgroup2": true, "securityfs": true, "pstore": true, "bpf": true, "debugfs": true,
|
|
"tracefs": true, "mqueue": true, "hugetlbfs": true, "configfs": true, "fusectl": true, "autofs": true,
|
|
"binfmt_misc": true, "efivarfs": true, "overlay": true, "squashfs": true, "nsfs": true, "ramfs": true,
|
|
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "9p": true, "virtiofs": true}
|
|
|
|
// SuidMounts is every local filesystem a setuid program could run from: the mount points of /proc/mounts whose
|
|
// type is not pseudo, network or a container's layer, and that are not mounted nosuid. Root first.
|
|
func SuidMounts(text string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 4 || pseudoFS[f[2]] || strings.HasPrefix(f[2], "fuse") {
|
|
continue
|
|
}
|
|
at := strings.ReplaceAll(strings.ReplaceAll(f[1], `\040`, " "), `\011`, "\t")
|
|
if strings.HasPrefix(at, "/var/lib/docker") || strings.HasPrefix(at, "/var/lib/containers") ||
|
|
strings.HasPrefix(at, "/proc") || strings.HasPrefix(at, "/sys") {
|
|
continue
|
|
}
|
|
if contains(strings.Split(f[3], ","), "nosuid") || seen[at] {
|
|
continue
|
|
}
|
|
seen[at] = true
|
|
out = append(out, at)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] == "/" || (out[j] != "/" && out[i] < out[j]) })
|
|
return out
|
|
}
|
|
|
|
func contains(xs []string, s string) bool {
|
|
for _, x := range xs {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetuidCache keeps the search for setuid programs, which walks the root filesystem, for Every: the judge
|
|
// looks every minute, and a setuid program appears only by root's act.
|
|
type SetuidCache struct {
|
|
Every time.Duration
|
|
mu sync.Mutex
|
|
at time.Time
|
|
found []string
|
|
err error
|
|
}
|
|
|
|
func (c *SetuidCache) get(now time.Time, search func() ([]string, error)) ([]string, error) {
|
|
if c == nil {
|
|
return search()
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.at.IsZero() || now.Sub(c.at) >= c.Every || c.err != nil {
|
|
c.found, c.err = search()
|
|
c.at = now
|
|
}
|
|
return c.found, c.err
|
|
}
|
|
|
|
// setuidSearch is every setuid- or setgid-root regular file on the root filesystem that no installed package
|
|
// owns, found with find and asked of the package manager. Bounded: a search that does not finish is an
|
|
// unanswered question, never "none".
|
|
func (e Exec) setuidSearch(ctx context.Context, mounts []string) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
// Each mount point a starting point of its own, -xdev keeping each to its own filesystem: every local
|
|
// filesystem mounted without nosuid is searched once (the re-review of 2026-10-08), and a program setgid to
|
|
// root's group counts whoever owns it.
|
|
args := append(append([]string{}, mounts...), "-xdev", "(", "-path", "/proc", "-o", "-path", "/sys", "-o",
|
|
"-path", "/var/lib/docker", "-o", "-path", "/var/lib/containers", ")", "-prune", "-o",
|
|
"-type", "f", "(", "(", "-user", "root", "-perm", "-4000", ")", "-o", "(", "-group", "root", "-perm",
|
|
"-2000", ")", ")", "-print")
|
|
out, err := e.Run(ctx, "find", args...)
|
|
if ctx.Err() != nil {
|
|
return nil, fmt.Errorf("the search for setuid programs did not finish within two minutes")
|
|
}
|
|
if err != nil && strings.TrimSpace(out) == "" {
|
|
return nil, fmt.Errorf("the search for setuid programs did not finish: %w", err)
|
|
}
|
|
var paths []string
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
paths = append(paths, l)
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
var unowned []string
|
|
for _, p := range paths {
|
|
owned, err := e.packaged(ctx, p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !owned {
|
|
unowned = append(unowned, p)
|
|
}
|
|
}
|
|
return unowned, nil
|
|
}
|
|
|
|
// packaged says whether an installed package owns a path: pacman's or apk's answer, never a guess.
|
|
func (e Exec) packaged(ctx context.Context, path string) (bool, error) {
|
|
out, err := e.Run(ctx, "pacman", "-Qqo", path)
|
|
if err == nil {
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
if strings.Contains(out+err.Error(), "No package owns") || strings.Contains(err.Error(), "exited 1") {
|
|
return false, nil
|
|
}
|
|
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, fs.ErrNotExist) {
|
|
return false, fmt.Errorf("pacman could not say who owns %s: %w", path, err)
|
|
}
|
|
out, err = e.Run(ctx, "apk", "info", "-W", path)
|
|
if err != nil {
|
|
return false, fmt.Errorf("no package manager could say who owns %s: %w", path, err)
|
|
}
|
|
return strings.Contains(out, " is owned by "), nil
|
|
}
|
|
|
|
// moreWays is every way beyond uid, groups and sudo; an unanswered question is an error, never none.
|
|
func (e Exec) moreWays(ctx context.Context, account string, uid int, groups []string, gids map[int]bool,
|
|
secrets []string) ([]string, error) {
|
|
read := e.ReadFile
|
|
if read == nil {
|
|
read = os.ReadFile
|
|
}
|
|
readDir := e.ReadDir
|
|
if readDir == nil {
|
|
readDir = os.ReadDir
|
|
}
|
|
acl := e.ACL
|
|
if acl == nil {
|
|
acl = aclOf
|
|
}
|
|
stat := e.Stat
|
|
if stat == nil {
|
|
stat = statOf
|
|
}
|
|
var ways []string
|
|
|
|
// doas.
|
|
for _, conf := range DoasConfigs {
|
|
raw, err := read(conf)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("doas's rules in %s could not be read: %w", conf, err)
|
|
}
|
|
for _, r := range DoasRules(string(raw), account, groups) {
|
|
ways = append(ways, "doas permits it: "+r)
|
|
}
|
|
}
|
|
|
|
// polkit.
|
|
for _, dir := range PolkitDirs {
|
|
var named, everyone []string
|
|
err := walkFiles(readDir, dir, func(path string) error {
|
|
raw, err := read(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if PolkitNames(string(raw), account, groups) {
|
|
named = append(named, path)
|
|
} else if PolkitGrantsEveryone(path, string(raw)) {
|
|
everyone = append(everyone, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return nil, fmt.Errorf("polkit's rules in %s could not be read: %w", dir, err)
|
|
}
|
|
for _, p := range named {
|
|
ways = append(ways, "a polkit rule names it or a group of it: "+p)
|
|
}
|
|
for _, p := range everyone {
|
|
ways = append(ways, "a polkit rule grants every account: "+p)
|
|
}
|
|
}
|
|
|
|
// The container runtimes' sockets.
|
|
seen := map[string]bool{}
|
|
for _, s := range RuntimeSockets {
|
|
// Two names of one socket are one socket. A test that gives its own files names them as they are.
|
|
real, err := filepath.EvalSymlinks(s)
|
|
if e.Stat != nil {
|
|
real, err = s, nil
|
|
}
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the socket %s could not be read: %w", s, err)
|
|
}
|
|
if seen[real] {
|
|
continue
|
|
}
|
|
seen[real] = true
|
|
m, err := stat(real)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the socket %s could not be read for its owner and mode: %w", s, err)
|
|
}
|
|
a, err := acl(real)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if Writable(m, uid, gids) || grantsByACL(a, uid, gids, true) {
|
|
ways = append(ways, "it can write the container runtime's socket "+s+", which runs a container as root")
|
|
}
|
|
}
|
|
|
|
// The secrets' ACLs: the bits were judged already.
|
|
for _, path := range secrets {
|
|
a, err := acl(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the secret %s's ACL could not be read: %w", path, err)
|
|
}
|
|
if grantsByACL(a, uid, gids, false) {
|
|
ways = append(ways, "an ACL lets it read the secret "+path)
|
|
}
|
|
}
|
|
|
|
// setuid programs no package owns.
|
|
now := time.Now
|
|
if e.Now != nil {
|
|
now = e.Now
|
|
}
|
|
mountsText, err := read("/proc/mounts")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the mounted filesystems could not be read: %w", err)
|
|
}
|
|
mounts := SuidMounts(string(mountsText))
|
|
if len(mounts) == 0 {
|
|
return nil, errors.New("no filesystem a setuid program could run from was found in /proc/mounts")
|
|
}
|
|
unowned, err := e.Cache.get(now(), func() ([]string, error) { return e.setuidSearch(ctx, mounts) })
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range unowned {
|
|
ways = append(ways, "a setuid-root program no package owns: "+p)
|
|
}
|
|
|
|
// The kernel's protection of links: off, any account may link another's file where root then acts on it.
|
|
for _, sysctl := range []string{"protected_hardlinks", "protected_symlinks"} {
|
|
raw, err := read("/proc/sys/fs/" + sysctl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fs.%s could not be read: %w", sysctl, err)
|
|
}
|
|
if strings.TrimSpace(string(raw)) == "0" {
|
|
what := map[string]string{"protected_hardlinks": "hard links", "protected_symlinks": "symbolic links"}[sysctl]
|
|
ways = append(ways, fmt.Sprintf("%s to other accounts' files are not protected (fs.%s=0)", what, sysctl))
|
|
}
|
|
}
|
|
|
|
// A container runtime's API on TCP, which any account on the machine reaches.
|
|
for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
|
raw, err := read(table)
|
|
if errors.Is(err, fs.ErrNotExist) && table == "/proc/net/tcp6" {
|
|
continue // no IPv6 on this machine
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the listening ports in %s could not be read: %w", table, err)
|
|
}
|
|
for _, port := range ListeningPorts(string(raw)) {
|
|
for _, api := range RuntimeAPIPorts {
|
|
if port == api {
|
|
ways = append(ways, fmt.Sprintf("a container runtime's API listens on TCP port %d, which any "+
|
|
"local account reaches", port))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ways, nil
|
|
}
|
|
|
|
// walkFiles calls fn for every regular file below dir, through readDir.
|
|
func walkFiles(readDir func(string) ([]fs.DirEntry, error), dir string, fn func(string) error) error {
|
|
entries, err := readDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, en := range entries {
|
|
p := filepath.Join(dir, en.Name())
|
|
if en.IsDir() {
|
|
if err := walkFiles(readDir, p, fn); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := fn(p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CLocale runs a command in the C locale, so what the judge parses — sudo's listing above all — is one
|
|
// language whatever the machine's is; stdin closed, output captured.
|
|
func CLocale(ctx context.Context, name string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
var exit *exec.ExitError
|
|
if errors.As(err, &exit) {
|
|
return string(out), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(stderr.String()))
|
|
}
|
|
return string(out), fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return string(out), nil
|
|
}
|