Found by being asked whether processes and containers handle environment the
same way. They do not, and the difference is not cosmetic.
Docker passes --env through literally. A unit file reads three things out of a
value that nothing else does, and a module's environment routinely contains all
three because a generated password is arbitrary bytes:
- % begins a specifier. %H is the hostname. A password containing one is
silently replaced, and it fails later as an authentication error nobody can
explain by reading the declaration.
- whitespace separates assignments. Unquoted, K=a b sets K to "a" and reads
"b" as another assignment.
- a newline ends the line, and what follows is read as a unit DIRECTIVE.
The first two are escaped: quoted, with quotes and backslashes escaped and
percent doubled. The third cannot be — a unit's environment has no way to carry
a line break — so it is refused in validation, near whoever wrote it. Without
that, an environment value could write ExecStart= and have the machine run
something nobody declared.
Ordinary awkward values stay accepted, because refusing those too would leave a
module unable to hold a generated password.
Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
161 lines
5.6 KiB
Go
161 lines
5.6 KiB
Go
package declaration
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func aProcess() *Process {
|
|
return &Process{
|
|
ID: "server", Type: TypeProcess, Name: "greeter",
|
|
Source: "https://store.invalid/greeter/daemon",
|
|
Digest: "sha256:" + strings.Repeat("a", 64),
|
|
Run: []string{"node", "index.js"},
|
|
}
|
|
}
|
|
|
|
// A process is part of the vocabulary, or a declaration carrying one is refused whole.
|
|
func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
|
|
var found bool
|
|
for _, kind := range Vocabulary() {
|
|
if kind == TypeProcess {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("a process cannot be declared, so a module that declares one is refused")
|
|
}
|
|
if newOf(TypeProcess) == nil {
|
|
t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind")
|
|
}
|
|
}
|
|
|
|
// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a
|
|
// reference somebody can repoint is not pinned, and it is the one thing on a machine that would
|
|
// then be running code nobody reviewed.
|
|
func TestAProcesssBundleMustBePinned(t *testing.T) {
|
|
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
|
|
d := aProcess()
|
|
d.Digest = bad
|
|
if problems := d.validate("a process", false); len(problems) == 0 {
|
|
t.Fatalf("a process pinned by %q was accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
|
|
// a process change what it runs when somebody adds a file.
|
|
func TestAProcessMustSayWhatToRun(t *testing.T) {
|
|
d := aProcess()
|
|
d.Run = nil
|
|
if problems := d.validate("a process", false); len(problems) == 0 {
|
|
t.Fatal("a process with no command was accepted")
|
|
}
|
|
}
|
|
|
|
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
|
|
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
|
|
for _, bad := range []string{"", "../escape", "two words", "a/b"} {
|
|
d := aProcess()
|
|
d.Name = bad
|
|
if problems := d.validate("a process", false); len(problems) == 0 {
|
|
t.Fatalf("a process called %q was accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And a well-formed one is accepted, or the tests above prove only that everything is refused.
|
|
func TestAWellFormedDaemonIsAccepted(t *testing.T) {
|
|
if problems := aProcess().validate("a process", false); len(problems) != 0 {
|
|
t.Fatalf("a well-formed daemon was refused: %v", problems)
|
|
}
|
|
}
|
|
|
|
// **The modes are exclusive, and saying so is the point of having one kind.** Something that runs
|
|
// once does not run on a schedule; something not running between fires cannot be restarted when a
|
|
// file changes. A container's modes carry the same rule, and this is the same rule because it is
|
|
// the same thing hosted differently.
|
|
func TestTheModesAreExclusive(t *testing.T) {
|
|
both := aProcess()
|
|
both.RunOnce = true
|
|
both.Schedule = "0 3 * * *"
|
|
if problems := both.validate("a process", false); len(problems) == 0 {
|
|
t.Fatal("a process that runs once and on a schedule was accepted")
|
|
}
|
|
|
|
watching := aProcess()
|
|
watching.Schedule = "0 3 * * *"
|
|
watching.RestartOn = []string{"some-file"}
|
|
if problems := watching.validate("a process", false); len(problems) == 0 {
|
|
t.Fatal("a scheduled process was given something to restart on, and it is never running")
|
|
}
|
|
}
|
|
|
|
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
|
|
// end of a declaration.
|
|
func TestAScheduleMustBeACadence(t *testing.T) {
|
|
for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} {
|
|
p := aProcess()
|
|
p.Schedule = bad
|
|
if problems := p.validate("a process", false); len(problems) == 0 {
|
|
t.Fatalf("a process scheduled %q was accepted", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And each mode on its own is accepted, or the tests above prove only that everything is refused.
|
|
func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
|
|
once := aProcess()
|
|
once.RunOnce = true
|
|
if problems := once.validate("a process", false); len(problems) != 0 {
|
|
t.Fatalf("a step was refused: %v", problems)
|
|
}
|
|
every := aProcess()
|
|
every.Schedule = "0 3 * * *"
|
|
if problems := every.validate("a process", false); len(problems) != 0 {
|
|
t.Fatalf("a scheduled process was refused: %v", problems)
|
|
}
|
|
}
|
|
|
|
// **The one that cannot be escaped, only refused.**
|
|
//
|
|
// Everything else a unit file reinterprets can be escaped: a percent specifier doubled, whitespace
|
|
// quoted, a quote backslashed. A newline cannot — it ends the line, and what follows is read as a
|
|
// unit DIRECTIVE. A value carrying one could write ExecStart= and have the machine run something
|
|
// nobody declared.
|
|
//
|
|
// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of
|
|
// a declaration.
|
|
func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
|
|
for _, bad := range []string{
|
|
"safe\nExecStart=/usr/bin/whatever",
|
|
"carriage\rreturn",
|
|
} {
|
|
p := aProcess()
|
|
p.Env = map[string]string{"X": bad}
|
|
problems := p.validate("a process", false)
|
|
if len(problems) == 0 {
|
|
t.Fatalf("a value containing %q was accepted", bad)
|
|
}
|
|
var said bool
|
|
for _, problem := range problems {
|
|
if strings.Contains(problem, "line break") {
|
|
said = true
|
|
}
|
|
}
|
|
if !said {
|
|
t.Fatalf("refused for some other reason, which would stop being true: %v", problems)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And ordinary awkward values are accepted, because escaping is what handles those — refusing them
|
|
// too would make a module unable to hold a generated password.
|
|
func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
|
|
p := aProcess()
|
|
p.Env = map[string]string{"PASSWORD": `a%H b"c\d`}
|
|
if problems := p.validate("a process", false); len(problems) != 0 {
|
|
t.Fatalf("a password containing the characters passwords contain was refused: %v", problems)
|
|
}
|
|
}
|