A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(env): port env/printenv with host-env-leak guard

Adds env(1) and printenv(1) ported from just-bash with GNU coreutils
semantics: exit 125 on bad invocation, exit 127 on command-not-found,
inner exit propagation otherwise; --help to stdout; lone "-" treated
as -i.

The readonly-vs-exported filter on the -i unset list is the
load-bearing bit. Iterating ec.Environ.Each in production picks up
mvdan's readonly shell scalars (EUID, UID, GID), and emitting `unset`
for them produces "EUID: readonly variable" stderr noise on every
`env -i` call. It also has no effect, since readonly shell vars are
not in the exported environ a child would inherit. Restricting the
unset list to set, exported, non-readonly scalars is what makes
`env -i` quiet and correct against a runner that pulls os.Environ().

A regression test (TestEnv_IgnoreEnv_ProductionRunner) builds the
runner the same way cmd/kefka/main.go does, with no interp.Env
override, so the host environment is genuinely present and the
filter actually has to do its job.

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 327c7d7b 2ec17e0f

+1014
+278
command/internal/env/env.go
··· 1 + // Package env implements the env(1) coreutil: run a program in a modified 2 + // environment, or print the current environment when no command is given. 3 + // 4 + // Argument parsing intentionally does not use getopt because env's grammar 5 + // is special: NAME=VALUE assignments and the inner command share the 6 + // positional slot, and the first positional that is neither a flag nor an 7 + // assignment terminates option parsing. Any subsequent NAME=VALUE-shaped 8 + // argument is part of the inner command, not an env assignment. getopt/v2 9 + // has no way to express that boundary. 10 + package env 11 + 12 + import ( 13 + "context" 14 + "errors" 15 + "fmt" 16 + "io" 17 + "sort" 18 + "strings" 19 + 20 + "mvdan.cc/sh/v3/expand" 21 + "mvdan.cc/sh/v3/interp" 22 + "mvdan.cc/sh/v3/syntax" 23 + "tangled.org/xeiaso.net/kefka/command" 24 + ) 25 + 26 + type Impl struct{} 27 + 28 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 29 + if ec == nil { 30 + return errors.New("env: nil ExecContext") 31 + } 32 + 33 + stderr := ec.Stderr 34 + if stderr == nil { 35 + stderr = io.Discard 36 + } 37 + stdout := ec.Stdout 38 + if stdout == nil { 39 + stdout = io.Discard 40 + } 41 + 42 + usage := func(w io.Writer) { 43 + fmt.Fprint(w, "Usage: env [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]\n") 44 + fmt.Fprint(w, "Set each NAME to VALUE in the environment and run COMMAND.\n\n") 45 + fmt.Fprint(w, " -i, --ignore-environment start with an empty environment\n") 46 + fmt.Fprint(w, " -u, --unset=NAME remove variable from the environment\n") 47 + fmt.Fprint(w, " --help display this help and exit\n\n") 48 + fmt.Fprint(w, "A mere - implies -i. If no COMMAND, print the resulting environment.\n") 49 + } 50 + 51 + var ( 52 + ignoreEnv bool 53 + unsetVars []string 54 + setOrder []string 55 + setVars = map[string]string{} 56 + ) 57 + 58 + i := 0 59 + parseLoop: 60 + for i < len(args) { 61 + arg := args[i] 62 + switch { 63 + case arg == "-i" || arg == "--ignore-environment" || arg == "-": 64 + ignoreEnv = true 65 + i++ 66 + case arg == "-u" || arg == "--unset": 67 + i++ 68 + if i >= len(args) { 69 + fmt.Fprintf(stderr, "env: option requires an argument -- '%s'\n", strings.TrimLeft(arg, "-")) 70 + usage(stderr) 71 + return interp.ExitStatus(125) 72 + } 73 + unsetVars = append(unsetVars, args[i]) 74 + i++ 75 + case strings.HasPrefix(arg, "--unset="): 76 + unsetVars = append(unsetVars, strings.TrimPrefix(arg, "--unset=")) 77 + i++ 78 + case strings.HasPrefix(arg, "-u") && len(arg) > 2: 79 + unsetVars = append(unsetVars, arg[2:]) 80 + i++ 81 + case arg == "--help": 82 + usage(stdout) 83 + return nil 84 + case arg == "--": 85 + i++ 86 + break parseLoop 87 + case strings.HasPrefix(arg, "--"): 88 + fmt.Fprintf(stderr, "env: unrecognized option '%s'\n", arg) 89 + usage(stderr) 90 + return interp.ExitStatus(125) 91 + case strings.HasPrefix(arg, "-") && arg != "-": 92 + fmt.Fprintf(stderr, "env: invalid option -- '%s'\n", arg[1:]) 93 + usage(stderr) 94 + return interp.ExitStatus(125) 95 + case strings.Contains(arg, "="): 96 + name, val, _ := strings.Cut(arg, "=") 97 + if _, exists := setVars[name]; !exists { 98 + setOrder = append(setOrder, name) 99 + } 100 + setVars[name] = val 101 + i++ 102 + default: 103 + break parseLoop 104 + } 105 + } 106 + 107 + commandArgs := args[i:] 108 + 109 + if len(commandArgs) == 0 { 110 + printEnvironment(stdout, ec.Environ, ignoreEnv, unsetVars, setOrder, setVars) 111 + return nil 112 + } 113 + 114 + if ec.Runner == nil { 115 + fmt.Fprint(stderr, "env: exec not available\n") 116 + return interp.ExitStatus(127) 117 + } 118 + 119 + return runWithEnv(ctx, ec, ignoreEnv, unsetVars, setOrder, setVars, commandArgs) 120 + } 121 + 122 + // printEnvironment writes the resulting environment, one NAME=VALUE per line, 123 + // matching just-bash's "join with \n + trailing \n if non-empty" rule. Output 124 + // is sorted alphabetically for deterministic behaviour; GNU env preserves 125 + // environ order, but inside kefka the parent Environ has no stable order. 126 + func printEnvironment( 127 + w io.Writer, 128 + parent expand.Environ, 129 + ignoreEnv bool, 130 + unsetVars []string, 131 + setOrder []string, 132 + setVars map[string]string, 133 + ) { 134 + newEnv := map[string]string{} 135 + if !ignoreEnv && parent != nil { 136 + parent.Each(func(name string, vr expand.Variable) bool { 137 + if !exportableString(vr) { 138 + return true 139 + } 140 + newEnv[name] = vr.String() 141 + return true 142 + }) 143 + } 144 + for _, name := range unsetVars { 145 + delete(newEnv, name) 146 + } 147 + for _, name := range setOrder { 148 + newEnv[name] = setVars[name] 149 + } 150 + 151 + names := make([]string, 0, len(newEnv)) 152 + for name := range newEnv { 153 + names = append(names, name) 154 + } 155 + sort.Strings(names) 156 + for _, name := range names { 157 + fmt.Fprintf(w, "%s=%s\n", name, newEnv[name]) 158 + } 159 + } 160 + 161 + // runWithEnv synthesises a small bash script that mutates the subshell's 162 + // environment and execs the inner command. The script: 163 + // 164 + // 1. Optionally `unset`s every currently-set parent var (for -i). 165 + // 2. `unset`s each name passed via -u/--unset. 166 + // 3. Runs `command -- CMD ARGS` with the requested NAME=VALUE assignment 167 + // prefix. The `command` builtin bypasses shell functions and keywords 168 + // (notably `time`), matching what GNU env does — it execvp's, so shell 169 + // functions are invisible. 170 + // 171 + // The script flows through ec.Runner.Subshell() so the inner command is 172 + // dispatched through the same exec-handler chain (registered builtins, shell 173 + // functions, PATH binaries) that the user would have hit by typing it. 174 + func runWithEnv( 175 + ctx context.Context, 176 + ec *command.ExecContext, 177 + ignoreEnv bool, 178 + unsetVars []string, 179 + setOrder []string, 180 + setVars map[string]string, 181 + commandArgs []string, 182 + ) error { 183 + stderr := ec.Stderr 184 + if stderr == nil { 185 + stderr = io.Discard 186 + } 187 + 188 + var b strings.Builder 189 + 190 + if ignoreEnv && ec.Environ != nil { 191 + var existing []string 192 + ec.Environ.Each(func(name string, vr expand.Variable) bool { 193 + // Only unset variables that would actually propagate to the 194 + // inner command — i.e. exported scalars. Skip readonly vars 195 + // (EUID, UID, GID, ...) since `unset` on those is a runtime 196 + // error that mvdan reports to stderr. 197 + if !vr.IsSet() || vr.ReadOnly || !vr.Exported { 198 + return true 199 + } 200 + if vr.Kind != expand.String && vr.Kind != expand.NameRef { 201 + return true 202 + } 203 + existing = append(existing, name) 204 + return true 205 + }) 206 + sort.Strings(existing) 207 + for _, name := range existing { 208 + quoted, err := syntax.Quote(name, syntax.LangBash) 209 + if err != nil { 210 + continue 211 + } 212 + b.WriteString("unset ") 213 + b.WriteString(quoted) 214 + b.WriteByte('\n') 215 + } 216 + } 217 + 218 + for _, name := range unsetVars { 219 + quoted, err := syntax.Quote(name, syntax.LangBash) 220 + if err != nil { 221 + continue 222 + } 223 + b.WriteString("unset ") 224 + b.WriteString(quoted) 225 + b.WriteByte('\n') 226 + } 227 + 228 + for _, name := range setOrder { 229 + b.WriteString(name) 230 + b.WriteByte('=') 231 + quoted, err := syntax.Quote(setVars[name], syntax.LangBash) 232 + if err != nil { 233 + fmt.Fprintf(stderr, "env: cannot quote value for %s: %v\n", name, err) 234 + return interp.ExitStatus(125) 235 + } 236 + b.WriteString(quoted) 237 + b.WriteByte(' ') 238 + } 239 + 240 + b.WriteString("command --") 241 + for _, a := range commandArgs { 242 + quoted, err := syntax.Quote(a, syntax.LangBash) 243 + if err != nil { 244 + fmt.Fprintf(stderr, "env: cannot quote argument %q: %v\n", a, err) 245 + return interp.ExitStatus(125) 246 + } 247 + b.WriteByte(' ') 248 + b.WriteString(quoted) 249 + } 250 + 251 + prog, err := syntax.NewParser(syntax.Variant(syntax.LangBash)). 252 + Parse(strings.NewReader(b.String()), "<env>") 253 + if err != nil { 254 + fmt.Fprintf(stderr, "env: cannot parse command: %v\n", err) 255 + return interp.ExitStatus(125) 256 + } 257 + 258 + sub := ec.Runner.Subshell() 259 + if err := interp.StdIO(ec.Stdin, ec.Stdout, ec.Stderr)(sub); err != nil { 260 + fmt.Fprintf(stderr, "env: cannot configure subshell: %v\n", err) 261 + return interp.ExitStatus(125) 262 + } 263 + return sub.Run(ctx, prog) 264 + } 265 + 266 + // exportableString reports whether vr should appear in the printed environment 267 + // (and is the kind of variable env can carry into an exec). Only set, scalar, 268 + // exported variables qualify — that mirrors what a real env would see in its 269 + // environ block. 270 + func exportableString(vr expand.Variable) bool { 271 + if !vr.IsSet() { 272 + return false 273 + } 274 + if vr.Kind != expand.String && vr.Kind != expand.NameRef { 275 + return false 276 + } 277 + return vr.Exported 278 + }
+475
command/internal/env/env_test.go
··· 1 + package env 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "io" 8 + "sort" 9 + "strings" 10 + "testing" 11 + 12 + "github.com/go-git/go-billy/v5" 13 + "github.com/go-git/go-billy/v5/memfs" 14 + "mvdan.cc/sh/v3/expand" 15 + "mvdan.cc/sh/v3/interp" 16 + "tangled.org/xeiaso.net/kefka/command" 17 + "tangled.org/xeiaso.net/kefka/command/registry" 18 + ) 19 + 20 + // envEchoImpl prints its env (via ec.Environ) one NAME=VALUE per line, sorted. 21 + // Used to verify env's environment plumbing actually reaches the inner cmd. 22 + type envEchoImpl struct{} 23 + 24 + func (envEchoImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 25 + if ec == nil || ec.Stdout == nil { 26 + return nil 27 + } 28 + if ec.Environ == nil { 29 + return nil 30 + } 31 + pairs := map[string]string{} 32 + ec.Environ.Each(func(name string, vr expand.Variable) bool { 33 + if !vr.IsSet() { 34 + return true 35 + } 36 + if vr.Kind != expand.String && vr.Kind != expand.NameRef { 37 + return true 38 + } 39 + if !vr.Exported { 40 + return true 41 + } 42 + pairs[name] = vr.String() 43 + return true 44 + }) 45 + names := make([]string, 0, len(pairs)) 46 + for name := range pairs { 47 + names = append(names, name) 48 + } 49 + sort.Strings(names) 50 + for _, name := range names { 51 + io.WriteString(ec.Stdout, name+"="+pairs[name]+"\n") 52 + } 53 + return nil 54 + } 55 + 56 + // argEchoImpl writes args joined by spaces and a trailing newline; used to 57 + // confirm that command/args reach the inner Execer intact. 58 + type argEchoImpl struct{} 59 + 60 + func (argEchoImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 61 + if ec.Stdout != nil { 62 + io.WriteString(ec.Stdout, strings.Join(args, " ")) 63 + io.WriteString(ec.Stdout, "\n") 64 + } 65 + return nil 66 + } 67 + 68 + // failImpl exits with a fixed non-zero status; used to verify env propagates 69 + // the inner command's exit code. 70 + type failImpl struct{} 71 + 72 + func (failImpl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 73 + return interp.ExitStatus(3) 74 + } 75 + 76 + func newRegistry(t *testing.T) *registry.Impl { 77 + t.Helper() 78 + reg := registry.New() 79 + reg.Register("env-echo", envEchoImpl{}) 80 + reg.Register("arg-echo", argEchoImpl{}) 81 + reg.Register("fail", failImpl{}) 82 + return reg 83 + } 84 + 85 + func newRunner(t *testing.T, reg *registry.Impl, fsys billy.Filesystem, env expand.Environ) *interp.Runner { 86 + t.Helper() 87 + var sh *interp.Runner 88 + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 89 + return func(ctx context.Context, args []string) error { 90 + return reg.Exec(ctx, fsys, sh, args) 91 + } 92 + } 93 + // Use a fixed Environ so the subshell does not pick up the host's 94 + // os.Environ() — that would mask the env the test set up via ec.Environ. 95 + if env == nil { 96 + env = expand.ListEnviron() 97 + } 98 + var err error 99 + sh, err = interp.New(interp.ExecHandlers(middleware), interp.Env(env)) 100 + if err != nil { 101 + t.Fatalf("interp.New: %v", err) 102 + } 103 + return sh 104 + } 105 + 106 + type runResult struct { 107 + stdout string 108 + stderr string 109 + err error 110 + } 111 + 112 + func run(t *testing.T, args []string, env expand.Environ) runResult { 113 + t.Helper() 114 + var stdout, stderr bytes.Buffer 115 + fsys := memfs.New() 116 + reg := newRegistry(t) 117 + ec := &command.ExecContext{ 118 + Stdout: &stdout, 119 + Stderr: &stderr, 120 + Dir: ".", 121 + FS: fsys, 122 + Environ: env, 123 + Runner: newRunner(t, reg, fsys, env), 124 + } 125 + err := Impl{}.Exec(context.Background(), ec, args) 126 + return runResult{ 127 + stdout: stdout.String(), 128 + stderr: stderr.String(), 129 + err: err, 130 + } 131 + } 132 + 133 + func TestEnv_PrintEnvironment(t *testing.T) { 134 + tests := []struct { 135 + name string 136 + args []string 137 + env expand.Environ 138 + want string 139 + wantErr bool 140 + }{ 141 + { 142 + name: "no args prints sorted env", 143 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 144 + want: "BAZ=qux\nFOO=bar\n", 145 + }, 146 + { 147 + name: "empty env produces no output and no trailing newline", 148 + env: expand.ListEnviron(), 149 + want: "", 150 + }, 151 + { 152 + name: "nil environ produces no output", 153 + env: nil, 154 + want: "", 155 + }, 156 + { 157 + name: "ignore-environment drops parent env", 158 + args: []string{"-i"}, 159 + env: expand.ListEnviron("FOO=bar"), 160 + want: "", 161 + }, 162 + { 163 + name: "long ignore-environment drops parent env", 164 + args: []string{"--ignore-environment"}, 165 + env: expand.ListEnviron("FOO=bar"), 166 + want: "", 167 + }, 168 + { 169 + name: "lone dash means -i", 170 + args: []string{"-"}, 171 + env: expand.ListEnviron("FOO=bar"), 172 + want: "", 173 + }, 174 + { 175 + name: "set adds NAME=VALUE", 176 + args: []string{"NEW=val"}, 177 + env: expand.ListEnviron("FOO=bar"), 178 + want: "FOO=bar\nNEW=val\n", 179 + }, 180 + { 181 + name: "set overrides existing var", 182 + args: []string{"FOO=replaced"}, 183 + env: expand.ListEnviron("FOO=bar"), 184 + want: "FOO=replaced\n", 185 + }, 186 + { 187 + name: "unset removes a variable", 188 + args: []string{"-u", "FOO"}, 189 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 190 + want: "BAZ=qux\n", 191 + }, 192 + { 193 + name: "long unset with equals removes a variable", 194 + args: []string{"--unset=FOO"}, 195 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 196 + want: "BAZ=qux\n", 197 + }, 198 + { 199 + name: "long unset with separate value removes a variable", 200 + args: []string{"--unset", "FOO"}, 201 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 202 + want: "BAZ=qux\n", 203 + }, 204 + { 205 + name: "short unset attached form removes a variable", 206 + args: []string{"-uFOO"}, 207 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 208 + want: "BAZ=qux\n", 209 + }, 210 + { 211 + name: "ignore plus set yields just the set vars", 212 + args: []string{"-i", "ONLY=this"}, 213 + env: expand.ListEnviron("FOO=bar"), 214 + want: "ONLY=this\n", 215 + }, 216 + { 217 + name: "double dash terminates options before printing", 218 + args: []string{"--"}, 219 + env: expand.ListEnviron("FOO=bar"), 220 + want: "FOO=bar\n", 221 + }, 222 + { 223 + name: "value containing equals is preserved", 224 + args: []string{"K=a=b=c"}, 225 + env: expand.ListEnviron(), 226 + want: "K=a=b=c\n", 227 + }, 228 + } 229 + 230 + for _, tt := range tests { 231 + t.Run(tt.name, func(t *testing.T) { 232 + r := run(t, tt.args, tt.env) 233 + if tt.wantErr && r.err == nil { 234 + t.Fatalf("err = nil, want non-nil") 235 + } 236 + if !tt.wantErr && r.err != nil { 237 + t.Fatalf("unexpected err: %v", r.err) 238 + } 239 + if r.stdout != tt.want { 240 + t.Errorf("stdout = %q, want %q", r.stdout, tt.want) 241 + } 242 + }) 243 + } 244 + } 245 + 246 + func TestEnv_RunCommand(t *testing.T) { 247 + tests := []struct { 248 + name string 249 + args []string 250 + env expand.Environ 251 + wantStdout string 252 + }{ 253 + { 254 + name: "passes parent env through", 255 + args: []string{"env-echo"}, 256 + env: expand.ListEnviron("FOO=bar"), 257 + wantStdout: "FOO=bar\n", 258 + }, 259 + { 260 + name: "adds NAME=VALUE to inner env", 261 + args: []string{"NEW=hi", "env-echo"}, 262 + env: expand.ListEnviron("FOO=bar"), 263 + wantStdout: "FOO=bar\nNEW=hi\n", 264 + }, 265 + { 266 + name: "ignore-environment hides parent vars", 267 + args: []string{"-i", "ONLY=this", "env-echo"}, 268 + env: expand.ListEnviron("FOO=bar"), 269 + wantStdout: "ONLY=this\n", 270 + }, 271 + { 272 + name: "unset removes parent var", 273 + args: []string{"-u", "FOO", "env-echo"}, 274 + env: expand.ListEnviron("FOO=bar", "KEEP=ok"), 275 + wantStdout: "KEEP=ok\n", 276 + }, 277 + { 278 + name: "passes args to inner command", 279 + args: []string{"arg-echo", "one", "two"}, 280 + env: expand.ListEnviron(), 281 + wantStdout: "one two\n", 282 + }, 283 + { 284 + name: "double dash separates env options from inner command", 285 + args: []string{"--", "arg-echo", "-i"}, 286 + env: expand.ListEnviron(), 287 + wantStdout: "-i\n", 288 + }, 289 + } 290 + 291 + for _, tt := range tests { 292 + t.Run(tt.name, func(t *testing.T) { 293 + r := run(t, tt.args, tt.env) 294 + if r.err != nil { 295 + t.Fatalf("unexpected err: %v\nstderr: %s", r.err, r.stderr) 296 + } 297 + if r.stdout != tt.wantStdout { 298 + t.Errorf("stdout = %q, want %q", r.stdout, tt.wantStdout) 299 + } 300 + }) 301 + } 302 + } 303 + 304 + func TestEnv_ExitCodes(t *testing.T) { 305 + tests := []struct { 306 + name string 307 + args []string 308 + env expand.Environ 309 + wantCode uint8 310 + stderrSub string 311 + }{ 312 + { 313 + name: "unknown short option exits 125", 314 + args: []string{"-Z"}, 315 + wantCode: 125, 316 + stderrSub: "invalid option", 317 + }, 318 + { 319 + name: "unknown long option exits 125", 320 + args: []string{"--bogus"}, 321 + wantCode: 125, 322 + stderrSub: "unrecognized option", 323 + }, 324 + { 325 + name: "missing -u argument exits 125", 326 + args: []string{"-u"}, 327 + wantCode: 125, 328 + stderrSub: "option requires an argument", 329 + }, 330 + { 331 + name: "command not found exits 127", 332 + args: []string{"nope-not-here"}, 333 + wantCode: 127, 334 + stderrSub: "command not found", 335 + }, 336 + { 337 + name: "inner command's exit code propagates", 338 + args: []string{"fail"}, 339 + wantCode: 3, 340 + }, 341 + } 342 + 343 + for _, tt := range tests { 344 + t.Run(tt.name, func(t *testing.T) { 345 + r := run(t, tt.args, tt.env) 346 + var status interp.ExitStatus 347 + if !errors.As(r.err, &status) { 348 + t.Fatalf("err = %v, want ExitStatus", r.err) 349 + } 350 + if uint8(status) != tt.wantCode { 351 + t.Errorf("exit = %d, want %d", uint8(status), tt.wantCode) 352 + } 353 + if tt.stderrSub != "" && !strings.Contains(r.stderr, tt.stderrSub) { 354 + t.Errorf("stderr missing %q; got: %q", tt.stderrSub, r.stderr) 355 + } 356 + }) 357 + } 358 + } 359 + 360 + func TestEnv_Help(t *testing.T) { 361 + r := run(t, []string{"--help"}, expand.ListEnviron("FOO=bar")) 362 + if r.err != nil { 363 + t.Fatalf("unexpected err: %v", r.err) 364 + } 365 + // --help goes to stdout per GNU coreutils convention. 366 + if !strings.Contains(r.stdout, "Usage: env") { 367 + t.Errorf("stdout missing usage; got: %q", r.stdout) 368 + } 369 + // Help must not print the environment. 370 + if strings.Contains(r.stdout, "FOO=bar") { 371 + t.Errorf("stdout leaked env into help: %q", r.stdout) 372 + } 373 + } 374 + 375 + func TestEnv_NilExecContext(t *testing.T) { 376 + err := Impl{}.Exec(context.Background(), nil, []string{"FOO=bar"}) 377 + if err == nil || !strings.Contains(err.Error(), "nil ExecContext") { 378 + t.Errorf("err = %v, want nil ExecContext error", err) 379 + } 380 + } 381 + 382 + func TestEnv_NilRunner(t *testing.T) { 383 + // With a command but no Runner, env can't dispatch. 384 + var stdout, stderr bytes.Buffer 385 + ec := &command.ExecContext{ 386 + Stdout: &stdout, 387 + Stderr: &stderr, 388 + Dir: ".", 389 + FS: memfs.New(), 390 + Environ: expand.ListEnviron("FOO=bar"), 391 + } 392 + err := Impl{}.Exec(context.Background(), ec, []string{"arg-echo"}) 393 + var status interp.ExitStatus 394 + if !errors.As(err, &status) || uint8(status) != 127 { 395 + t.Errorf("err = %v, want ExitStatus(127)", err) 396 + } 397 + if !strings.Contains(stderr.String(), "exec not available") { 398 + t.Errorf("stderr missing 'exec not available': %q", stderr.String()) 399 + } 400 + } 401 + 402 + func TestEnv_NilRunner_NoCommand(t *testing.T) { 403 + // Without a command, Runner is unused — should still print env. 404 + var stdout, stderr bytes.Buffer 405 + ec := &command.ExecContext{ 406 + Stdout: &stdout, 407 + Stderr: &stderr, 408 + Dir: ".", 409 + Environ: expand.ListEnviron("FOO=bar"), 410 + } 411 + err := Impl{}.Exec(context.Background(), ec, nil) 412 + if err != nil { 413 + t.Fatalf("unexpected err: %v", err) 414 + } 415 + if stdout.String() != "FOO=bar\n" { 416 + t.Errorf("stdout = %q, want %q", stdout.String(), "FOO=bar\n") 417 + } 418 + } 419 + 420 + // TestEnv_IgnoreEnv_ProductionRunner exercises the production runner setup 421 + // (no interp.Env override, so the runner pulls in os.Environ()) to make sure 422 + // `env -i` actually clears the host environment from the inner command and 423 + // does not error trying to unset readonly shell vars (EUID, UID, GID). 424 + func TestEnv_IgnoreEnv_ProductionRunner(t *testing.T) { 425 + t.Setenv("KEFKA_TEST_LEAKY", "leak-me") 426 + 427 + reg := newRegistry(t) 428 + fsys := memfs.New() 429 + var sh *interp.Runner 430 + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 431 + return func(ctx context.Context, args []string) error { 432 + return reg.Exec(ctx, fsys, sh, args) 433 + } 434 + } 435 + var err error 436 + sh, err = interp.New(interp.ExecHandlers(middleware)) 437 + if err != nil { 438 + t.Fatalf("interp.New: %v", err) 439 + } 440 + 441 + var stdout, stderr bytes.Buffer 442 + ec := &command.ExecContext{ 443 + Stdout: &stdout, 444 + Stderr: &stderr, 445 + Dir: ".", 446 + FS: fsys, 447 + Environ: sh.Env, 448 + Runner: sh, 449 + } 450 + err = Impl{}.Exec(context.Background(), ec, []string{"-i", "ONLY=this", "env-echo"}) 451 + if err != nil { 452 + t.Fatalf("err = %v\nstderr: %s", err, stderr.String()) 453 + } 454 + if strings.Contains(stderr.String(), "readonly variable") { 455 + t.Errorf("stderr leaked readonly-variable errors: %q", stderr.String()) 456 + } 457 + if strings.Contains(stdout.String(), "KEFKA_TEST_LEAKY") { 458 + t.Errorf("env -i leaked host env into inner command: %q", stdout.String()) 459 + } 460 + if !strings.Contains(stdout.String(), "ONLY=this") { 461 + t.Errorf("inner env missing ONLY=this: %q", stdout.String()) 462 + } 463 + } 464 + 465 + func TestEnv_LastSetWins(t *testing.T) { 466 + // Repeated NAME=VALUE: the last one wins, but the position is the first 467 + // occurrence — verifies setOrder dedup. 468 + r := run(t, []string{"X=1", "Y=a", "X=2"}, expand.ListEnviron()) 469 + if r.err != nil { 470 + t.Fatalf("unexpected err: %v", r.err) 471 + } 472 + if r.stdout != "X=2\nY=a\n" { 473 + t.Errorf("stdout = %q, want %q", r.stdout, "X=2\nY=a\n") 474 + } 475 + }
+112
command/internal/printenv/printenv.go
··· 1 + // Package printenv implements the printenv(1) coreutil: print all or part 2 + // of the environment. 3 + package printenv 4 + 5 + import ( 6 + "context" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "sort" 11 + "strings" 12 + 13 + "mvdan.cc/sh/v3/expand" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 21 + if ec == nil { 22 + return errors.New("printenv: nil ExecContext") 23 + } 24 + 25 + stderr := ec.Stderr 26 + if stderr == nil { 27 + stderr = io.Discard 28 + } 29 + stdout := ec.Stdout 30 + if stdout == nil { 31 + stdout = io.Discard 32 + } 33 + 34 + usage := func(w io.Writer) { 35 + fmt.Fprint(w, "Usage: printenv [OPTION]... [VARIABLE]...\n") 36 + fmt.Fprint(w, "Print the values of the specified environment VARIABLE(s).\n") 37 + fmt.Fprint(w, "If no VARIABLE is specified, print name and value pairs for them all.\n\n") 38 + fmt.Fprint(w, " --help display this help and exit\n") 39 + } 40 + 41 + // printenv accepts arbitrary VARIABLE names, including ones that look 42 + // like flags. Only --help is treated as an option; everything else 43 + // (including a leading dash) is taken as a variable name. This matches 44 + // just-bash, with the exception that we recognise --help anywhere in the 45 + // arg list, since GNU coreutils does the same. 46 + var vars []string 47 + for _, arg := range args { 48 + switch arg { 49 + case "--help": 50 + usage(stdout) 51 + return nil 52 + default: 53 + vars = append(vars, arg) 54 + } 55 + } 56 + 57 + if len(vars) == 0 { 58 + printAll(stdout, ec.Environ) 59 + return nil 60 + } 61 + 62 + exitCode := 0 63 + for _, name := range vars { 64 + if ec.Environ == nil { 65 + exitCode = 1 66 + continue 67 + } 68 + v := ec.Environ.Get(name) 69 + if !v.IsSet() || (v.Kind != expand.String && v.Kind != expand.NameRef) { 70 + exitCode = 1 71 + continue 72 + } 73 + fmt.Fprintln(stdout, v.String()) 74 + } 75 + if exitCode != 0 { 76 + return interp.ExitStatus(uint8(exitCode)) 77 + } 78 + return nil 79 + } 80 + 81 + // printAll writes the full exported environment, one NAME=VALUE per line, 82 + // sorted alphabetically. Trailing newline only when output is non-empty, 83 + // matching just-bash and GNU printenv. 84 + func printAll(w io.Writer, parent expand.Environ) { 85 + if parent == nil { 86 + return 87 + } 88 + pairs := map[string]string{} 89 + parent.Each(func(name string, vr expand.Variable) bool { 90 + if !vr.IsSet() { 91 + return true 92 + } 93 + if vr.Kind != expand.String && vr.Kind != expand.NameRef { 94 + return true 95 + } 96 + if !vr.Exported { 97 + return true 98 + } 99 + pairs[name] = vr.String() 100 + return true 101 + }) 102 + names := make([]string, 0, len(pairs)) 103 + for name := range pairs { 104 + names = append(names, name) 105 + } 106 + sort.Strings(names) 107 + var b strings.Builder 108 + for _, name := range names { 109 + fmt.Fprintf(&b, "%s=%s\n", name, pairs[name]) 110 + } 111 + io.WriteString(w, b.String()) 112 + }
+145
command/internal/printenv/printenv_test.go
··· 1 + package printenv 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "strings" 8 + "testing" 9 + 10 + "mvdan.cc/sh/v3/expand" 11 + "mvdan.cc/sh/v3/interp" 12 + "tangled.org/xeiaso.net/kefka/command" 13 + ) 14 + 15 + type runResult struct { 16 + stdout string 17 + stderr string 18 + err error 19 + } 20 + 21 + func run(t *testing.T, args []string, env expand.Environ) runResult { 22 + t.Helper() 23 + var stdout, stderr bytes.Buffer 24 + ec := &command.ExecContext{ 25 + Stdout: &stdout, 26 + Stderr: &stderr, 27 + Dir: ".", 28 + Environ: env, 29 + } 30 + err := Impl{}.Exec(context.Background(), ec, args) 31 + return runResult{ 32 + stdout: stdout.String(), 33 + stderr: stderr.String(), 34 + err: err, 35 + } 36 + } 37 + 38 + func TestPrintenv(t *testing.T) { 39 + tests := []struct { 40 + name string 41 + args []string 42 + env expand.Environ 43 + want string 44 + wantCode uint8 // 0 = success 45 + }{ 46 + { 47 + name: "no args prints sorted env", 48 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 49 + want: "BAZ=qux\nFOO=bar\n", 50 + }, 51 + { 52 + name: "empty env produces no output", 53 + env: expand.ListEnviron(), 54 + want: "", 55 + }, 56 + { 57 + name: "nil environ produces no output", 58 + env: nil, 59 + want: "", 60 + }, 61 + { 62 + name: "single existing variable prints value", 63 + args: []string{"FOO"}, 64 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 65 + want: "bar\n", 66 + }, 67 + { 68 + name: "multiple existing variables print values in order", 69 + args: []string{"BAZ", "FOO"}, 70 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 71 + want: "qux\nbar\n", 72 + }, 73 + { 74 + name: "missing variable exits 1", 75 + args: []string{"NOPE"}, 76 + env: expand.ListEnviron("FOO=bar"), 77 + want: "", 78 + wantCode: 1, 79 + }, 80 + { 81 + name: "mix of present and missing prints the present ones, exits 1", 82 + args: []string{"FOO", "NOPE", "BAZ"}, 83 + env: expand.ListEnviron("FOO=bar", "BAZ=qux"), 84 + want: "bar\nqux\n", 85 + wantCode: 1, 86 + }, 87 + { 88 + name: "value containing equals is preserved in print-all", 89 + env: expand.ListEnviron("K=a=b=c"), 90 + want: "K=a=b=c\n", 91 + }, 92 + { 93 + name: "value with newline is printed verbatim", 94 + args: []string{"MULTI"}, 95 + env: expand.ListEnviron("MULTI=line1\nline2"), 96 + want: "line1\nline2\n", 97 + }, 98 + { 99 + name: "leading-dash arg is a variable name, not a flag", 100 + args: []string{"-FOO"}, 101 + env: expand.ListEnviron("-FOO=dash-name"), 102 + want: "dash-name\n", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + r := run(t, tt.args, tt.env) 109 + if tt.wantCode == 0 { 110 + if r.err != nil { 111 + t.Fatalf("unexpected err: %v", r.err) 112 + } 113 + } else { 114 + var status interp.ExitStatus 115 + if !errors.As(r.err, &status) || uint8(status) != tt.wantCode { 116 + t.Errorf("err = %v, want ExitStatus(%d)", r.err, tt.wantCode) 117 + } 118 + } 119 + if r.stdout != tt.want { 120 + t.Errorf("stdout = %q, want %q", r.stdout, tt.want) 121 + } 122 + }) 123 + } 124 + } 125 + 126 + func TestPrintenv_Help(t *testing.T) { 127 + r := run(t, []string{"--help"}, expand.ListEnviron("FOO=bar")) 128 + if r.err != nil { 129 + t.Fatalf("unexpected err: %v", r.err) 130 + } 131 + if !strings.Contains(r.stdout, "Usage: printenv") { 132 + t.Errorf("stdout missing usage; got: %q", r.stdout) 133 + } 134 + // Help must not print the environment. 135 + if strings.Contains(r.stdout, "FOO=bar") { 136 + t.Errorf("stdout leaked env into help: %q", r.stdout) 137 + } 138 + } 139 + 140 + func TestPrintenv_NilExecContext(t *testing.T) { 141 + err := Impl{}.Exec(context.Background(), nil, []string{"FOO"}) 142 + if err == nil || !strings.Contains(err.Error(), "nil ExecContext") { 143 + t.Errorf("err = %v, want nil ExecContext error", err) 144 + } 145 + }
+4
command/registry/coreutils/coreutils.go
··· 12 12 "tangled.org/xeiaso.net/kefka/command/internal/diff" 13 13 "tangled.org/xeiaso.net/kefka/command/internal/dirname" 14 14 "tangled.org/xeiaso.net/kefka/command/internal/du" 15 + "tangled.org/xeiaso.net/kefka/command/internal/env" 15 16 "tangled.org/xeiaso.net/kefka/command/internal/expand" 16 17 "tangled.org/xeiaso.net/kefka/command/internal/expr" 17 18 "tangled.org/xeiaso.net/kefka/command/internal/file" ··· 29 30 "tangled.org/xeiaso.net/kefka/command/internal/nl" 30 31 "tangled.org/xeiaso.net/kefka/command/internal/od" 31 32 "tangled.org/xeiaso.net/kefka/command/internal/paste" 33 + "tangled.org/xeiaso.net/kefka/command/internal/printenv" 32 34 "tangled.org/xeiaso.net/kefka/command/internal/printf" 33 35 "tangled.org/xeiaso.net/kefka/command/internal/pwd" 34 36 "tangled.org/xeiaso.net/kefka/command/internal/readlink" ··· 68 70 reg.Register("diff", diff.Impl{}) 69 71 reg.Register("dirname", dirname.Impl{}) 70 72 reg.Register("du", du.Impl{}) 73 + reg.Register("env", env.Impl{}) 71 74 reg.Register("expand", expand.Impl{}) 72 75 reg.Register("expr", expr.Impl{}) 73 76 reg.Register("file", file.Impl{}) ··· 85 88 reg.Register("nl", nl.Impl{}) 86 89 reg.Register("od", od.Impl{}) 87 90 reg.Register("paste", paste.Impl{}) 91 + reg.Register("printenv", printenv.Impl{}) 88 92 reg.Register("printf", printf.Impl{}) 89 93 reg.Register("pwd", pwd.Impl{}) 90 94 reg.Register("readlink", readlink.Impl{})