| 1 | // Package caps is the closed capability vocabulary: what an app may ask the |
| 2 | // platform for, and what each name maps to. A manifest never names a path, a |
| 3 | // user, a uid, a resource number, or a systemd directive — the directives are |
| 4 | // Go constants in this package. There is no passthrough field and no |
| 5 | // "extra" escape hatch; the escape hatch is 90-operator.conf, which this |
| 6 | // package never writes and never reads. |
| 7 | // |
| 8 | // See docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2 (the |
| 9 | // model: self-service vs operator-consented), §3 (wire shape and which way |
| 10 | // each failure falls), §7 (security posture — why each rail exists), and §8 |
| 11 | // (the testing shape this file's tests follow). |
| 12 | package caps |
| 13 | |
| 14 | import ( |
| 15 | "encoding/json" |
| 16 | "fmt" |
| 17 | "regexp" |
| 18 | "sort" |
| 19 | "strings" |
| 20 | ) |
| 21 | |
| 22 | // Version is the capability block version THIS binary understands. A block |
| 23 | // declaring a "v" outside 1..Version refuses the version on this box (spec |
| 24 | // §3b) — fail closed in the direction that changes nothing: the box keeps |
| 25 | // serving what it has rather than materialize a unit missing something the |
| 26 | // app needs. Accepting the whole 1..Version range (not just == Version) is |
| 27 | // what lets an immutable v1 manifest already sitting in a production bucket |
| 28 | // keep validating the day Version becomes 2 — a rollback depends on it. |
| 29 | const Version = 1 |
| 30 | |
| 31 | // Marker is the first line of every drop-in this package generates — the |
| 32 | // same SHAPE as the marker `carlos ops litestream` uses (not the same |
| 33 | // string): a "# GENERATED by carlos ... — do not hand-edit" line, so a |
| 34 | // hand-edit check can look for that shape without two packages having to |
| 35 | // agree on one hardcoded literal. A file at a generated path WITHOUT it is |
| 36 | // never overwritten and never deleted (spec §3c). |
| 37 | const Marker = "# GENERATED by carlos adopt — do not hand-edit" |
| 38 | |
| 39 | // DataReplicatePlatform and DataReplicateApp are the only two values |
| 40 | // Data.Replicate accepts (spec §2, §3a): the box-wide carlos-litestream |
| 41 | // unit replicates the named dbs, or the app streams its own WAL and the |
| 42 | // platform provisions the directory and replicates nothing. Two |
| 43 | // replicators on one WAL fight over checkpointing and corrupt the replica, |
| 44 | // so the two are mutually exclusive — see the "dbs with replicate:\"app\"" |
| 45 | // rule in validData. |
| 46 | const ( |
| 47 | DataReplicatePlatform = "platform" |
| 48 | DataReplicateApp = "app" |
| 49 | ) |
| 50 | |
| 51 | // maxPorts and maxDBs are bounds, not features (spec §7) — shared between |
| 52 | // decode time, where they bound the memory a hostile manifest can force |
| 53 | // this binary to allocate, and ValidCapabilities, the semantic gate a |
| 54 | // programmatically-built Set (never having passed through UnmarshalJSON) |
| 55 | // still has to clear. |
| 56 | const ( |
| 57 | maxPorts = 4 |
| 58 | maxDBs = 8 |
| 59 | ) |
| 60 | |
| 61 | // tokenRe is the charset shared by command, runtime_dir, and ports[].name |
| 62 | // (spec §7): one lowercase token, so "../etc", "carlos/apps", absolute |
| 63 | // paths, flags, and spaces never parse. It is also, byte for byte, |
| 64 | // release.ValidApp's own app-name charset — see ValidCapabilities's |
| 65 | // host_role check for why that duplication exists. |
| 66 | var tokenRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| 67 | |
| 68 | // dbNameRe is data.dbs's charset: a bare filename, used only as |
| 69 | // filepath.Join(stateDir, name) and never a path (spec §7). "." and ".." |
| 70 | // pass this character class and are rejected separately below. |
| 71 | var dbNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) |
| 72 | |
| 73 | // reservedRuntimeDir names a directory the platform or the OS already owns. |
| 74 | // "carlos" is the load-bearing entry: a tenant owning /run/carlos would own |
| 75 | // every tenant socket the edge dials — the single most valuable directory |
| 76 | // on the box (spec §7). The rest are /run subdirectories systemd, the |
| 77 | // kernel, or a common distro tool already owns; the "systemd-" prefix is |
| 78 | // checked separately, below. |
| 79 | // |
| 80 | // static list is the first guard; converge's existence check (Task 6) is |
| 81 | // the second. This list can never be exhaustive — a box-specific |
| 82 | // /run/tailscale is not on it and never will be — so the converge pass |
| 83 | // additionally refuses a runtime_dir grant when /run/<name> already exists |
| 84 | // on the box and is not this route's own prior claim. See the note |
| 85 | // appended to the Task 6 section of |
| 86 | // docs/superpowers/plans/2026-08-03-app-capabilities.md. |
| 87 | var reservedRuntimeDir = map[string]bool{ |
| 88 | "carlos": true, "systemd": true, "lock": true, "log": true, |
| 89 | "user": true, "dbus": true, "sshd": true, "udev": true, |
| 90 | "credentials": true, "sudo": true, "mount": true, "network": true, |
| 91 | "initramfs": true, "tmpfiles.d": true, "host": true, "machines": true, |
| 92 | "netns": true, "blkid": true, "motd.d": true, "sendsigs.omit.d": true, |
| 93 | "console-setup": true, "setrans": true, "shm": true, "dhcpcd": true, |
| 94 | "initctl": true, |
| 95 | } |
| 96 | |
| 97 | // reservedPort is bound absolutely and is never grantable, typo or not |
| 98 | // (spec §7): the edge's own ports and the box's SSH. |
| 99 | var reservedPort = map[int]bool{22: true, 80: true, 443: true} |
| 100 | |
| 101 | // knownCapability is the closed vocabulary's eight names. A manifest key |
| 102 | // under "capabilities" that isn't one of these (or "v") names a capability |
| 103 | // this binary does not know, and UnmarshalJSON refuses the whole block. |
| 104 | var knownCapability = map[string]bool{ |
| 105 | "command": true, |
| 106 | "daemon": true, |
| 107 | "children": true, |
| 108 | "runtime_dir": true, |
| 109 | "ports": true, |
| 110 | "data": true, |
| 111 | "host_role": true, |
| 112 | "doc_engine": true, |
| 113 | } |
| 114 | |
| 115 | // Set is a manifest's whole "capabilities" object. It is decoded only |
| 116 | // through UnmarshalJSON below — the strict door that tells an unknown |
| 117 | // capability NAME (refuse) apart from an unknown FIELD inside a known one |
| 118 | // (ignore and note). |
| 119 | type Set struct { |
| 120 | V int `json:"v"` |
| 121 | Command string `json:"command,omitempty"` |
| 122 | Daemon bool `json:"daemon,omitempty"` |
| 123 | Children bool `json:"children,omitempty"` |
| 124 | RuntimeDir string `json:"runtime_dir,omitempty"` |
| 125 | Ports []Port `json:"ports,omitempty"` |
| 126 | Data *Data `json:"data,omitempty"` |
| 127 | HostRole bool `json:"host_role,omitempty"` |
| 128 | DocEngine bool `json:"doc_engine,omitempty"` |
| 129 | |
| 130 | notes []string // unknown-field-inside-a-known-capability lines; see Notes |
| 131 | } |
| 132 | |
| 133 | // Port is one privileged-port ask. Name is an operator/log label and |
| 134 | // carries no behaviour (spec §3a, §4c) — it is never a systemd |
| 135 | // FileDescriptorName. |
| 136 | type Port struct { |
| 137 | Port int `json:"port"` |
| 138 | Name string `json:"name,omitempty"` // an operator/log label; carries NO behaviour |
| 139 | } |
| 140 | |
| 141 | // Data is the persistent-data-dir ask: which files under the state dir are |
| 142 | // databases, and who replicates them. The path itself is never in the |
| 143 | // manifest — it is always StateDirectory=carlos-app/%i (spec §2). |
| 144 | // |
| 145 | // Asymmetry, on purpose: a manifest with no "data" key at all decodes to a |
| 146 | // nil *Data on Set — no capability declared, always valid. A manifest that |
| 147 | // instead writes an explicit "data": null is NOT the same thing: |
| 148 | // UnmarshalJSON still allocates a non-nil *Data (the zero value, an empty |
| 149 | // Replicate), which ValidCapabilities then refuses for naming neither |
| 150 | // accepted replicate value. Fail-closed on purpose: an explicit-but-empty |
| 151 | // "data" is not silently waved through as "no capability" just because an |
| 152 | // absent key would have been. |
| 153 | type Data struct { |
| 154 | Replicate string `json:"replicate"` // DataReplicatePlatform | DataReplicateApp |
| 155 | DBs []string `json:"dbs,omitempty"` |
| 156 | } |
| 157 | |
| 158 | // UnmarshalJSON is the strict door (spec §3b), and the three outcomes are |
| 159 | // deliberately different: |
| 160 | // - an unknown CAPABILITY NAME, or a "v" outside 1..Version: error, |
| 161 | // naming the capability and "update the platform binary". This |
| 162 | // propagates through json.Unmarshal -> release.GetManifest -> |
| 163 | // ensureStaged, which is exactly how the version comes to be refused on |
| 164 | // this box. |
| 165 | // - an unknown FIELD INSIDE a known capability: accepted, recorded in |
| 166 | // notes. |
| 167 | // - anything absent: the zero value, which means "not declared". |
| 168 | // |
| 169 | // It decodes into a map[string]json.RawMessage first so it can check every |
| 170 | // key against the eight known names plus "v" before decoding anything |
| 171 | // typed, then re-decodes each object-valued capability with a second |
| 172 | // map[string]json.RawMessage pass to collect notes AND to decode each known |
| 173 | // field one at a time by its exact key. That second point matters: a |
| 174 | // whole-object json.Unmarshal into Port or Data would let Go's decoder |
| 175 | // case-fold an unrecognised "PORT" or "REPLICATE" onto the typed field via |
| 176 | // its default case-insensitive tag matching — silently applying exactly the |
| 177 | // value the notes scan just reported as ignored (config-that-lies). Every |
| 178 | // field of every object-valued capability is therefore decoded via |
| 179 | // decodeIfPresent against the fields map's exact string key, never via a |
| 180 | // whole-object unmarshal. |
| 181 | // |
| 182 | // json.Decoder.DisallowUnknownFields is deliberately NOT used on the whole |
| 183 | // block — it cannot distinguish an unknown name (refuse) from an unknown |
| 184 | // field (ignore), and that distinction is the entire forward-compatibility |
| 185 | // contract (spec §3b). |
| 186 | // |
| 187 | // Decoding builds into a local Set and assigns to *s only once, at the very |
| 188 | // end: a mid-decode error can then never leave *s partially populated, by |
| 189 | // construction rather than by careful statement ordering. |
| 190 | func (s *Set) UnmarshalJSON(b []byte) error { |
| 191 | var raw map[string]json.RawMessage |
| 192 | if err := json.Unmarshal(b, &raw); err != nil { |
| 193 | return err |
| 194 | } |
| 195 | |
| 196 | // "v" must be present as its own key. An absent "v" on a present block |
| 197 | // is refused with an honest message naming the missing key — never |
| 198 | // treated as "v 0", which would conflate "the block forgot to say" with |
| 199 | // "the block explicitly said an invalid version". |
| 200 | vRaw, ok := raw["v"] |
| 201 | if !ok { |
| 202 | return fmt.Errorf(`capabilities: missing "v" (capability block version) — refusing rather than guessing which one was meant`) |
| 203 | } |
| 204 | var v int |
| 205 | if err := json.Unmarshal(vRaw, &v); err != nil { |
| 206 | return fmt.Errorf("capabilities: v: %w", err) |
| 207 | } |
| 208 | if v < 1 || v > Version { |
| 209 | return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", v, Version) |
| 210 | } |
| 211 | |
| 212 | for name := range raw { |
| 213 | if name == "v" { |
| 214 | continue |
| 215 | } |
| 216 | if !knownCapability[name] { |
| 217 | return fmt.Errorf("capabilities: unknown capability %q — update the platform binary", name) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | var out Set |
| 222 | out.V = v |
| 223 | var notes []string |
| 224 | |
| 225 | if err := decodeIfPresent(raw, "command", &out.Command); err != nil { |
| 226 | return fmt.Errorf("capabilities: command: %w", err) |
| 227 | } |
| 228 | if err := decodeIfPresent(raw, "daemon", &out.Daemon); err != nil { |
| 229 | return fmt.Errorf("capabilities: daemon: %w", err) |
| 230 | } |
| 231 | if err := decodeIfPresent(raw, "children", &out.Children); err != nil { |
| 232 | return fmt.Errorf("capabilities: children: %w", err) |
| 233 | } |
| 234 | if err := decodeIfPresent(raw, "runtime_dir", &out.RuntimeDir); err != nil { |
| 235 | return fmt.Errorf("capabilities: runtime_dir: %w", err) |
| 236 | } |
| 237 | if err := decodeIfPresent(raw, "host_role", &out.HostRole); err != nil { |
| 238 | return fmt.Errorf("capabilities: host_role: %w", err) |
| 239 | } |
| 240 | if err := decodeIfPresent(raw, "doc_engine", &out.DocEngine); err != nil { |
| 241 | return fmt.Errorf("capabilities: doc_engine: %w", err) |
| 242 | } |
| 243 | |
| 244 | if portsRaw, ok := raw["ports"]; ok { |
| 245 | var items []json.RawMessage |
| 246 | if err := json.Unmarshal(portsRaw, &items); err != nil { |
| 247 | return fmt.Errorf("capabilities: ports: %w", err) |
| 248 | } |
| 249 | // Bound BEFORE the make below, so the length a hostile manifest |
| 250 | // claims bounds the memory this binary allocates for it, not just |
| 251 | // the eventual result ValidCapabilities would reject. |
| 252 | if len(items) > maxPorts { |
| 253 | return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(items), maxPorts) |
| 254 | } |
| 255 | ports := make([]Port, len(items)) |
| 256 | for i, item := range items { |
| 257 | var fields map[string]json.RawMessage |
| 258 | if err := json.Unmarshal(item, &fields); err != nil { |
| 259 | return fmt.Errorf("capabilities: ports[%d]: %w", i, err) |
| 260 | } |
| 261 | for f := range fields { |
| 262 | if f != "port" && f != "name" { |
| 263 | notes = append(notes, fmt.Sprintf("capabilities: ports[%d]: unknown field %q ignored", i, f)) |
| 264 | } |
| 265 | } |
| 266 | if err := decodeIfPresent(fields, "port", &ports[i].Port); err != nil { |
| 267 | return fmt.Errorf("capabilities: ports[%d].port: %w", i, err) |
| 268 | } |
| 269 | if err := decodeIfPresent(fields, "name", &ports[i].Name); err != nil { |
| 270 | return fmt.Errorf("capabilities: ports[%d].name: %w", i, err) |
| 271 | } |
| 272 | } |
| 273 | out.Ports = ports |
| 274 | } |
| 275 | |
| 276 | if dataRaw, ok := raw["data"]; ok { |
| 277 | var fields map[string]json.RawMessage |
| 278 | if err := json.Unmarshal(dataRaw, &fields); err != nil { |
| 279 | return fmt.Errorf("capabilities: data: %w", err) |
| 280 | } |
| 281 | for f := range fields { |
| 282 | if f != "replicate" && f != "dbs" { |
| 283 | notes = append(notes, fmt.Sprintf("capabilities: data: unknown field %q ignored", f)) |
| 284 | } |
| 285 | } |
| 286 | var d Data |
| 287 | if err := decodeIfPresent(fields, "replicate", &d.Replicate); err != nil { |
| 288 | return fmt.Errorf("capabilities: data.replicate: %w", err) |
| 289 | } |
| 290 | if dbsRaw, ok := fields["dbs"]; ok { |
| 291 | var dbs []string |
| 292 | if err := json.Unmarshal(dbsRaw, &dbs); err != nil { |
| 293 | return fmt.Errorf("capabilities: data.dbs: %w", err) |
| 294 | } |
| 295 | // Same bound-before-allocation discipline as ports above. |
| 296 | if len(dbs) > maxDBs { |
| 297 | return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(dbs), maxDBs) |
| 298 | } |
| 299 | d.DBs = dbs |
| 300 | } |
| 301 | out.Data = &d |
| 302 | } |
| 303 | |
| 304 | sort.Strings(notes) |
| 305 | out.notes = notes |
| 306 | *s = out |
| 307 | return nil |
| 308 | } |
| 309 | |
| 310 | // decodeIfPresent unmarshals raw[key] into out when key is present, and |
| 311 | // leaves out untouched (the zero value, meaning "not declared") otherwise. |
| 312 | // Because the lookup is an exact Go map-key comparison rather than a |
| 313 | // whole-object json.Unmarshal, it never falls back to encoding/json's |
| 314 | // case-insensitive struct-tag matching — a "PORT" or "REPLICATE" the caller |
| 315 | // has already flagged as an unknown field via the same fields map stays |
| 316 | // unapplied. |
| 317 | func decodeIfPresent(raw map[string]json.RawMessage, key string, out any) error { |
| 318 | if v, ok := raw[key]; ok { |
| 319 | return json.Unmarshal(v, out) |
| 320 | } |
| 321 | return nil |
| 322 | } |
| 323 | |
| 324 | // Notes are the ignore-with-a-log lines the decoder collected, sorted. |
| 325 | // Callers print them once per pass, never per host. A nil Set — every |
| 326 | // production manifest before this feature, and what RunAdopt sees on every |
| 327 | // route that never declared capabilities — returns nil rather than |
| 328 | // panicking. |
| 329 | func (s *Set) Notes() []string { |
| 330 | if s == nil { |
| 331 | return nil |
| 332 | } |
| 333 | return s.notes |
| 334 | } |
| 335 | |
| 336 | // ValidCapabilities is the semantic gate, shared by SHIP (a human is |
| 337 | // present to read the refusal) and ADOPT (the box refuses the version), |
| 338 | // exactly as appconfig.ValidExecDelivery is shared by set-time and |
| 339 | // sync-time. A nil Set is valid and means no capabilities. app is needed |
| 340 | // for the host_role user-name charset and length checks and nothing else. |
| 341 | func ValidCapabilities(app string, s *Set) error { |
| 342 | if s == nil { |
| 343 | return nil |
| 344 | } |
| 345 | if s.V < 1 || s.V > Version { |
| 346 | return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", s.V, Version) |
| 347 | } |
| 348 | if s.Command != "" && !tokenRe.MatchString(s.Command) { |
| 349 | return fmt.Errorf("capabilities: command %q must be one lowercase token matching ^[a-z][a-z0-9-]{0,31}$ — one token, no flags, no spaces", s.Command) |
| 350 | } |
| 351 | if s.RuntimeDir != "" { |
| 352 | if !tokenRe.MatchString(s.RuntimeDir) { |
| 353 | return fmt.Errorf("capabilities: runtime_dir %q must match ^[a-z][a-z0-9-]{0,31}$", s.RuntimeDir) |
| 354 | } |
| 355 | if reservedRuntimeDir[s.RuntimeDir] || strings.HasPrefix(s.RuntimeDir, "systemd-") { |
| 356 | return fmt.Errorf("capabilities: runtime_dir %q is a reserved name", s.RuntimeDir) |
| 357 | } |
| 358 | } |
| 359 | if err := validPorts(s.Ports); err != nil { |
| 360 | return err |
| 361 | } |
| 362 | if s.Data != nil { |
| 363 | if err := validData(s.Data); err != nil { |
| 364 | return err |
| 365 | } |
| 366 | } |
| 367 | if s.HostRole { |
| 368 | // release.ValidApp (internal/release/release.go) already refuses an |
| 369 | // app name outside this exact charset at every route-creation entry |
| 370 | // point. It is duplicated here — rather than imported, since |
| 371 | // release comes to depend on this package in Task 2 and importing |
| 372 | // it back would cycle — as defense in depth for the |
| 373 | // User=<AppUser(app)> sink: a Set can be validated directly, |
| 374 | // without ever having passed through a route. |
| 375 | if !tokenRe.MatchString(app) { |
| 376 | return fmt.Errorf("capabilities: host_role: app %q fails the app-name charset — see release.ValidApp", app) |
| 377 | } |
| 378 | if u := AppUser(app); len(u) > 32 { |
| 379 | return fmt.Errorf("capabilities: host_role: user %q is longer than the 32-character account-name limit — refused at ship, where a human can rename", u) |
| 380 | } |
| 381 | } |
| 382 | if s.HostRole && s.DocEngine { |
| 383 | // Refused as a declaration, before any grant is consulted, because the |
| 384 | // two contradict in the rendered bytes: host_role emits |
| 385 | // IPAddressAllow=169.254.169.254 to re-open the IMDS the template |
| 386 | // fenced, and in systemd's BPF firewall an allow-list match WINS over |
| 387 | // a deny-list match (internal/caps/render.go). doc_engine's whole |
| 388 | // purpose is to fence the engine's egress against private networks it |
| 389 | // can be SSRF'd into — so declaring both asks for a fence with a |
| 390 | // hole punched in it by the other half of the same drop-in. |
| 391 | // |
| 392 | // A document-conversion orchestrator has no business holding the |
| 393 | // box's cloud instance role anyway: it converts bytes a caller sent |
| 394 | // and needs no cloud credentials to do it. Refusing the pair is the |
| 395 | // honest answer to a manifest that asks for both. |
| 396 | return fmt.Errorf("capabilities: host_role and doc_engine contradict — doc_engine fences the unit's egress away from private networks, and host_role re-opens the cloud metadata address, which in systemd's BPF firewall an IPAddressAllow wins over any IPAddressDeny. A document-conversion engine needs no cloud instance role; drop host_role") |
| 397 | } |
| 398 | return nil |
| 399 | } |
| 400 | |
| 401 | // validPorts encodes the ports rules: only privileged ports are a |
| 402 | // capability, 22/80/443 are reserved absolutely, no duplicates, and a bound |
| 403 | // of maxPorts — a bound, not a feature. |
| 404 | func validPorts(ports []Port) error { |
| 405 | if len(ports) > maxPorts { |
| 406 | return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(ports), maxPorts) |
| 407 | } |
| 408 | seen := make(map[int]bool, len(ports)) |
| 409 | for _, p := range ports { |
| 410 | if p.Port < 1 || p.Port > 1023 { |
| 411 | return fmt.Errorf("capabilities: ports: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", p.Port) |
| 412 | } |
| 413 | if reservedPort[p.Port] { |
| 414 | return fmt.Errorf("capabilities: ports: port %d is reserved and not grantable", p.Port) |
| 415 | } |
| 416 | if seen[p.Port] { |
| 417 | return fmt.Errorf("capabilities: ports: port %d declared more than once", p.Port) |
| 418 | } |
| 419 | seen[p.Port] = true |
| 420 | if p.Name != "" && !tokenRe.MatchString(p.Name) { |
| 421 | return fmt.Errorf("capabilities: ports: name %q must match ^[a-z][a-z0-9-]{0,31}$ — it lands in logs", p.Name) |
| 422 | } |
| 423 | } |
| 424 | return nil |
| 425 | } |
| 426 | |
| 427 | // validData encodes the data rules: replicate must be one of the two |
| 428 | // constants, dbs is a bound list of unique bare filenames, and dbs is |
| 429 | // meaningless (and refused) when the app replicates its own WAL. |
| 430 | func validData(d *Data) error { |
| 431 | if d.Replicate != DataReplicatePlatform && d.Replicate != DataReplicateApp { |
| 432 | return fmt.Errorf("capabilities: data.replicate %q must be %q or %q", d.Replicate, DataReplicatePlatform, DataReplicateApp) |
| 433 | } |
| 434 | if len(d.DBs) > maxDBs { |
| 435 | return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(d.DBs), maxDBs) |
| 436 | } |
| 437 | if d.Replicate == DataReplicateApp && len(d.DBs) > 0 { |
| 438 | return fmt.Errorf("capabilities: data.dbs is meaningless when replicate is %q — the app streams its own WAL, and accepting it would invite the two-replicators hazard", DataReplicateApp) |
| 439 | } |
| 440 | seen := make(map[string]bool, len(d.DBs)) |
| 441 | for _, name := range d.DBs { |
| 442 | if seen[name] { |
| 443 | return fmt.Errorf("capabilities: data.dbs: %q declared more than once — two replicators on one WAL fight over checkpointing", name) |
| 444 | } |
| 445 | seen[name] = true |
| 446 | if name == "." || name == ".." || !dbNameRe.MatchString(name) { |
| 447 | return fmt.Errorf("capabilities: data.dbs: %q must be a bare filename matching ^[A-Za-z0-9._-]{1,64}$", name) |
| 448 | } |
| 449 | } |
| 450 | return nil |
| 451 | } |
| 452 | |
| 453 | // AppUser is the static system user a host_role app runs as, DERIVED by the |
| 454 | // platform and never taken from a manifest. |
| 455 | func AppUser(app string) string { |
| 456 | return "carlos-" + app |
| 457 | } |
| 458 | |