| 1 | package caps |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | func 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 | |
| 34 | func keymailGrants() Grants { |
| 35 | return Grants{Ports: map[int]bool{25: true}, HostRole: true} |
| 36 | } |
| 37 | |
| 38 | func 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 | |
| 48 | func 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. |
| 55 | func 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 | |
| 71 | func gotenbergGrants() Grants { |
| 72 | return Grants{DocEngine: true} |
| 73 | } |
| 74 | |
| 75 | func 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 | |
| 84 | func 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 | |
| 95 | func 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 | |
| 106 | func 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 | |
| 117 | func 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. |
| 134 | func 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). |
| 163 | func 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. |
| 204 | func 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. |
| 235 | func 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. |
| 254 | func 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. |
| 274 | func 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. |
| 306 | func 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. |
| 365 | func 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. |
| 403 | func 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. |
| 423 | func 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. |
| 438 | func 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. |
| 451 | func 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. |
| 472 | func 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. |
| 494 | func 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. |
| 504 | func 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. |
| 514 | func 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. |
| 523 | func 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. |
| 552 | func 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 | |