carlos / platform Public

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>
Paul Campbell ddbe55f9baad44bfd37d758a78265659ad98b3bb parent 141d7f5
5 files changed, +276 −47
  • docs/superpowers/specs/2026-08-03-app-capabilities-design.md +30 −6
  • internal/caps/render.go +74 −15
  • internal/caps/render_test.go +170 −26
  • internal/caps/testdata/console.service.conf +1 −0
  • internal/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