| 1 | package caps |
| 2 | |
| 3 | // The operator half of the model: what may be written into the registry's |
| 4 | // `caps` column, what a hand-edited column does, and why one granted set has |
| 5 | // exactly one spelling. |
| 6 | |
| 7 | import ( |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // TestValidGrantTokenRefusals pins the closed vocabulary an operator meets at |
| 13 | // `carlos route -grant`. The three reserved ports are the load-bearing rows: |
| 14 | // they are refused ABSOLUTELY, so an operator typo can never hand a tenant |
| 15 | // the edge's ports or the box's SSH (spec §7). |
| 16 | func TestValidGrantTokenRefusals(t *testing.T) { |
| 17 | for _, tok := range []string{HostRoleToken, DocEngineToken, "port:25", "port:465", "port:1", "port:1023"} { |
| 18 | if err := ValidGrantToken(tok); err != nil { |
| 19 | t.Fatalf("ValidGrantToken(%q) = %v, want accepted", tok, err) |
| 20 | } |
| 21 | } |
| 22 | cases := []struct { |
| 23 | tok string |
| 24 | says string |
| 25 | }{ |
| 26 | {"port:22", "reserved"}, |
| 27 | {"port:80", "reserved"}, |
| 28 | {"port:443", "reserved"}, |
| 29 | {"port:8080", "1..1023"}, |
| 30 | {"port:0", "1..1023"}, |
| 31 | {"port:1024", "1..1023"}, |
| 32 | {"port:99999", "1..1023"}, |
| 33 | {"port:-1", "1..1023"}, |
| 34 | {"hostrole", "unknown grant token"}, |
| 35 | {"host-role:1", "unknown grant token"}, |
| 36 | // "engine" alone is NOT the token — it is the word `carlos store scan` |
| 37 | // already owns for the antivirus engine, so the caps token is spelled |
| 38 | // "doc-engine" to stay unambiguous in a caps column. |
| 39 | {"engine", "unknown grant token"}, |
| 40 | {"docengine", "unknown grant token"}, |
| 41 | {"", "empty"}, |
| 42 | {"port:", "decimal port number"}, |
| 43 | {"port:x", "decimal port number"}, |
| 44 | // One grant, one spelling: strconv would take all three. |
| 45 | {"port:025", "canonically spelled"}, |
| 46 | {"port:+25", "canonically spelled"}, |
| 47 | {"port: 25", "decimal port number"}, |
| 48 | // A token can never carry the column's own separator, or a newline. |
| 49 | {"port:25,host-role", "decimal port number"}, |
| 50 | {"host-role\n", "unknown grant token"}, |
| 51 | } |
| 52 | for _, c := range cases { |
| 53 | err := ValidGrantToken(c.tok) |
| 54 | if err == nil { |
| 55 | t.Fatalf("ValidGrantToken(%q) accepted; want a refusal", c.tok) |
| 56 | } |
| 57 | if !strings.Contains(err.Error(), c.says) { |
| 58 | t.Fatalf("ValidGrantToken(%q) = %q, want it to say %q", c.tok, err, c.says) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // TestParseGrantsRoundTrip: the column round-trips, and "" — every row on |
| 64 | // every box today — is the zero Grants with no error, meaning "nothing |
| 65 | // granted". |
| 66 | func TestParseGrantsRoundTrip(t *testing.T) { |
| 67 | g, err := ParseGrants("") |
| 68 | if err != nil || g.HostRole || len(g.Ports) != 0 { |
| 69 | t.Fatalf(`ParseGrants("") = (%+v, %v), want the zero Grants and no error`, g, err) |
| 70 | } |
| 71 | if g.String() != "" { |
| 72 | t.Fatalf("zero Grants formats as %q, want %q", g.String(), "") |
| 73 | } |
| 74 | |
| 75 | g, err = ParseGrants("host-role,port:25") |
| 76 | if err != nil { |
| 77 | t.Fatalf("ParseGrants: %v", err) |
| 78 | } |
| 79 | if !g.HostRole || !g.Ports[25] || len(g.Ports) != 1 { |
| 80 | t.Fatalf("ParseGrants = %+v, want host-role and port 25", g) |
| 81 | } |
| 82 | if got := g.String(); got != "host-role,port:25" { |
| 83 | t.Fatalf("String() = %q, want the canonical column value", got) |
| 84 | } |
| 85 | // A hand-written column in the other order normalises to the same bytes. |
| 86 | g, err = ParseGrants("port:25,host-role") |
| 87 | if err != nil { |
| 88 | t.Fatalf("ParseGrants: %v", err) |
| 89 | } |
| 90 | if got := g.String(); got != "host-role,port:25" { |
| 91 | t.Fatalf("String() = %q, want the canonical order regardless of input order", got) |
| 92 | } |
| 93 | if !g.Has(HostRoleToken) || !g.Has("port:25") || g.Has("port:465") || g.Has("nonsense") { |
| 94 | t.Fatalf("Has is wrong for %+v", g) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // TestParseGrantsRefusesAHandEditedColumn: this column is written only by |
| 99 | // registry.SetCaps behind `carlos route -grant`, so anything the formatter |
| 100 | // would not have written means a human edited the row — and the honest |
| 101 | // answer is to refuse to materialize, never to silently honour the subset |
| 102 | // that happens to parse. |
| 103 | func TestParseGrantsRefusesAHandEditedColumn(t *testing.T) { |
| 104 | for _, col := range []string{ |
| 105 | "port:99999", // out of range |
| 106 | "port:22", // reserved, ungrantable |
| 107 | "gpu", // not in the vocabulary |
| 108 | "host-role,", // empty element |
| 109 | ",port:25", // empty element |
| 110 | "host-role, port:25", // whitespace is not trimmed: one spelling only |
| 111 | "host-role,host-role", // the formatter never writes a duplicate |
| 112 | "port:25,port:025", // nor two spellings of one grant |
| 113 | } { |
| 114 | if g, err := ParseGrants(col); err == nil { |
| 115 | t.Fatalf("ParseGrants(%q) = %+v, want a refusal", col, g) |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // TestGrantRevokeIdempotent: granting what is granted writes the same bytes, |
| 121 | // and revoking what was never granted is a quiet success — the caller's goal |
| 122 | // is "not granted", not "I removed something". |
| 123 | func TestGrantRevokeIdempotent(t *testing.T) { |
| 124 | col, err := Grant("", "port:25") |
| 125 | if err != nil || col != "port:25" { |
| 126 | t.Fatalf(`Grant("", "port:25") = (%q, %v)`, col, err) |
| 127 | } |
| 128 | again, err := Grant(col, "port:25") |
| 129 | if err != nil || again != col { |
| 130 | t.Fatalf("re-granting changed the column: %q -> %q (%v)", col, again, err) |
| 131 | } |
| 132 | col, err = Grant(col, HostRoleToken) |
| 133 | if err != nil || col != "host-role,port:25" { |
| 134 | t.Fatalf("Grant(host-role) = (%q, %v), want the canonical two-token column", col, err) |
| 135 | } |
| 136 | // Order of granting does not change the stored bytes. |
| 137 | other, err := Grant("host-role", "port:25") |
| 138 | if err != nil || other != col { |
| 139 | t.Fatalf("granting in the other order = %q, want %q", other, col) |
| 140 | } |
| 141 | |
| 142 | col, err = Revoke(col, "port:25") |
| 143 | if err != nil || col != "host-role" { |
| 144 | t.Fatalf("Revoke(port:25) = (%q, %v), want just host-role left", col, err) |
| 145 | } |
| 146 | // Absent grant: no-op, no error. |
| 147 | col, err = Revoke(col, "port:25") |
| 148 | if err != nil || col != "host-role" { |
| 149 | t.Fatalf("revoking an absent grant = (%q, %v), want a quiet no-op", col, err) |
| 150 | } |
| 151 | col, err = Revoke(col, HostRoleToken) |
| 152 | if err != nil || col != "" { |
| 153 | t.Fatalf("Revoke(host-role) = (%q, %v), want an empty column", col, err) |
| 154 | } |
| 155 | // Revoking from an empty column is still a quiet success. |
| 156 | if col, err = Revoke("", HostRoleToken); err != nil || col != "" { |
| 157 | t.Fatalf(`Revoke("", host-role) = (%q, %v), want ("", nil)`, col, err) |
| 158 | } |
| 159 | // But a nonsense token is refused rather than cheerfully no-oped, on |
| 160 | // both verbs: an operator who typed rubbish deserves to hear so. |
| 161 | if _, err := Revoke("port:25", "port:22"); err == nil { |
| 162 | t.Fatal("Revoke accepted an ungrantable token") |
| 163 | } |
| 164 | if _, err := Grant("", "gpu"); err == nil { |
| 165 | t.Fatal("Grant accepted a token outside the vocabulary") |
| 166 | } |
| 167 | // A hand-edited column is refused by both, not repaired by them. |
| 168 | if _, err := Grant("gpu", "port:25"); err == nil { |
| 169 | t.Fatal("Grant repaired a hand-edited column instead of refusing it") |
| 170 | } |
| 171 | if _, err := Revoke("gpu", "port:25"); err == nil { |
| 172 | t.Fatal("Revoke repaired a hand-edited column instead of refusing it") |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // TestParseGrantsRoundTripsDocEngine pins the new token through the column: |
| 177 | // parse, Has, String, and the canonical sort order (doc-engine first — "d" |
| 178 | // sorts before "h" and "p", so a re-grant of the same set writes the same |
| 179 | // bytes regardless of the order the operator granted them in). |
| 180 | func TestParseGrantsRoundTripsDocEngine(t *testing.T) { |
| 181 | g, err := ParseGrants("doc-engine") |
| 182 | if err != nil { |
| 183 | t.Fatalf("ParseGrants: %v", err) |
| 184 | } |
| 185 | if !g.DocEngine || g.HostRole || len(g.Ports) != 0 { |
| 186 | t.Fatalf("ParseGrants(doc-engine) = %+v, want only DocEngine set", g) |
| 187 | } |
| 188 | if !g.Has(DocEngineToken) || g.Has(HostRoleToken) || g.Has("port:25") { |
| 189 | t.Fatalf("Has is wrong for %+v", g) |
| 190 | } |
| 191 | // Canonical order: doc-engine sorts before host-role and any port. |
| 192 | g, err = ParseGrants("port:25,host-role,doc-engine") |
| 193 | if err != nil { |
| 194 | t.Fatalf("ParseGrants: %v", err) |
| 195 | } |
| 196 | if got := g.String(); got != "doc-engine,host-role,port:25" { |
| 197 | t.Fatalf("String() = %q, want doc-engine,host-role,port:25", got) |
| 198 | } |
| 199 | // A duplicate doc-engine is refused like any other hand-edited duplicate. |
| 200 | if _, err := ParseGrants("doc-engine,doc-engine"); err == nil { |
| 201 | t.Fatal("ParseGrants accepted a duplicate doc-engine") |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // TestGrantRevokeDocEngine: the new token grants, is idempotent, and revokes, |
| 206 | // through the same String() canonicalisation the other tokens use. |
| 207 | func TestGrantRevokeDocEngine(t *testing.T) { |
| 208 | col, err := Grant("", DocEngineToken) |
| 209 | if err != nil || col != "doc-engine" { |
| 210 | t.Fatalf(`Grant("", doc-engine) = (%q, %v)`, col, err) |
| 211 | } |
| 212 | again, err := Grant(col, DocEngineToken) |
| 213 | if err != nil || again != col { |
| 214 | t.Fatalf("re-granting doc-engine changed the column: %q -> %q (%v)", col, again, err) |
| 215 | } |
| 216 | col, err = Grant(col, "port:25") |
| 217 | if err != nil || col != "doc-engine,port:25" { |
| 218 | t.Fatalf("Grant(port:25) over doc-engine = (%q, %v), want canonical order", col, err) |
| 219 | } |
| 220 | col, err = Revoke(col, DocEngineToken) |
| 221 | if err != nil || col != "port:25" { |
| 222 | t.Fatalf("Revoke(doc-engine) = (%q, %v), want just port:25 left", col, err) |
| 223 | } |
| 224 | // Revoking an absent doc-engine is a quiet success. |
| 225 | if col, err = Revoke(col, DocEngineToken); err != nil || col != "port:25" { |
| 226 | t.Fatalf("revoking an absent doc-engine = (%q, %v), want a quiet no-op", col, err) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // TestValidCapabilitiesRefusesHostRoleWithDocEngine pins the contradiction |
| 231 | // refusal: the two capabilities cannot both be declared, because host_role's |
| 232 | // IPAddressAllow re-opens the IMDS address that doc_engine's fence exists to |
| 233 | // close, and in systemd's BPF firewall an allow wins over a deny. The refusal |
| 234 | // fires on the declaration alone, before any grant is consulted. |
| 235 | func TestValidCapabilitiesRefusesHostRoleWithDocEngine(t *testing.T) { |
| 236 | err := ValidCapabilities("gotenberg", &Set{V: 1, HostRole: true, DocEngine: true}) |
| 237 | if err == nil { |
| 238 | t.Fatal("ValidCapabilities accepted host_role + doc_engine together") |
| 239 | } |
| 240 | if !strings.Contains(err.Error(), "contradict") { |
| 241 | t.Fatalf("refusal %q does not say the two contradict", err) |
| 242 | } |
| 243 | // Each alone is fine. |
| 244 | if err := ValidCapabilities("gotenberg", &Set{V: 1, DocEngine: true}); err != nil { |
| 245 | t.Fatalf("doc_engine alone refused: %v", err) |
| 246 | } |
| 247 | if err := ValidCapabilities("keymail", &Set{V: 1, HostRole: true}); err != nil { |
| 248 | t.Fatalf("host_role alone refused: %v", err) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // TestPortTokenMatchesTheManifestVocabulary: a declared port and its grant |
| 253 | // token must agree, or a grant an operator types could never satisfy the |
| 254 | // declaration it was typed for. ValidCapabilities and ValidGrantToken share |
| 255 | // the reserved list; this pins that they also share the range. |
| 256 | func TestPortTokenMatchesTheManifestVocabulary(t *testing.T) { |
| 257 | for p := 1; p <= 1023; p++ { |
| 258 | declarable := validPorts([]Port{{Port: p}}) == nil |
| 259 | grantable := ValidGrantToken(PortToken(p)) == nil |
| 260 | if declarable != grantable { |
| 261 | t.Fatalf("port %d: declarable=%v grantable=%v — a declaration nobody can grant, or a grant nothing can declare", |
| 262 | p, declarable, grantable) |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | |