package accounts import ( "context" "errors" "fmt" "os" "path/filepath" "strings" ) // Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner). type Runner func(ctx context.Context, name string, args ...string) (string, error) // Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`, // the machine's own manager's `systemctl show`, and a status file under /proc (a test holds it). type Exec struct { Run Runner // Proc is where the process table is; empty is /proc. A test points it at a directory of its own. Proc string } // InDatabase is `id -nG`: every group the user database lists the account in. func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) { out, err := e.Run(ctx, "id", "-nG", account) if err != nil { return nil, err } return strings.Fields(out), nil } // Session reads the account's own manager, user@.service, from the machine's manager — never from // the account's, which asking would start — and the groups its process holds, from its status file. func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) { passwd, err := e.Run(ctx, "getent", "passwd", account) if err != nil { return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err) } fields := strings.Split(strings.TrimSpace(passwd), ":") if len(fields) < 7 || fields[2] == "" { return Session{}, fmt.Errorf("the user database gave no number for %q", account) } shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service") if err != nil { return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err) } pid := strings.TrimSpace(shown) if pid == "" || pid == "0" { return Session{}, nil } held, err := e.heldBy(pid) if err != nil { return Session{}, err } s := Session{Running: true, Has: map[string]bool{}} for _, g := range groups { entry, err := e.Run(ctx, "getent", "group", g) if err != nil { return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err) } parts := strings.Split(strings.TrimSpace(entry), ":") if len(parts) < 3 { return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g) } s.Has[g] = held[parts[2]] } return s, nil } // heldBy is every group id a process holds, from the Groups line of its status file. func (e Exec) heldBy(pid string) (map[string]bool, error) { proc := e.Proc if proc == "" { proc = "/proc" } raw, err := os.ReadFile(filepath.Join(proc, pid, "status")) if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid) } if err != nil { return nil, err } // Its supplementary groups, and its own group, which the supplementary list need not repeat. held := map[string]bool{} named := false for _, line := range strings.Split(string(raw), "\n") { if rest, ok := strings.CutPrefix(line, "Groups:"); ok { named = true for _, gid := range strings.Fields(rest) { held[gid] = true } } if rest, ok := strings.CutPrefix(line, "Gid:"); ok { if f := strings.Fields(rest); len(f) > 0 { held[f[0]] = true } } } if !named { return nil, fmt.Errorf("process %s's status names no groups", pid) } return held, nil }