Merge pull request 'A login session is the account's own, of class user, read at accept (hq ADR 0272)' (#71) from feat/272-the-session-is-a-users-read-at-accept into main

This commit was merged in pull request #71.
This commit is contained in:
2026-10-09 12:04:18 +00:00
3 changed files with 97 additions and 4 deletions
+3 -1
View File
@@ -160,7 +160,9 @@ connection's deadline set the moment it is accepted, two lines at once per accou
"busy" beyond that.
**Only a login session can be the terminal.** The engine reads the asking process's cgroup and sends its login
session's scope (`session-<id>.scope` under the account's own `user-<uid>.slice`), or nothing; the controller makes
session's scope (`session-<id>.scope` under the account's own `user-<uid>.slice`, which logind's own record in
`/run/systemd/sessions/<id>` must say is this account's, of class `user`), or nothing, read the moment the
connection is accepted; the controller makes
nothing else the terminal. A service running as the operator — the tool runner, a unit of the account's user
manager — is at most an ordinary call.
+34 -2
View File
@@ -96,7 +96,36 @@ var sessionOf = func(pid int32, uid uint32) string {
if err != nil {
return ""
}
return loginSessionIn(string(raw), uid)
return userSessionIn(string(raw), uid)
}
// sessionsDir is where logind keeps a record of each session it holds. A variable so a test can say.
var sessionsDir = "/run/systemd/sessions"
// userSessionIn is the login session a cgroup names, when logind's own record of it says it is a person's: this
// account's (UID), of class `user` — not a greeter's or the user manager's — and of this very scope. The cgroup
// alone names a scope; only the record says whose session it is. No record, or one that does not say so, is no
// session.
func userSessionIn(cgroup string, uid uint32) string {
scope := loginSessionIn(cgroup, uid)
id, ok := strings.CutPrefix(strings.TrimSuffix(scope, ".scope"), "session-")
if !ok || id == "" || strings.ContainsAny(id, "/.") {
return ""
}
raw, err := os.ReadFile(filepath.Join(sessionsDir, id))
if err != nil {
return ""
}
record := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
record[k] = v
}
}
if record["UID"] != strconv.FormatUint(uint64(uid), 10) || record["CLASS"] != "user" || record["SCOPE"] != scope {
return ""
}
return scope
}
// loginSessionIn is the login session scope a process's cgroup names, for its own account: the unified hierarchy's
@@ -219,6 +248,9 @@ func answer(ctx context.Context, conn *net.UnixConn, held *slots, forward Forwar
"machine's account and nothing was asked: " + err.Error()))
return
}
// And the login session, now: judged as the peer was when it connected, before it can exec or exit while its
// line is read (review of ADR 0272).
session := sessionOf(pid, uid)
if !held.take(uid) {
reply(cli.Refusal(fmt.Sprintf("busy: user id %d has %d lines being asked already, or the machine %d; ask "+
"again when one has ended. Nothing was asked", uid, perAccount, atOnce)))
@@ -239,5 +271,5 @@ func answer(ctx context.Context, conn *net.UnixConn, held *slots, forward Forwar
_ = conn.SetDeadline(time.Now().Add(cli.AskWithin))
asking, cancel := context.WithTimeout(ctx, cli.AskWithin-30*time.Second)
defer cancel()
reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: sessionOf(pid, uid)}))
reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: session}))
}
+60 -1
View File
@@ -249,7 +249,66 @@ func TestTheLoginSessionAskingIsReadFromTheKernel(t *testing.T) {
t.Fatal(err)
}
raw, _ := os.ReadFile("/proc/self/cgroup")
if want := loginSessionIn(string(raw), uint32(os.Getuid())); got.Session != want {
if want := userSessionIn(string(raw), uint32(os.Getuid())); got.Session != want {
t.Fatalf("sent session %q, the kernel says %q", got.Session, want)
}
}
// **A login session is a person's: systemd's own record says so** (review of ADR 0272): the scope the cgroup names
// must be a session logind keeps for this very account, of class `user` — not a greeter's, not the user manager's,
// not one whose record names another account — and read at accept, with the namespaces, before anything else.
func TestALoginSessionIsAUsersByItsOwnRecord(t *testing.T) {
dir := t.TempDir()
was := sessionsDir
sessionsDir = dir
t.Cleanup(func() { sessionsDir = was })
write := func(id, body string) {
if err := os.WriteFile(filepath.Join(dir, id), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("9", "UID=1000\nUSER=op\nCLASS=user\nSCOPE=session-9.scope\nACTIVE=1\n")
write("3", "UID=1000\nCLASS=greeter\nSCOPE=session-3.scope\n")
write("4", "UID=1001\nCLASS=user\nSCOPE=session-4.scope\n")
write("5", "UID=1000\nCLASS=user\nSCOPE=session-6.scope\n")
for _, c := range []struct {
cgroup string
uid uint32
session string
}{
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1000, "session-9.scope"},
{"0::/user.slice/user-1000.slice/x.scope\n", 1000, ""},
{"0::/user.slice/user-1000.slice/session-9.scope\n", 1001, ""}, // a different uid than the scope's slice
{"0::/user.slice/user-1001.slice/session-4.scope\n", 1000, ""}, // another account's session
{"0::/user.slice/user-1000.slice/session-3.scope\n", 1000, ""}, // a greeter's
{"0::/user.slice/user-1000.slice/session-5.scope\n", 1000, ""}, // a record naming another scope
{"0::/user.slice/user-1000.slice/session-7.scope\n", 1000, ""}, // no record at all
{"0::/user.slice/user-1000.slice/session-../9.scope\n", 1000, ""},
} {
if got := userSessionIn(c.cgroup, c.uid); got != c.session {
t.Errorf("%q as uid %d read as %q, want %q", c.cgroup, c.uid, got, c.session)
}
}
}
// The session is read at accept, with the namespaces, and before the line: a peer that execs or exits while its
// line is read is judged as it was when it connected.
func TestTheSessionIsReadBeforeTheLine(t *testing.T) {
var order []string
beforeNS, beforeSession := sameNamespaces, sessionOf
sameNamespaces = func(int32) error { order = append(order, "namespaces"); return nil }
sessionOf = func(int32, uint32) string { order = append(order, "session"); return "session-1.scope" }
t.Cleanup(func() { sameNamespaces, sessionOf = beforeNS, beforeSession })
var got Asked
socket := serving(t, func(_ context.Context, asked Asked) cli.Answer {
order = append(order, "forward")
got = asked
return cli.Answer{}
})
if _, err := cli.Ask(context.Background(), socket, []string{"status"}); err != nil {
t.Fatal(err)
}
if strings.Join(order, ",") != "namespaces,session,forward" || got.Session != "session-1.scope" {
t.Fatalf("read in the order %v, sent %q", order, got.Session)
}
}