diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 9a8e3c8b..7bc6fc3b 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -445,6 +445,9 @@ func settingsCommand(ctx context.Context, args []string) error { if err != nil { return err } + if err := refuseTerminalSettingsThroughAVerb(before, values, positionals[0], where); err != nil { + return err + } added, changed, removed := settingsChange(before, values) if len(removed) > 0 && !*replace { return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+ @@ -596,6 +599,13 @@ func settingsCommand(ctx context.Context, args []string) error { if len(positionals) != 1 { return errors.New("settings clear [--node ]") } + before, _, err := inv.Layer(ctx, *node, positionals[0]) + if err != nil { + return err + } + if err := refuseTerminalSettingsThroughAVerb(before, nil, positionals[0], where); err != nil { + return err + } if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil { return err } @@ -1051,3 +1061,40 @@ func declaresTools(m catalogue.Manifest) bool { } return false } + +// terminalSettings are the keys no verb may change (novox/hq issue 339). `places` says where the node-engine +// creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says which of +// the machine's paths are mounted into a module's container. Set through a verb, either lets any caller of the +// mesh's console — an agent among them — have root hand it a directory, or mount one of the machine's into a +// container it reaches. They are the operator's, typed at the controller's terminal. +var terminalSettings = []string{catalogue.PlacesSetting, catalogue.AccessesSetting} + +// throughAVerb says whether this process runs a seat verb's command line: the serving controller names the +// verb in the environment of every command it runs for one (runVerb), and a person at the terminal runs none. +// Every verb route reaches a command through runVerb — the named verbs and the generic `command` alike — so +// this is the one place that knows, whatever line the verb composed. +func throughAVerb() (string, bool) { + verb := os.Getenv(verbVar) + return verb, verb != "" +} + +// refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove +// places or accesses; a change that leaves both as they were is not refused. +func refuseTerminalSettingsThroughAVerb(before, after map[string]any, module, where string) error { + verb, through := throughAVerb() + if !through { + return nil + } + for _, key := range terminalSettings { + was, _ := json.Marshal(before[key]) + now, _ := json.Marshal(after[key]) + if string(was) == string(now) { + continue + } + return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+ + "line came through %q): it says where root creates and owns a module's directories, or which of "+ + "the machine's paths are mounted into its container, and whoever may call a verb includes agents "+ + "(novox/hq issue 339). Nothing was changed", key, module, where, verb) + } + return nil +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index f3b5a4d1..baf7f5b3 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -245,6 +245,14 @@ func (a *verbArguments) commandLine() ([]string, error) { if len(argv) == 0 { return nil, errors.New("command names no command") } + // A layer is written through the settings verb, never the generic one (novox/hq issue 339): the + // settings verb is where what a verb may not set is refused, and one route is one set of words. + // The command refuses places and accesses through any verb as well; this says so before it runs. + if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { + return nil, errors.New("settings are set and cleared through the settings verb, not the generic " + + "command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + + "Nothing was done") + } // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through // it says why, as it would through its own verb. if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) { diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index bbb29096..127e0714 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -245,7 +245,7 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) { if err != nil || strings.Join(argv, " ") != "node account g14 jochen" { t.Fatalf("a plain line: %v %v", argv, err) } - argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`}) + argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`}) if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { t.Fatalf("a quoted word stays one word: %q %v", argv, err) } diff --git a/cmd/mesh-controller/terminal_settings_test.go b/cmd/mesh-controller/terminal_settings_test.go new file mode 100644 index 00000000..48741817 --- /dev/null +++ b/cmd/mesh-controller/terminal_settings_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Where root creates and owns a module's directories, and which of the machine's paths reach its container, +// are said at the controller's terminal alone (novox/hq issue 339): through a verb, a caller who set `places` +// to /etc with an owner of its own would have the next push hand it /etc, and one who set `accesses` to / +// would have the machine's root mounted into a container. + +// throughVerb runs a verb's call the way the serving controller does: the command line argvFor composes, in +// a process that carries the verb in its environment (runVerb), through this binary's own dispatch. +func throughVerb(t *testing.T, verb string, args map[string]any) error { + t.Helper() + argv, err := argvFor(verb, args) + if err != nil { + return err + } + t.Setenv(verbVar, verb) + defer os.Unsetenv(verbVar) + return runLine(t, argv) +} + +// atTheTerminal runs a command line the way the operator does at the controller's terminal: no verb. +func atTheTerminal(t *testing.T, argv ...string) error { + t.Helper() + t.Setenv(verbVar, "") + os.Unsetenv(verbVar) + return runLine(t, argv) +} + +func runLine(t *testing.T, argv []string) error { + t.Helper() + before := os.Args + defer func() { os.Args = before }() + os.Args = append([]string{"mesh-controller"}, argv...) + return run() +} + +func TestPlacesAndAccessesAreRefusedThroughEveryVerb(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, catalogue.Manifest{Module: "notes", Version: "1", + Accesses: []catalogue.Access{{ID: "media", Path: "/storage/media", Mode: "read"}}, + Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"}, + {"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\n"}}}) + if _, err := assign(ctx, open, "laptop", "notes"); err != nil { + t.Fatal(err) + } + layer := func() string { + t.Helper() + values, _, err := open.inventory.Layer(ctx, "laptop", "notes") + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(values) + return string(raw) + } + refused := func(what string, err error) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), "controller's terminal") || + !strings.Contains(err.Error(), "issue 339") { + t.Fatalf("%s: %v", what, err) + } + } + + // The attack the issue reports, through each verb route: nothing is kept. + attacks := []map[string]any{ + {"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}, "x": 1}, + {"accesses": map[string]any{"media": "/srv/elsewhere"}, "x": 1}, + } + for _, values := range attacks { + raw, _ := json.Marshal(values) + refused("settings verb, one machine", throughVerb(t, "settings", + map[string]any{"module": "notes", "node": "laptop", "values": string(raw)})) + refused("settings verb, the whole mesh", throughVerb(t, "settings", + map[string]any{"module": "notes", "values": string(raw)})) + for _, line := range []string{ + "settings set notes '" + string(raw) + "' --node laptop", + "settings set --node laptop notes '" + string(raw) + "'", + "settings set notes '" + string(raw) + "'", + } { + if err := throughVerb(t, "command", map[string]any{"command": line}); err == nil { + t.Fatalf("the command verb ran %q", line) + } + } + if got := layer(); got != "null" && got != "{}" { + t.Fatalf("a refused call kept a layer: %s", got) + } + } + + // At the terminal the same placement is taken. + if err := atTheTerminal(t, "settings", "set", "notes", + `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":0}`, "--node", "laptop"); err != nil { + t.Fatalf("places at the terminal: %v", err) + } + // A verb may change another key and keep the placement as it is. + if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", + "values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":1}`}); err != nil { + t.Fatalf("another key through the verb: %v", err) + } + kept := layer() + // But not move it, drop it, or clear the layer that holds it. + refused("moved through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", + "values": `{"places":{"data":{"path":"/srv/other","owner":"1001:1001"}},"x":1}`})) + refused("dropped through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", + "values": `{"x":1}`, "replace": "true"})) + refused("cleared through the verb", throughVerb(t, "settings", + map[string]any{"module": "notes", "node": "laptop", "clear": "true"})) + if err := throughVerb(t, "command", map[string]any{"command": "settings clear notes --node laptop"}); err == nil { + t.Fatal("the command verb cleared a layer") + } + if got := layer(); got != kept { + t.Fatalf("a refused call changed the layer: %s, was %s", got, kept) + } + // Reading through a verb still answers. + if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop"}); err != nil { + t.Fatalf("reading through the verb: %v", err) + } + + // The machine's own trees are refused from anywhere, the terminal too. + for _, path := range []string{"/etc", "/etc/sudoers.d", "/", "/home", "/var/lib", "/var/lib/mesh-host/x", "/srv/../etc"} { + err := atTheTerminal(t, "settings", "set", "notes", + `{"places":{"data":{"path":"`+path+`","owner":"1001:1001"}},"x":1}`, "--node", "laptop") + if err == nil || !strings.Contains(err.Error(), "issue 339") { + t.Fatalf("a place at %s at the terminal: %v", path, err) + } + err = atTheTerminal(t, "settings", "set", "notes", + `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"accesses":{"media":"`+path+`"},"x":1}`, + "--node", "laptop") + if err == nil || !strings.Contains(err.Error(), "issue 339") { + t.Fatalf("an access at %s at the terminal: %v", path, err) + } + } + // A line break in any setting is refused where it is kept, through a verb or at the terminal. + for _, x := range []string{`"a\nPATH=/tmp"`, `"a\rb"`, `"a\u0000b"`, `["ok","x\ny"]`, `{"k":"x\ny"}`} { + err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "replace": "true", + "values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":` + x + `}`}) + if err == nil || !strings.Contains(err.Error(), "line break") { + t.Fatalf("a line break in %s: %v", x, err) + } + } + if got := layer(); got != kept { + t.Fatalf("a refused call changed the layer: %s, was %s", got, kept) + } +} diff --git a/internal/catalogue/co_located_test.go b/internal/catalogue/co_located_test.go index e6679172..bf098c16 100644 --- a/internal/catalogue/co_located_test.go +++ b/internal/catalogue/co_located_test.go @@ -19,7 +19,7 @@ import ( // A root certificate, in the shape a certificate authority serves one. const servedRoot = `-----BEGIN CERTIFICATE----- -MIIBeDCCAR2gAwIBAgIQfake0000000000000000000000 +MIIBeDCCAR2gAwIBAgIQfake000000000000000000000000 -----END CERTIFICATE----- ` diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go index 6a406a26..1f6a7e38 100644 --- a/internal/catalogue/placement.go +++ b/internal/catalogue/placement.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "path/filepath" "regexp" "sort" "strings" @@ -44,6 +45,43 @@ type Placement struct { var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`) +// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339). +// +// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and +// whatever the module writes into it is written as root; an access is mounted into the module's container, +// which may run as root. A place at /etc owned by an account a caller names hands that account the machine, +// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here, +// before anything is kept or composed, and the node-engine refuses them again where it applies. +// +// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home, +// what lives only while the machine runs, and the node-engine's and the mesh's own state. systemRoots are +// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every +// module's or every person's directories, and owning it is owning all of them. +var ( + systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys", + "/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host"} + systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/var/spool", "/home", + "/mnt", "/media", "/srv", "/opt", "/tmp", "/storage", "/data", "/services"} +) + +// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "". +func systemPath(path string) string { + for _, tree := range systemTrees { + if path == tree || strings.HasPrefix(path, tree+"/") { + return path + " is in " + tree + ", the machine's own or the mesh's state" + } + if strings.HasPrefix(tree, path+"/") || path == "/" { + return path + " holds " + tree + ", the machine's own or the mesh's state" + } + } + for _, root := range systemRoots { + if path == root { + return path + " is the parent of every module's or every person's directories" + } + } + return "" +} + // accessRef is how a module names one of its accesses: ${access:}. var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`) @@ -103,7 +141,12 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) { if !strings.HasPrefix(p.Path, "/") { return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path) } - p.Path = strings.TrimRight(p.Path, "/") + p.Path = filepath.Clean(p.Path) + if why := systemPath(p.Path); why != "" { + return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+ + "directory as root, with the owner the setting names — no placement is ever there "+ + "(novox/hq issue 339)", m.Module, id, p.Path, why) + } out[id] = p } } @@ -147,7 +190,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) { return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path", m.Module, id, body) } - out[id] = strings.TrimRight(path, "/") + path = filepath.Clean(path) + if why := systemPath(path); why != "" { + return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+ + "module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why) + } + out[id] = path } } if len(out) == 0 { diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index d7ff5f8b..f7042491 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -209,6 +209,113 @@ func deepCopy(in map[string]any) map[string]any { return out } +// settingsHoldOneLine refuses a line break, a carriage return or a NUL in any string of any setting, for every +// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is +// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind +// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL +// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a +// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file +// of the module's own, never a setting. +func settingsHoldOneLine(module string, layers []Layer) error { + for _, layer := range layers { + for _, key := range sortedKeysAny(layer.Values) { + if at := lineBreakIn(layer.Values[key], key); at != "" { + return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return or a NUL, which a "+ + "file it is written into would read as a line of its own; a setting is one line (novox/hq "+ + "issue 339)", module, at, layer.From) + } + } + } + return nil +} + +// pemBlocks says whether a value is PEM blocks and nothing else: the one value with lines a setting may hold, +// because a provider serves its certificate authority's root to its consumers as one (step-ca to the route +// proxy). Each block is a BEGIN line, base64 lines of exactly 64 characters but the last, and the END line of +// the same label; the last line is a whole number of base64 groups, padded at its end alone. So no line of it +// is a path, an option, a section or an assignment of a name a program reads — a line break with anything +// else around it is refused. +func pemBlocks(v string) bool { + lines := strings.Split(strings.TrimSuffix(v, "\n"), "\n") + if len(lines) < 3 { + return false + } + for i := 0; i < len(lines); { + label, ok := strings.CutPrefix(lines[i], "-----BEGIN ") + if !ok || !strings.HasSuffix(label, "-----") { + return false + } + label = strings.TrimSuffix(label, "-----") + if label == "" || strings.Trim(label, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ") != "" { + return false + } + i++ + body := 0 + for i < len(lines) && !strings.HasPrefix(lines[i], "-----END ") { + body++ + i++ + } + if body == 0 || i == len(lines) || lines[i] != "-----END "+label+"-----" { + return false + } + for j, line := range lines[i-body : i] { + if !base64Line(line, j == body-1) { + return false + } + } + i++ + } + return true +} + +// base64Line is one line of a PEM body: 64 characters of the base64 alphabet, or for the last line at most 64, +// a whole number of groups, with at most two '=' at its end. +func base64Line(line string, last bool) bool { + if line == "" || len(line) > 64 || (!last && len(line) != 64) || len(line)%4 != 0 { + return false + } + data := strings.TrimRight(line, "=") + if len(line)-len(data) > 2 || (!last && data != line) { + return false + } + return strings.Trim(data, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") == "" +} + +// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "". +func lineBreakIn(v any, at string) string { + if strings.ContainsAny(at, "\n\r\x00") { + return strings.NewReplacer("\n", `\n`, "\r", `\r`, "\x00", `\0`).Replace(at) + } + switch t := v.(type) { + case string: + if strings.ContainsAny(t, "\n\r\x00") && !pemBlocks(t) { + return at + } + case map[string]any: + for _, k := range sortedKeysAny(t) { + if found := lineBreakIn(t[k], at+"."+k); found != "" { + return found + } + } + case []any: + for i, e := range t { + if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" { + return found + } + } + } + return "" +} + +func sortedKeysAny(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + // UnusedSettings names settings that reach nothing. // // Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed @@ -405,6 +512,9 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) // refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read, // and never costs a module its place. func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { + if err := settingsHoldOneLine(m.Module, layers); err != nil { + return err + } // With no layers too: a definition may ask for a setting nobody made — an access placed by // nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing. if _, err := GivenPorts(m, layers); err != nil { diff --git a/internal/catalogue/terminal_settings_test.go b/internal/catalogue/terminal_settings_test.go new file mode 100644 index 00000000..27a2d4ea --- /dev/null +++ b/internal/catalogue/terminal_settings_test.go @@ -0,0 +1,83 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq +// issue 339); a module's own place elsewhere is taken. +func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) { + m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}}, + Accesses: []Access{{ID: "media"}}} + for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib", + "/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity", + "/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} { + layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { + t.Errorf("a place at %s: %v", path, err) + } + layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}} + if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") { + t.Errorf("an access at %s: %v", path, err) + } + } + for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies", + "/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} { + layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}} + if got, err := Places(m, layers); err != nil || got["data"].Path != path { + t.Errorf("a place at %s: %v %v", path, got, err) + } + layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}} + if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path { + t.Errorf("an access at %s: %v %v", path, got, err) + } + } +} + +// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too — +// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339). +func TestASettingHoldsOneLine(t *testing.T) { + m := Manifest{Module: "mailu"} + for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"}, + map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} { + if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil || + !strings.Contains(err.Error(), "line break") { + t.Errorf("%q: %v", v, err) + } + } + if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil { + t.Error("a line break in a key was taken") + } + if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0, + "l": []any{"a", "b"}}}}, false); err != nil { + t.Errorf("one line each: %v", err) + } +} + +// PEM blocks alone may hold lines: a provider serves its authority's root as a setting. Anything around them, or +// a line in them that is not base64, is refused. +func TestAPEMBlockIsTheOneSettingWithLines(t *testing.T) { + m := Manifest{Module: "route-proxy"} + full := strings.Repeat("MIIB", 16) + pem := "-----BEGIN CERTIFICATE-----\n" + full + "\n" + full + "\nAbCd+/==\n-----END CERTIFICATE-----\n" + for _, ok := range []string{pem, pem + pem, strings.TrimSuffix(pem, "\n"), + "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n"} { + if err := JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{"root": ok}}}, false); err != nil { + t.Errorf("a PEM block: %v", err) + } + } + for _, bad := range []string{ + pem + "PATH=/tmp\n", + "PATH=\n" + pem, + "-----BEGIN CERTIFICATE-----\n" + full + "\nPATH=\n-----END CERTIFICATE-----\n", + "-----BEGIN CERTIFICATE-----\nshort\n" + full + "\n-----END CERTIFICATE-----\n", + "-----BEGIN CERTIFICATE-----\n" + full + "\n-----END KEY-----\n", + "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", + "-----BEGIN CERTIFICATE-----\r\n" + full + "\r\n-----END CERTIFICATE-----\r\n", + } { + if err := JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{"root": bad}}}, false); err == nil { + t.Errorf("taken: %q", bad) + } + } +}