caps: restore RestrictSUIDSGID under host_role; renderer validates its own input
Fix-first from Opus review of 141d7f5:
- DynamicUser=yes implies RestrictSUIDSGID=yes; the host_role drop-in set
DynamicUser=no and silently dropped that hardening with it, letting a
host_role app persist setuid/setgid files in its own state or runtime
directory. RestrictSUIDSGID=yes now follows DynamicUser=no in the
host_role block, both service goldens, and spec §4a's two worked
examples, with a new sentence there and migration verify steps
(`systemctl show -p RestrictSUIDSGID`) in §5b/§5c.
- RenderService/RenderSocket now return ([]byte, error): a non-nil Set is
run through ValidCapabilities (RenderSocket reuses validPorts directly,
since it has no app-dependent host_role business) before anything
renders, closing the direct-injection path where a *Set built by hand
never passed through UnmarshalJSON's strict decode. A nil Set still
returns (nil, nil) for an unrelated reason ("nothing to render", not
"remove") — doc comments now say so explicitly, since the converge pass
must branch on Set == nil before calling, never after.
- New tests kill two surviving asymmetric-declared/granted mutations: a
lingering host_role grant with no matching declaration must not
materialize DynamicUser=no/RestrictSUIDSGID=yes/User=/Group=, and a port
grant naming a port the manifest never declared (or only one of two
declared ports) must render exactly the intersection.
- Minors: a comment on RuntimeDirectory noting it is list-valued like
ExecStart but needs no reset today; belt-and-braces port de-dup in
RenderSocket after sorting; spec §3c now states that "an operator
drop-in wins every conflict" holds for scalars but list-valued
directives need their own reset line in 90-operator.conf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+276
−47
docs/superpowers/specs/2026-08-03-app-capabilities-design.md+30 −6internal/caps/render.go+74 −15internal/caps/render_test.go+170 −26internal/caps/testdata/console.service.conf+1 −0internal/caps/testdata/keymail.service.conf+1 −0
diff --git a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md| index 484c2cedc..2c11bf76b 100644 |
| --- a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md |
| @@ -261,6 +261,16 @@ reported and skipped. `10-` sorts before `90-`, so an operator drop-in wins |
| every conflict, which is the escape hatch for anything this vocabulary |
| cannot say. |
| +"An operator drop-in wins every conflict" holds outright for scalar |
| +directives (`MemoryMax=`, `DynamicUser=`, and so on) — the last one systemd |
| +reads is the one that applies. It does **not** hold for free for |
| +list-valued directives (`ExecStart=`, `RuntimeDirectory=`): systemd |
| +*appends* to a list-valued directive across drop-ins rather than replacing |
| +it, so an operator wanting to override one of those needs their own empty |
| +reset line (`ExecStart=`) in `90-operator.conf` before the replacement |
| +value — exactly the same reset this package's own `10-` drop-in uses |
| +against the template. |
| + |
| One generated file per unit, not one per capability. Removal semantics are |
| the reason: a capability disappearing from a manifest is then one rewrite of |
| one file, with no orphan-file reaping pass to get wrong, and "which |
| @@ -291,6 +301,7 @@ KillMode=control-group |
| TasksMax=1024 |
| MemoryMax=1G |
| DynamicUser=no |
| +RestrictSUIDSGID=yes |
| User=carlos-keymail |
| Group=carlos-keymail |
| ``` |
| @@ -303,15 +314,24 @@ and `carlos-app@keymail.dev.socket.d/10-carlos-capabilities.conf`: |
| ListenStream=25 |
| ``` |
| -console's is three lines plus the header: the template's `ExecStart` already |
| +console's is four lines plus the header: the template's `ExecStart` already |
| says `serve`, `daemon` adds no directives (it is enable/start/restart |
| behaviour, not unit text), and only `host_role` writes anything — |
| -`DynamicUser=no`, `User=carlos-console`, `Group=carlos-console`. |
| +`DynamicUser=no`, `RestrictSUIDSGID=yes`, `User=carlos-console`, |
| +`Group=carlos-console`. |
| The empty `ExecStart=` before the real one is systemd's documented way to |
| reset a list-valued directive from a drop-in; without it the unit would have |
| two ExecStart lines and refuse to start. |
| +`DynamicUser=yes` *implies* `RestrictSUIDSGID=yes` — systemd turns the |
| +restriction on for free the moment it mints an ephemeral uid. Setting |
| +`DynamicUser=no` drops that implication silently along with it, so the |
| +drop-in restores `RestrictSUIDSGID=yes` explicitly rather than let a |
| +`host_role` app quietly regain the ability to persist setuid/setgid files |
| +in its own state or runtime directory: an app that has been handed the |
| +box's cloud instance role has no business minting them. |
| + |
| ### 4b. `host_role`: a static user, and why |
| `DynamicUser=yes` mints an ephemeral uid per start. The box's IMDS gate is |
| @@ -510,7 +530,10 @@ Steps, per box: |
| `carlos add` re-upsert is the wrong tool — it resets the channel, the |
| issue #14 lesson) → `carlos adopt` (writes the drop-ins, creates the |
| user, enables and starts `carlos-app@<host>.service`) → verify: unit |
| - active, `/healthz`-equivalent through the edge, sign-in round trip. |
| + active, `/healthz`-equivalent through the edge, sign-in round trip, and |
| + `systemctl show -p RestrictSUIDSGID carlos-app@<host>.service` reports |
| + `RestrictSUIDSGID=yes` (confirms the static-user drop-in restored what |
| + dropping `DynamicUser` took away). |
| 4. Remove `/etc/systemd/system/console.service`. Confirm the per-unit |
| metrics table now lists `carlos-app@<host>.service`. |
| @@ -546,9 +569,10 @@ Steps, once the flag is merged and shipped: |
| bounce) → copy `/opt/keymail/data` to `/var/lib/carlos-app/keymail.dev`, |
| owned `carlos-keymail`, `0700` → start. Verify **before declaring |
| success**: the `backup: restored …` log lines, a federation lookup 200, |
| - the instance children present, `/run/keymail/*.sock` populated. F4's |
| - restore-succeeds-empty failure mode makes "the unit is active" an |
| - insufficient check. |
| + the instance children present, `/run/keymail/*.sock` populated, and |
| + `systemctl show -p RestrictSUIDSGID carlos-app@keymail.dev.service` |
| + reports `RestrictSUIDSGID=yes`. F4's restore-succeeds-empty failure mode |
| + makes "the unit is active" an insufficient check. |
| 5. `status.keymail.dev` becomes (or stays) a `kind=service` proxy route to |
| the same upstream. |
diff --git a/internal/caps/render.go b/internal/caps/render.go| index fa480324b..2cdadc905 100644 |
| --- a/internal/caps/render.go |
| +++ b/internal/caps/render.go |
| @@ -24,10 +24,27 @@ const execStartPath = "/opt/carlos/live/%i" |
| // RenderService returns the [Service] drop-in body for the capabilities |
| // that are BOTH declared by s and (for the operator-consented ones) |
| -// granted by g, or nil when the effective set writes no [Service] |
| -// directive at all. Nil means "remove the generated file", not "write an |
| -// empty stanza" — one generated file per unit, and removal semantics are |
| -// the reason (spec §3c). |
| +// granted by g, or (nil, nil) when the effective set writes no [Service] |
| +// directive at all — that is the "remove the generated file" signal Task 6 |
| +// acts on, not "write an empty stanza": one generated file per unit. |
| +// |
| +// A nil s ALSO returns (nil, nil), but for an entirely different reason: |
| +// there is nothing to render because no manifest resolved. That is NOT a |
| +// removal signal — the converge pass (Task 6) must branch on p.Set == nil |
| +// BEFORE calling this function and leave every drop-in exactly as it is, |
| +// never treating this function's (nil, nil) as license to delete anything. |
| +// The two states look identical from this function's return value on |
| +// purpose; only the caller, which still has the original *Set, can tell |
| +// them apart. |
| +// |
| +// A non-nil s is run through ValidCapabilities before anything is |
| +// rendered, and that error is returned unchanged. This closes the |
| +// direct-injection path: a *Set built by hand — never having passed |
| +// through UnmarshalJSON's strict decode — could otherwise carry a Command |
| +// like "relay\nUser=root", or an empty app that would render |
| +// "User=carlos-". Ship and adopt already share this gate (spec §3b); the |
| +// renderer is a third caller of the same one, not a second copy of the |
| +// rules. |
| // |
| // Directive order is FIXED: command, then runtime_dir, then children, then |
| // host_role. `daemon` and `data` are self-service capabilities that emit no |
| @@ -35,9 +52,12 @@ const execStartPath = "/opt/carlos/live/%i" |
| // behaviour (Task 6 owns it), and `data`'s path is already the template's |
| // own StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see |
| // RenderSocket. |
| -func RenderService(app string, s *Set, g Grants) []byte { |
| +func RenderService(app string, s *Set, g Grants) ([]byte, error) { |
| if s == nil { |
| - return nil |
| + return nil, nil |
| + } |
| + if err := ValidCapabilities(app, s); err != nil { |
| + return nil, err |
| } |
| var lines []string |
| @@ -53,6 +73,10 @@ func RenderService(app string, s *Set, g Grants) []byte { |
| } |
| if s.RuntimeDir != "" { |
| + // RuntimeDirectory is list-valued exactly like ExecStart, but no |
| + // reset line is needed here because the template itself sets no |
| + // RuntimeDirectory= directive to override. A future template |
| + // revision that adds one would need to revisit this. |
| lines = append(lines, |
| "RuntimeDirectory="+s.RuntimeDir, |
| "RuntimeDirectoryMode=0700", |
| @@ -74,23 +98,46 @@ func RenderService(app string, s *Set, g Grants) []byte { |
| user := AppUser(app) |
| lines = append(lines, |
| "DynamicUser=no", |
| + // DynamicUser=yes implies RestrictSUIDSGID=yes; dropping |
| + // DynamicUser silently drops that hardening along with it, so |
| + // this drop-in restores it explicitly — a host_role app has |
| + // no business persisting setuid/setgid files in its own |
| + // state or runtime directory (spec §4a, §4b). |
| + "RestrictSUIDSGID=yes", |
| "User="+user, |
| "Group="+user, |
| ) |
| } |
| if len(lines) == 0 { |
| - return nil |
| + return nil, nil |
| } |
| - return dropIn("[Service]", lines) |
| + return dropIn("[Service]", lines), nil |
| } |
| -// RenderSocket returns the [Socket] drop-in body, or nil when no declared |
| -// port is also granted. keymail with no `port:25` grant serves HTTP and |
| -// does not bind the MX — it does not refuse to start (spec §2). |
| -func RenderSocket(s *Set, g Grants) []byte { |
| +// RenderSocket returns the [Socket] drop-in body, or (nil, nil) when no |
| +// declared port is also granted. keymail with no `port:25` grant serves |
| +// HTTP and does not bind the MX — it does not refuse to start (spec §2). |
| +// |
| +// The same nil-vs-nil-input caveat RenderService documents applies here: a |
| +// nil s returns (nil, nil) meaning "nothing to render" (leave-alone), and a |
| +// non-nil s rendering (nil, nil) means "remove the generated file" — the |
| +// converge pass must branch on p.Set == nil before calling, never after. |
| +// |
| +// A non-nil s has its ports validated through validPorts — the same |
| +// function ValidCapabilities uses — before anything is rendered, closing |
| +// the same direct-injection path RenderService closes (a hand-built Set |
| +// naming a non-privileged or reserved port). This intentionally does not |
| +// call the full ValidCapabilities, which needs an app name for the |
| +// host_role check that has nothing to do with sockets; validPorts is |
| +// already the one place that rule lives, and reusing it keeps a single |
| +// source of truth instead of a second copy of the ports rules. |
| +func RenderSocket(s *Set, g Grants) ([]byte, error) { |
| if s == nil { |
| - return nil |
| + return nil, nil |
| + } |
| + if err := validPorts(s.Ports); err != nil { |
| + return nil, err |
| } |
| var ports []int |
| @@ -100,15 +147,27 @@ func RenderSocket(s *Set, g Grants) []byte { |
| } |
| } |
| if len(ports) == 0 { |
| - return nil |
| + return nil, nil |
| } |
| sort.Ints(ports) |
| + // Belt-and-braces de-dup: validPorts above already refuses a |
| + // duplicate declared port, so two adjacent-equal entries can't reach |
| + // here from a validated Set — kept anyway so this function does not |
| + // quietly assume validation happened somewhere else. |
| + out := ports[:1] |
| + for _, p := range ports[1:] { |
| + if p != out[len(out)-1] { |
| + out = append(out, p) |
| + } |
| + } |
| + ports = out |
| + |
| lines := make([]string, len(ports)) |
| for i, p := range ports { |
| lines[i] = fmt.Sprintf("ListenStream=%d", p) |
| } |
| - return dropIn("[Socket]", lines) |
| + return dropIn("[Socket]", lines), nil |
| } |
| // dropIn assembles the marker line, the section header, and the directive |
diff --git a/internal/caps/render_test.go b/internal/caps/render_test.go| index 3b8fec5dc..870a5defd 100644 |
| --- a/internal/caps/render_test.go |
| +++ b/internal/caps/render_test.go |
| @@ -58,7 +58,10 @@ func readGolden(t *testing.T, name string) []byte { |
| } |
| func TestRenderServiceMatchesKeymailGolden(t *testing.T) { |
| - got := RenderService("keymail", keymailSet(t), keymailGrants()) |
| + got, err := RenderService("keymail", keymailSet(t), keymailGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| want := readGolden(t, "keymail.service.conf") |
| if !bytes.Equal(got, want) { |
| t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want) |
| @@ -66,7 +69,10 @@ func TestRenderServiceMatchesKeymailGolden(t *testing.T) { |
| } |
| func TestRenderSocketMatchesKeymailGolden(t *testing.T) { |
| - got := RenderSocket(keymailSet(t), keymailGrants()) |
| + got, err := RenderSocket(keymailSet(t), keymailGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderSocket: %v", err) |
| + } |
| want := readGolden(t, "keymail.socket.conf") |
| if !bytes.Equal(got, want) { |
| t.Fatalf("RenderSocket mismatch:\n got: %q\n want: %q", got, want) |
| @@ -74,7 +80,10 @@ func TestRenderSocketMatchesKeymailGolden(t *testing.T) { |
| } |
| func TestRenderServiceMatchesConsoleGolden(t *testing.T) { |
| - got := RenderService("console", consoleSet(t), consoleGrants()) |
| + got, err := RenderService("console", consoleSet(t), consoleGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| want := readGolden(t, "console.service.conf") |
| if !bytes.Equal(got, want) { |
| t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want) |
| @@ -99,8 +108,14 @@ func TestRenderIsDeterministic(t *testing.T) { |
| g := Grants{Ports: map[int]bool{25: true, 587: true}} |
| - gotA := RenderSocket(&sa, g) |
| - gotB := RenderSocket(&sb, g) |
| + gotA, err := RenderSocket(&sa, g) |
| + if err != nil { |
| + t.Fatalf("RenderSocket(a): %v", err) |
| + } |
| + gotB, err := RenderSocket(&sb, g) |
| + if err != nil { |
| + t.Fatalf("RenderSocket(b): %v", err) |
| + } |
| if !bytes.Equal(gotA, gotB) { |
| t.Fatalf("render not deterministic across declaration order:\n a: %q\n b: %q", gotA, gotB) |
| } |
| @@ -111,13 +126,22 @@ func TestRenderIsDeterministic(t *testing.T) { |
| } |
| // Rendering again from the same Set must reproduce the identical bytes. |
| - again := RenderSocket(&sa, g) |
| + again, err := RenderSocket(&sa, g) |
| + if err != nil { |
| + t.Fatalf("RenderSocket(a) again: %v", err) |
| + } |
| if !bytes.Equal(gotA, again) { |
| t.Fatalf("render not repeatable: %q vs %q", gotA, again) |
| } |
| - svcA := RenderService("keymail", &sa, g) |
| - svcAgain := RenderService("keymail", &sa, g) |
| + svcA, err := RenderService("keymail", &sa, g) |
| + if err != nil { |
| + t.Fatalf("RenderService(a): %v", err) |
| + } |
| + svcAgain, err := RenderService("keymail", &sa, g) |
| + if err != nil { |
| + t.Fatalf("RenderService(a) again: %v", err) |
| + } |
| if !bytes.Equal(svcA, svcAgain) { |
| t.Fatalf("service render not repeatable: %q vs %q", svcA, svcAgain) |
| } |
| @@ -125,11 +149,15 @@ func TestRenderIsDeterministic(t *testing.T) { |
| // TestUngrantedCapabilitiesMaterializeNothing: keymail's full set with no |
| // grants at all still renders the self-service lines (command, runtime_dir, |
| -// children) but none of the operator-consented ones — no DynamicUser/User/ |
| -// Group, and a nil socket body. keymail with no port:25 grant serves HTTP |
| -// and does not bind the MX; it does not refuse to start. |
| +// children) but none of the operator-consented ones — no DynamicUser/ |
| +// RestrictSUIDSGID/User/Group, and a nil socket body. keymail with no |
| +// port:25 grant serves HTTP and does not bind the MX; it does not refuse |
| +// to start. |
| func TestUngrantedCapabilitiesMaterializeNothing(t *testing.T) { |
| - got := RenderService("keymail", keymailSet(t), Grants{}) |
| + got, err := RenderService("keymail", keymailSet(t), Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| if got == nil { |
| t.Fatal("service body is nil, want the self-service lines") |
| } |
| @@ -143,48 +171,161 @@ func TestUngrantedCapabilitiesMaterializeNothing(t *testing.T) { |
| t.Errorf("service body missing self-service line %q:\n%s", want, got) |
| } |
| } |
| - for _, unwanted := range []string{"DynamicUser", "User=", "Group="} { |
| + for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group="} { |
| if bytes.Contains(got, []byte(unwanted)) { |
| t.Errorf("service body has ungranted host_role directive %q:\n%s", unwanted, got) |
| } |
| } |
| - if sock := RenderSocket(keymailSet(t), Grants{}); sock != nil { |
| + sock, err := RenderSocket(keymailSet(t), Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderSocket: %v", err) |
| + } |
| + if sock != nil { |
| t.Fatalf("socket body = %q, want nil when port:25 is not granted", sock) |
| } |
| } |
| +// TestHostRoleGrantLingersButManifestNoLongerDeclaresFallsBackToDynamicUser |
| +// covers the asymmetric case where an operator grant survives a build that |
| +// stopped asking for it: the grant alone must never materialize |
| +// DynamicUser=no/RestrictSUIDSGID=yes/User=/Group= — only a Set that STILL |
| +// declares host_role does. |
| +func TestHostRoleGrantLingersButManifestNoLongerDeclaresFallsBackToDynamicUser(t *testing.T) { |
| + s := &Set{V: 1, Command: "relay"} |
| + got, err := RenderService("keymail", s, Grants{HostRole: true}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + for _, want := range []string{"ExecStart=\n", "ExecStart=/opt/carlos/live/%i relay\n"} { |
| + if !bytes.Contains(got, []byte(want)) { |
| + t.Errorf("service body missing %q:\n%s", want, got) |
| + } |
| + } |
| + for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group="} { |
| + if bytes.Contains(got, []byte(unwanted)) { |
| + t.Errorf("service body has host_role directive %q though the manifest no longer declares host_role:\n%s", unwanted, got) |
| + } |
| + } |
| +} |
| + |
| +// TestRenderSocketOnlyEmitsGrantedPorts: a Set declaring two ports with |
| +// only one of them granted must emit exactly that one ListenStream= line. |
| +func TestRenderSocketOnlyEmitsGrantedPorts(t *testing.T) { |
| + s := &Set{V: 1, Ports: []Port{{Port: 25, Name: "mx"}, {Port: 587, Name: "submission"}}} |
| + got, err := RenderSocket(s, Grants{Ports: map[int]bool{587: true}}) |
| + if err != nil { |
| + t.Fatalf("RenderSocket: %v", err) |
| + } |
| + want := []byte(Marker + "\n[Socket]\nListenStream=587\n") |
| + if !bytes.Equal(got, want) { |
| + t.Fatalf("got %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestRenderSocketGrantOfAnUndeclaredPortRendersNil: a grant naming a port |
| +// the Set never declared materializes nothing — the grant alone is not a |
| +// declaration. |
| +func TestRenderSocketGrantOfAnUndeclaredPortRendersNil(t *testing.T) { |
| + s := &Set{V: 1, Ports: []Port{{Port: 25, Name: "mx"}}} |
| + got, err := RenderSocket(s, Grants{Ports: map[int]bool{587: true}}) |
| + if err != nil { |
| + t.Fatalf("RenderSocket: %v", err) |
| + } |
| + if got != nil { |
| + t.Fatalf("got %q, want nil — the grant names a port the manifest never declared", got) |
| + } |
| +} |
| + |
| // TestEmptyEffectiveSetRendersNil: daemon and data emit no directives, and |
| // nothing else is declared, so both bodies are nil. |
| func TestEmptyEffectiveSetRendersNil(t *testing.T) { |
| s := &Set{V: 1, Daemon: true, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{"console.db"}}} |
| - if got := RenderService("console", s, Grants{}); got != nil { |
| + got, err := RenderService("console", s, Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + if got != nil { |
| t.Fatalf("RenderService = %q, want nil", got) |
| } |
| - if got := RenderSocket(s, Grants{}); got != nil { |
| - t.Fatalf("RenderSocket = %q, want nil", got) |
| + sock, err := RenderSocket(s, Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderSocket: %v", err) |
| + } |
| + if sock != nil { |
| + t.Fatalf("RenderSocket = %q, want nil", sock) |
| } |
| } |
| // TestNilSetRendersNil: a nil *Set (every manifest before this feature, and |
| -// every route that never declared capabilities) must render nil, not panic. |
| +// every route that never declared capabilities) must render (nil, nil), |
| +// not panic and not error. |
| func TestNilSetRendersNil(t *testing.T) { |
| - if got := RenderService("console", nil, Grants{HostRole: true}); got != nil { |
| + got, err := RenderService("console", nil, Grants{HostRole: true}) |
| + if err != nil { |
| + t.Fatalf("RenderService(nil): %v", err) |
| + } |
| + if got != nil { |
| t.Fatalf("RenderService(nil) = %q, want nil", got) |
| } |
| - if got := RenderSocket(nil, Grants{Ports: map[int]bool{25: true}}); got != nil { |
| - t.Fatalf("RenderSocket(nil) = %q, want nil", got) |
| + sock, err := RenderSocket(nil, Grants{Ports: map[int]bool{25: true}}) |
| + if err != nil { |
| + t.Fatalf("RenderSocket(nil): %v", err) |
| + } |
| + if sock != nil { |
| + t.Fatalf("RenderSocket(nil) = %q, want nil", sock) |
| + } |
| +} |
| + |
| +// TestRenderServiceRefusesAnInvalidSet closes the direct-injection path: a |
| +// *Set built by hand, never having passed through UnmarshalJSON's strict |
| +// decode, still has to clear ValidCapabilities before anything renders. A |
| +// command with an embedded newline would otherwise inject a second |
| +// directive line into the unit. |
| +func TestRenderServiceRefusesAnInvalidSet(t *testing.T) { |
| + bad := &Set{V: 1, Command: "relay\nUser=root"} |
| + if _, err := RenderService("keymail", bad, Grants{}); err == nil { |
| + t.Fatal("RenderService accepted a command with an embedded newline") |
| + } |
| +} |
| + |
| +// TestRenderServiceRefusesHostRoleWithAnEmptyApp: an empty app would |
| +// otherwise render "User=carlos-" — a real, if odd, system user name that |
| +// names no app. ValidCapabilities's app-charset check refuses it here. |
| +func TestRenderServiceRefusesHostRoleWithAnEmptyApp(t *testing.T) { |
| + s := &Set{V: 1, HostRole: true} |
| + if _, err := RenderService("", s, Grants{HostRole: true}); err == nil { |
| + t.Fatal("RenderService accepted host_role with an empty app name") |
| + } |
| +} |
| + |
| +// TestRenderSocketRefusesAnInvalidSet: the same direct-injection path, |
| +// for RenderSocket — a non-privileged or reserved port built by hand must |
| +// still be refused before it renders a ListenStream= line. |
| +func TestRenderSocketRefusesAnInvalidSet(t *testing.T) { |
| + bad := &Set{V: 1, Ports: []Port{{Port: 8080}}} |
| + if _, err := RenderSocket(bad, Grants{Ports: map[int]bool{8080: true}}); err == nil { |
| + t.Fatal("RenderSocket accepted a non-privileged port") |
| } |
| } |
| // TestRenderStartsWithTheMarker checks every non-nil body this file |
| // produces opens with the marker line. |
| func TestRenderStartsWithTheMarker(t *testing.T) { |
| - bodies := [][]byte{ |
| - RenderService("keymail", keymailSet(t), keymailGrants()), |
| - RenderSocket(keymailSet(t), keymailGrants()), |
| - RenderService("console", consoleSet(t), consoleGrants()), |
| + svc, err := RenderService("keymail", keymailSet(t), keymailGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService(keymail): %v", err) |
| + } |
| + sock, err := RenderSocket(keymailSet(t), keymailGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderSocket(keymail): %v", err) |
| + } |
| + consoleSvc, err := RenderService("console", consoleSet(t), consoleGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService(console): %v", err) |
| } |
| + |
| + bodies := [][]byte{svc, sock, consoleSvc} |
| for i, b := range bodies { |
| if b == nil { |
| t.Fatalf("body %d is nil, want non-nil to check its marker", i) |
| @@ -201,7 +342,10 @@ func TestRenderStartsWithTheMarker(t *testing.T) { |
| // already says. |
| func TestCommandIdenticalToTemplateDefaultStillEmitsReset(t *testing.T) { |
| s := &Set{V: 1, Command: "serve"} |
| - got := RenderService("console", s, Grants{}) |
| + got, err := RenderService("console", s, Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| want := []byte(Marker + "\n[Service]\nExecStart=\nExecStart=/opt/carlos/live/%i serve\n") |
| if !bytes.Equal(got, want) { |
| t.Fatalf("got %q, want %q", got, want) |
diff --git a/internal/caps/testdata/console.service.conf b/internal/caps/testdata/console.service.conf| index 6e6c6f4bb..bf1d4c8f6 100644 |
| --- a/internal/caps/testdata/console.service.conf |
| +++ b/internal/caps/testdata/console.service.conf |
| @@ -1,5 +1,6 @@ |
| # GENERATED by carlos adopt — do not hand-edit |
| [Service] |
| DynamicUser=no |
| +RestrictSUIDSGID=yes |
| User=carlos-console |
| Group=carlos-console |
diff --git a/internal/caps/testdata/keymail.service.conf b/internal/caps/testdata/keymail.service.conf| index 79c15098c..766f71f78 100644 |
| --- a/internal/caps/testdata/keymail.service.conf |
| +++ b/internal/caps/testdata/keymail.service.conf |
| @@ -9,5 +9,6 @@ KillMode=control-group |
| TasksMax=1024 |
| MemoryMax=1G |
| DynamicUser=no |
| +RestrictSUIDSGID=yes |
| User=carlos-keymail |
| Group=carlos-keymail |