A unit file reinterprets an environment value; a container does not

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
This commit is contained in:
2026-09-15 12:40:36 +02:00
parent f5cf9510c1
commit de5160de4a
4 changed files with 119 additions and 1 deletions
+26 -1
View File
@@ -203,7 +203,7 @@ func unitFor(r *declaration.Process) string {
fmt.Fprintf(&b, "EnvironmentFile=%s\n", file)
}
for _, key := range sortedKeys(r.Env) {
fmt.Fprintf(&b, "Environment=%s=%s\n", key, r.Env[key])
fmt.Fprintf(&b, "Environment=%s\n", unitValue(key, r.Env[key]))
}
if r.User != "" {
fmt.Fprintf(&b, "User=%s\n", r.User)
@@ -264,3 +264,28 @@ func calendarFor(cron string) string {
}
return fmt.Sprintf("%s*-%s-%s %s:%s:00", day, month, dom, hour, minute)
}
// unitValue renders one environment assignment so a unit file means what the declaration said.
//
// **Three things a unit file does to a value that nothing else does**, and a module's environment
// routinely contains all three — a generated password is arbitrary bytes.
//
// - `%` begins a specifier. `%H` is the hostname, `%i` the instance. A password containing one
// is silently replaced by something else, and the failure is an authentication error nobody
// can explain by looking at the declaration.
// - whitespace separates assignments. `Environment=K=a b` sets K to "a" and then tries to read
// "b" as another assignment.
// - a newline ends the line. What follows it is read as a unit DIRECTIVE, so a value carrying
// one could write ExecStart= and have the machine run something nobody declared.
//
// Quoted, with quotes and backslashes escaped and percent doubled. A container needs none of this
// because `--env` is passed through literally, which is why this had to be found here rather than
// noticed in both.
func unitValue(key, value string) string {
escaped := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"%", "%%",
).Replace(value)
return `"` + key + "=" + escaped + `"`
}
+33
View File
@@ -130,3 +130,36 @@ func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) {
t.Fatalf("a process that should stay up is declared a step:\n%s", unit)
}
}
// **A unit file reinterprets a value in three ways nothing else does**, and a module's environment
// routinely contains all three — a generated password is arbitrary bytes.
func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
// A percent begins a specifier: %H is the hostname. A password containing one would be
// silently replaced, failing as an authentication error nobody can explain from the
// declaration.
percent := aProcess()
percent.Env = map[string]string{"PASSWORD": "a%Hb"}
if !strings.Contains(unitFor(percent), "%%H") {
t.Fatalf("a percent was left as a systemd specifier:\n%s", unitFor(percent))
}
// Whitespace separates assignments: unquoted, K=a b sets K to "a" and reads "b" as another.
spaced := aProcess()
spaced.Env = map[string]string{"GREETING": "hello there"}
if !strings.Contains(unitFor(spaced), `"GREETING=hello there"`) {
t.Fatalf("a value with a space was not quoted:\n%s", unitFor(spaced))
}
// A quote would end the quoting early, and what follows would be read as unit syntax.
quoted := aProcess()
quoted.Env = map[string]string{"TOKEN": `a"b`}
line := ""
for _, l := range strings.Split(unitFor(quoted), "\n") {
if strings.HasPrefix(l, "Environment=") {
line = l
}
}
if !strings.Contains(line, `\"`) {
t.Fatalf("a quote was not escaped, so the value ends early: %s", line)
}
}