carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package caps
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "strings"
8 "testing"
9)
10
11// keymailSet is the full declared set the worked example in spec §4a
12// renders from: command, runtime_dir, children, host_role and a single
13// privileged port, plus a `data` capability that (being self-service and
14// directive-free) must never show up in the rendered bytes.
15func keymailSet(t *testing.T) *Set {
16 t.Helper()
17 var s Set
18 raw := `{
19 "v": 1,
20 "command": "relay",
21 "daemon": true,
22 "children": true,
23 "runtime_dir": "keymail",
24 "ports": [{"port": 25, "name": "mx"}],
25 "data": {"replicate": "platform", "dbs": ["registry.db"]},
26 "host_role": true
27 }`
28 if err := json.Unmarshal([]byte(raw), &s); err != nil {
29 t.Fatalf("decode keymail set: %v", err)
30 }
31 return &s
32}
33
34func keymailGrants() Grants {
35 return Grants{Ports: map[int]bool{25: true}, HostRole: true}
36}
37
38func consoleSet(t *testing.T) *Set {
39 t.Helper()
40 var s Set
41 raw := `{"v": 1, "daemon": true, "host_role": true}`
42 if err := json.Unmarshal([]byte(raw), &s); err != nil {
43 t.Fatalf("decode console set: %v", err)
44 }
45 return &s
46}
47
48func consoleGrants() Grants {
49 return Grants{HostRole: true}
50}
51
52// gotenbergSet is the worked example for the doc_engine capability: a
53// document-conversion orchestrator that spawns children (Chromium,
54// LibreOffice) and is granted the engine trade.
55func gotenbergSet(t *testing.T) *Set {
56 t.Helper()
57 var s Set
58 raw := `{
59 "v": 1,
60 "command": "serve",
61 "daemon": true,
62 "children": true,
63 "doc_engine": true
64 }`
65 if err := json.Unmarshal([]byte(raw), &s); err != nil {
66 t.Fatalf("decode gotenberg set: %v", err)
67 }
68 return &s
69}
70
71func gotenbergGrants() Grants {
72 return Grants{DocEngine: true}
73}
74
75func readGolden(t *testing.T, name string) []byte {
76 t.Helper()
77 b, err := os.ReadFile("testdata/" + name)
78 if err != nil {
79 t.Fatalf("read golden %s: %v", name, err)
80 }
81 return b
82}
83
84func TestRenderServiceMatchesKeymailGolden(t *testing.T) {
85 got, err := RenderService("keymail", keymailSet(t), keymailGrants())
86 if err != nil {
87 t.Fatalf("RenderService: %v", err)
88 }
89 want := readGolden(t, "keymail.service.conf")
90 if !bytes.Equal(got, want) {
91 t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want)
92 }
93}
94
95func TestRenderSocketMatchesKeymailGolden(t *testing.T) {
96 got, err := RenderSocket(keymailSet(t), keymailGrants())
97 if err != nil {
98 t.Fatalf("RenderSocket: %v", err)
99 }
100 want := readGolden(t, "keymail.socket.conf")
101 if !bytes.Equal(got, want) {
102 t.Fatalf("RenderSocket mismatch:\n got: %q\n want: %q", got, want)
103 }
104}
105
106func TestRenderServiceMatchesConsoleGolden(t *testing.T) {
107 got, err := RenderService("console", consoleSet(t), consoleGrants())
108 if err != nil {
109 t.Fatalf("RenderService: %v", err)
110 }
111 want := readGolden(t, "console.service.conf")
112 if !bytes.Equal(got, want) {
113 t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want)
114 }
115}
116
117func TestRenderServiceMatchesGotenbergGolden(t *testing.T) {
118 got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
119 if err != nil {
120 t.Fatalf("RenderService: %v", err)
121 }
122 want := readGolden(t, "gotenberg.service.conf")
123 if !bytes.Equal(got, want) {
124 t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want)
125 }
126}
127
128// TestDocEngineTradeIsAtomic is the load-bearing property of this capability:
129// the resource relaxation and the egress fence land together or not at all. A
130// granted engine that got JIT and memory WITHOUT the private-network fence
131// would be an open SSRF proxy into the VPC with a bigger blast radius than the
132// stock tenant — the exact opposite of the trade the operator consented to. So
133// every relaxation directive must coexist with the fence in the same bytes.
134func TestDocEngineTradeIsAtomic(t *testing.T) {
135 got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
136 if err != nil {
137 t.Fatalf("RenderService: %v", err)
138 }
139 relaxations := []string{"MemoryDenyWriteExecute=no", "MemoryMax=" + docEngineMemoryMax, "TasksMax=" + docEngineTasksMax}
140 fence := "IPAddressDeny=0.0.0.0/8"
141 hasFence := bytes.Contains(got, []byte(fence))
142 if !hasFence {
143 t.Fatalf("engine drop-in has no egress fence:\n%s", got)
144 }
145 for _, r := range relaxations {
146 if !bytes.Contains(got, []byte(r)) {
147 t.Errorf("engine drop-in is missing relaxation %q (the fence is present, so the trade is half-written):\n%s", r, got)
148 }
149 }
150}
151
152// TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback pins the two halves
153// of the fence that are each independently load-bearing and each easy to get
154// wrong:
155//
156// - every private/reserved range an SSRF'd engine would be pointed at is
157// denied, IMDS named explicitly (not only covered by its supernet);
158// - loopback is NEVER denied, because Gotenberg drives its own Chromium and
159// LibreOffice over 127.0.0.1 and reaches its DNS stub resolver there too.
160// Fencing loopback bricks the service and, on AWS, breaks name resolution
161// outright (the VPC resolver is at VPC-base+2, inside the denied RFC1918
162// ranges, so the box must hand the tenant a loopback stub).
163func TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback(t *testing.T) {
164 got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
165 if err != nil {
166 t.Fatalf("RenderService: %v", err)
167 }
168 deny := ""
169 for _, line := range strings.Split(string(got), "\n") {
170 if strings.HasPrefix(line, "IPAddressDeny=") && line != "IPAddressDeny=" {
171 deny = line
172 }
173 }
174 for _, mustDeny := range []string{
175 "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // RFC1918
176 "169.254.169.254/32", "fd00:ec2::254/128", // IMDS, named explicitly
177 "169.254.0.0/16", "fe80::/10", // link-local
178 "100.64.0.0/10", // CGNAT (cloud internal services)
179 "fc00::/7", // IPv6 ULA
180 "192.0.0.0/24", "240.0.0.0/4", // IETF assignments, reserved
181 "198.18.0.0/15", // benchmarking / common NAT-proxy range
182 "64:ff9b::/96", // NAT64 well-known prefix (translates into private IPv4)
183 "fec0::/10", // IPv6 site-local (deprecated but routable on some VPCs)
184 } {
185 if !strings.Contains(deny, mustDeny) {
186 t.Errorf("egress fence does not deny %q:\n%s", mustDeny, deny)
187 }
188 }
189 for _, mustNotDeny := range []string{"127.0.0.0/8", "127.0.0.1", "::1/128", "localhost"} {
190 if strings.Contains(deny, mustNotDeny) {
191 t.Errorf("egress fence denies loopback %q — Gotenberg talks to its own engines and its DNS stub resolver over loopback; fencing it bricks the service:\n%s", mustNotDeny, deny)
192 }
193 }
194}
195
196// TestDocEngineBandReplacesTheChildrenBand pins the scalar-render-once
197// property: a conversion orchestrator declares children AND doc_engine, and the
198// engine band replaces the children band's memory/tasks rather than stacking a
199// second assignment on top. The generated unit text must never contain two
200// MemoryMax or two TasksMax lines, so it does not lean on systemd's
201// last-assignment-wins rule — a reader sees the effective value directly.
202// KillMode, which is children's "stop kills the tree" promise and not a
203// resource scalar, still renders.
204func TestDocEngineBandReplacesTheChildrenBand(t *testing.T) {
205 got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
206 if err != nil {
207 t.Fatalf("RenderService: %v", err)
208 }
209 for _, scalar := range []string{"MemoryMax=", "TasksMax=", "MemoryHigh="} {
210 n := 0
211 for _, line := range strings.Split(string(got), "\n") {
212 if strings.HasPrefix(line, scalar) {
213 n++
214 }
215 }
216 if n != 1 {
217 t.Errorf("engine drop-in renders %q %d times, want exactly once (the children band must be replaced, not stacked):\n%s", scalar, n, got)
218 }
219 }
220 if !bytes.Contains(got, []byte("KillMode=control-group")) {
221 t.Errorf("engine drop-in lost children's KillMode:\n%s", got)
222 }
223 if bytes.Contains(got, []byte("MemoryMax=1G")) {
224 t.Errorf("engine drop-in still carries the children tier's 1G — the engine band did not replace it:\n%s", got)
225 }
226}
227
228// TestDocEngineUngrantedRendersNoRelaxationAndNoFence is the asymmetric case:
229// a manifest declaring doc_engine on a box that has NOT granted it materializes
230// neither the relaxation nor the fence. The engine simply will not start under
231// the stock template (MemoryDenyWriteExecute stays on, the default tier
232// OOM-kills it) — which is the safe direction, and ungrantedAdvice names the
233// fix. It must never render the fence alone, either: a fence with no engine is
234// a lie in the unit text.
235func TestDocEngineUngrantedRendersNoRelaxationAndNoFence(t *testing.T) {
236 got, err := RenderService("gotenberg", gotenbergSet(t), Grants{})
237 if err != nil {
238 t.Fatalf("RenderService: %v", err)
239 }
240 for _, unwanted := range []string{"MemoryDenyWriteExecute=no", "IPAddressDeny=0.0.0.0/8", "MemoryMax=" + docEngineMemoryMax} {
241 if bytes.Contains(got, []byte(unwanted)) {
242 t.Errorf("ungranted doc_engine rendered %q:\n%s", unwanted, got)
243 }
244 }
245 // children is still declared and self-service, so its band renders.
246 if !bytes.Contains(got, []byte("MemoryMax=1G")) {
247 t.Errorf("ungranted doc_engine lost the children band it still declares:\n%s", got)
248 }
249}
250
251// TestDocEngineGrantAloneRendersNothing mirrors the host_role grant-lingers
252// case: a grant with no matching declaration materializes nothing. The grant is
253// not a declaration.
254func TestDocEngineGrantAloneRendersNothing(t *testing.T) {
255 s := &Set{V: 1, Command: "serve"}
256 got, err := RenderService("gotenberg", s, Grants{DocEngine: true})
257 if err != nil {
258 t.Fatalf("RenderService: %v", err)
259 }
260 if bytes.Contains(got, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(got, []byte("MemoryDenyWriteExecute=no")) {
261 t.Errorf("a doc_engine grant with no declaration rendered engine directives:\n%s", got)
262 }
263}
264
265// TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock pins the case the
266// host_role+doc_engine refusal at declaration time cannot cover by itself: the
267// registry's caps column CAN hold both tokens (ValidGrantToken accepts each,
268// and nothing forbids the pair in the column — the refusal lives in
269// ValidCapabilities, which sees the manifest). So a box whose column says
270// "doc-engine,host-role" against a manifest declaring only one must render that
271// one block and NOTHING of the other — in particular, a doc_engine declaration
272// must never pick up host_role's IPAddressAllow, which in systemd's BPF
273// firewall would win over the fence's IPAddressDeny and re-open IMDS.
274func TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock(t *testing.T) {
275 both := Grants{DocEngine: true, HostRole: true}
276
277 engineOnly, err := RenderService("gotenberg", &Set{V: 1, Command: "serve", DocEngine: true}, both)
278 if err != nil {
279 t.Fatalf("RenderService(doc_engine only): %v", err)
280 }
281 if !bytes.Contains(engineOnly, []byte("IPAddressDeny=0.0.0.0/8")) {
282 t.Errorf("doc_engine declaration under both grants lost its fence:\n%s", engineOnly)
283 }
284 for _, forbidden := range []string{"IPAddressAllow", "DynamicUser=no", "User=carlos-gotenberg"} {
285 if bytes.Contains(engineOnly, []byte(forbidden)) {
286 t.Errorf("doc_engine declaration under both grants rendered host_role's %q:\n%s", forbidden, engineOnly)
287 }
288 }
289
290 hostOnly, err := RenderService("keymail", &Set{V: 1, Command: "relay", HostRole: true}, both)
291 if err != nil {
292 t.Fatalf("RenderService(host_role only): %v", err)
293 }
294 if !bytes.Contains(hostOnly, []byte("IPAddressAllow=169.254.169.254/32")) {
295 t.Errorf("host_role declaration under both grants lost its IPAddressAllow:\n%s", hostOnly)
296 }
297 if bytes.Contains(hostOnly, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(hostOnly, []byte("MemoryDenyWriteExecute=no")) {
298 t.Errorf("host_role declaration under both grants rendered engine directives:\n%s", hostOnly)
299 }
300}
301
302// TestRenderIsDeterministic renders the same effective set from two
303// independently-decoded Sets whose "ports" arrays are given in different
304// orders, and asserts byte-identical output with ports in ascending order
305// regardless of declaration order.
306func TestRenderIsDeterministic(t *testing.T) {
307 const a = `{"v":1,"command":"relay","ports":[{"port":587,"name":"submission"},{"port":25,"name":"mx"}]}`
308 const b = `{"v":1,"command":"relay","ports":[{"port":25,"name":"mx"},{"port":587,"name":"submission"}]}`
309
310 var sa, sb Set
311 if err := json.Unmarshal([]byte(a), &sa); err != nil {
312 t.Fatalf("decode a: %v", err)
313 }
314 if err := json.Unmarshal([]byte(b), &sb); err != nil {
315 t.Fatalf("decode b: %v", err)
316 }
317
318 g := Grants{Ports: map[int]bool{25: true, 587: true}}
319
320 gotA, err := RenderSocket(&sa, g)
321 if err != nil {
322 t.Fatalf("RenderSocket(a): %v", err)
323 }
324 gotB, err := RenderSocket(&sb, g)
325 if err != nil {
326 t.Fatalf("RenderSocket(b): %v", err)
327 }
328 if !bytes.Equal(gotA, gotB) {
329 t.Fatalf("render not deterministic across declaration order:\n a: %q\n b: %q", gotA, gotB)
330 }
331
332 want := []byte(Marker + "\n[Socket]\nListenStream=25\nListenStream=587\n")
333 if !bytes.Equal(gotA, want) {
334 t.Fatalf("ports not sorted ascending: %q", gotA)
335 }
336
337 // Rendering again from the same Set must reproduce the identical bytes.
338 again, err := RenderSocket(&sa, g)
339 if err != nil {
340 t.Fatalf("RenderSocket(a) again: %v", err)
341 }
342 if !bytes.Equal(gotA, again) {
343 t.Fatalf("render not repeatable: %q vs %q", gotA, again)
344 }
345
346 svcA, err := RenderService("keymail", &sa, g)
347 if err != nil {
348 t.Fatalf("RenderService(a): %v", err)
349 }
350 svcAgain, err := RenderService("keymail", &sa, g)
351 if err != nil {
352 t.Fatalf("RenderService(a) again: %v", err)
353 }
354 if !bytes.Equal(svcA, svcAgain) {
355 t.Fatalf("service render not repeatable: %q vs %q", svcA, svcAgain)
356 }
357}
358
359// TestUngrantedCapabilitiesMaterializeNothing: keymail's full set with no
360// grants at all still renders the self-service lines (command, runtime_dir,
361// children) but none of the operator-consented ones — no DynamicUser/
362// RestrictSUIDSGID/User/Group, and a nil socket body. keymail with no
363// port:25 grant serves HTTP and does not bind the MX; it does not refuse
364// to start.
365func TestUngrantedCapabilitiesMaterializeNothing(t *testing.T) {
366 got, err := RenderService("keymail", keymailSet(t), Grants{})
367 if err != nil {
368 t.Fatalf("RenderService: %v", err)
369 }
370 if got == nil {
371 t.Fatal("service body is nil, want the self-service lines")
372 }
373 for _, want := range []string{
374 "ExecStart=\n",
375 "ExecStart=/opt/carlos/live/%i relay\n",
376 "RuntimeDirectory=keymail\n",
377 "KillMode=control-group\n",
378 } {
379 if !bytes.Contains(got, []byte(want)) {
380 t.Errorf("service body missing self-service line %q:\n%s", want, got)
381 }
382 }
383 for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group=", "IPAddressAllow"} {
384 if bytes.Contains(got, []byte(unwanted)) {
385 t.Errorf("service body has ungranted host_role directive %q:\n%s", unwanted, got)
386 }
387 }
388
389 sock, err := RenderSocket(keymailSet(t), Grants{})
390 if err != nil {
391 t.Fatalf("RenderSocket: %v", err)
392 }
393 if sock != nil {
394 t.Fatalf("socket body = %q, want nil when port:25 is not granted", sock)
395 }
396}
397
398// TestHostRoleGrantLingersButManifestNoLongerDeclaresFallsBackToDynamicUser
399// covers the asymmetric case where an operator grant survives a build that
400// stopped asking for it: the grant alone must never materialize
401// DynamicUser=no/RestrictSUIDSGID=yes/User=/Group= — only a Set that STILL
402// declares host_role does.
403func TestHostRoleGrantLingersButManifestNoLongerDeclaresFallsBackToDynamicUser(t *testing.T) {
404 s := &Set{V: 1, Command: "relay"}
405 got, err := RenderService("keymail", s, Grants{HostRole: true})
406 if err != nil {
407 t.Fatalf("RenderService: %v", err)
408 }
409 for _, want := range []string{"ExecStart=\n", "ExecStart=/opt/carlos/live/%i relay\n"} {
410 if !bytes.Contains(got, []byte(want)) {
411 t.Errorf("service body missing %q:\n%s", want, got)
412 }
413 }
414 for _, unwanted := range []string{"DynamicUser", "RestrictSUIDSGID", "User=", "Group=", "IPAddressAllow"} {
415 if bytes.Contains(got, []byte(unwanted)) {
416 t.Errorf("service body has host_role directive %q though the manifest no longer declares host_role:\n%s", unwanted, got)
417 }
418 }
419}
420
421// TestRenderSocketOnlyEmitsGrantedPorts: a Set declaring two ports with
422// only one of them granted must emit exactly that one ListenStream= line.
423func TestRenderSocketOnlyEmitsGrantedPorts(t *testing.T) {
424 s := &Set{V: 1, Ports: []Port{{Port: 25, Name: "mx"}, {Port: 587, Name: "submission"}}}
425 got, err := RenderSocket(s, Grants{Ports: map[int]bool{587: true}})
426 if err != nil {
427 t.Fatalf("RenderSocket: %v", err)
428 }
429 want := []byte(Marker + "\n[Socket]\nListenStream=587\n")
430 if !bytes.Equal(got, want) {
431 t.Fatalf("got %q, want %q", got, want)
432 }
433}
434
435// TestRenderSocketGrantOfAnUndeclaredPortRendersNil: a grant naming a port
436// the Set never declared materializes nothing — the grant alone is not a
437// declaration.
438func TestRenderSocketGrantOfAnUndeclaredPortRendersNil(t *testing.T) {
439 s := &Set{V: 1, Ports: []Port{{Port: 25, Name: "mx"}}}
440 got, err := RenderSocket(s, Grants{Ports: map[int]bool{587: true}})
441 if err != nil {
442 t.Fatalf("RenderSocket: %v", err)
443 }
444 if got != nil {
445 t.Fatalf("got %q, want nil — the grant names a port the manifest never declared", got)
446 }
447}
448
449// TestEmptyEffectiveSetRendersNil: daemon and data emit no directives, and
450// nothing else is declared, so both bodies are nil.
451func TestEmptyEffectiveSetRendersNil(t *testing.T) {
452 s := &Set{V: 1, Daemon: true, Data: &Data{Replicate: DataReplicatePlatform, DBs: []string{"console.db"}}}
453 got, err := RenderService("console", s, Grants{})
454 if err != nil {
455 t.Fatalf("RenderService: %v", err)
456 }
457 if got != nil {
458 t.Fatalf("RenderService = %q, want nil", got)
459 }
460 sock, err := RenderSocket(s, Grants{})
461 if err != nil {
462 t.Fatalf("RenderSocket: %v", err)
463 }
464 if sock != nil {
465 t.Fatalf("RenderSocket = %q, want nil", sock)
466 }
467}
468
469// TestNilSetRendersNil: a nil *Set (every manifest before this feature, and
470// every route that never declared capabilities) must render (nil, nil),
471// not panic and not error.
472func TestNilSetRendersNil(t *testing.T) {
473 got, err := RenderService("console", nil, Grants{HostRole: true})
474 if err != nil {
475 t.Fatalf("RenderService(nil): %v", err)
476 }
477 if got != nil {
478 t.Fatalf("RenderService(nil) = %q, want nil", got)
479 }
480 sock, err := RenderSocket(nil, Grants{Ports: map[int]bool{25: true}})
481 if err != nil {
482 t.Fatalf("RenderSocket(nil): %v", err)
483 }
484 if sock != nil {
485 t.Fatalf("RenderSocket(nil) = %q, want nil", sock)
486 }
487}
488
489// TestRenderServiceRefusesAnInvalidSet closes the direct-injection path: a
490// *Set built by hand, never having passed through UnmarshalJSON's strict
491// decode, still has to clear ValidCapabilities before anything renders. A
492// command with an embedded newline would otherwise inject a second
493// directive line into the unit.
494func TestRenderServiceRefusesAnInvalidSet(t *testing.T) {
495 bad := &Set{V: 1, Command: "relay\nUser=root"}
496 if _, err := RenderService("keymail", bad, Grants{}); err == nil {
497 t.Fatal("RenderService accepted a command with an embedded newline")
498 }
499}
500
501// TestRenderServiceRefusesHostRoleWithAnEmptyApp: an empty app would
502// otherwise render "User=carlos-" — a real, if odd, system user name that
503// names no app. ValidCapabilities's app-charset check refuses it here.
504func TestRenderServiceRefusesHostRoleWithAnEmptyApp(t *testing.T) {
505 s := &Set{V: 1, HostRole: true}
506 if _, err := RenderService("", s, Grants{HostRole: true}); err == nil {
507 t.Fatal("RenderService accepted host_role with an empty app name")
508 }
509}
510
511// TestRenderSocketRefusesAnInvalidSet: the same direct-injection path,
512// for RenderSocket — a non-privileged or reserved port built by hand must
513// still be refused before it renders a ListenStream= line.
514func TestRenderSocketRefusesAnInvalidSet(t *testing.T) {
515 bad := &Set{V: 1, Ports: []Port{{Port: 8080}}}
516 if _, err := RenderSocket(bad, Grants{Ports: map[int]bool{8080: true}}); err == nil {
517 t.Fatal("RenderSocket accepted a non-privileged port")
518 }
519}
520
521// TestRenderStartsWithTheMarker checks every non-nil body this file
522// produces opens with the marker line.
523func TestRenderStartsWithTheMarker(t *testing.T) {
524 svc, err := RenderService("keymail", keymailSet(t), keymailGrants())
525 if err != nil {
526 t.Fatalf("RenderService(keymail): %v", err)
527 }
528 sock, err := RenderSocket(keymailSet(t), keymailGrants())
529 if err != nil {
530 t.Fatalf("RenderSocket(keymail): %v", err)
531 }
532 consoleSvc, err := RenderService("console", consoleSet(t), consoleGrants())
533 if err != nil {
534 t.Fatalf("RenderService(console): %v", err)
535 }
536
537 bodies := [][]byte{svc, sock, consoleSvc}
538 for i, b := range bodies {
539 if b == nil {
540 t.Fatalf("body %d is nil, want non-nil to check its marker", i)
541 }
542 if !bytes.HasPrefix(b, []byte(Marker+"\n")) {
543 t.Errorf("body %d does not start with the marker:\n%s", i, b)
544 }
545 }
546}
547
548// A command identical to the template's default ("serve") is still an
549// explicit declaration: the reset+rewrite pair is still correct and still
550// deterministic, even though the rewritten value matches what the template
551// already says.
552func TestCommandIdenticalToTemplateDefaultStillEmitsReset(t *testing.T) {
553 s := &Set{V: 1, Command: "serve"}
554 got, err := RenderService("console", s, Grants{})
555 if err != nil {
556 t.Fatalf("RenderService: %v", err)
557 }
558 want := []byte(Marker + "\n[Service]\nExecStart=\nExecStart=/opt/carlos/live/%i serve\n")
559 if !bytes.Equal(got, want) {
560 t.Fatalf("got %q, want %q", got, want)
561 }
562}
563