Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
# A second job rather than a matrix on the one above:
# Windows runs neither the Makefile's shell recipes nor the race detector,
# and reports no coverage, so a matrix would be conditionals in every step.
# What it must prove is narrower
# What it must prove is narrower -
# that the platform still builds and still runs a command.
# The transparency v0.2.0 adds is *nix-only (ADR-001),
# and Windows keeps the supervising path,
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Variables already present in the environment override the ones in the file.
The command may have arguments, and it will be looked up in the `$PATH` if its name does not contain a `/`.

Many programs, and most IDEs in their run configurations, can read a `.env` file themselves.
`envrun` is for everywhere else CI/CD, a `just` or `make` target, a plain shell
`envrun` is for everywhere else - CI/CD, a `just` or `make` target, a plain shell -
where the program to be run cannot.

If the program is yours and written in Go, you need no wrapper at all:
Expand All @@ -27,7 +27,7 @@ On \*nix, `envrun` does not wrap the command:
it **becomes** it, through `execve`, keeping the same process.
Signals, exit status, standard streams and the controlling terminal
are the command's own, exactly as if it had read the environment file itself.
Windows has no equivalent and keeps a supervising parent
Windows has no equivalent and keeps a supervising parent -
see [Exit status and platform scope](docs/exit-status.md).

## Installing
Expand Down Expand Up @@ -62,7 +62,7 @@ A run with no defaults to add is a run that does not need `envrun`,
so a missing file is a failure rather than a silent pass-through:
configuration that was meant to be there is worth stopping for.

`envrun` reads the file, it never sources it no expansion, no execution,
`envrun` reads the file, it never sources it - no expansion, no execution,
so `DSN=postgres://${INSTANCE}/db` reaches the command exactly as written.
A shell sourcing the same file would substitute instead,
so keep values plain if both read it.
Expand All @@ -82,7 +82,7 @@ Apart from `-h`, which prints usage on standard output and exits `0`,
`envrun` has statuses only for its own failures, before the command starts,
following the convention of coreutils `env`, `timeout` and `nohup`:

- `125`: `envrun` itself failed the environment file could not be read,
- `125`: `envrun` itself failed - the environment file could not be read,
a flag was not understood, or no command was given
- `126`: the command exists but could not be executed
- `127`: the command could not be found
Expand All @@ -99,14 +99,14 @@ The rest, and the Windows divergence, is in
None at build or run time: `envrun` uses only the standard library.
The modules in `go.mod` belong to `staticcheck`, used for linting.
Each release archive ships an SPDX SBOM built from the binary itself,
so the claim is checkable without a toolchain
so the claim is checkable without a toolchain -
see the [Installing](docs/installing.md#dependencies) document.

## Related tools

`envrun` optimises for being invisible.
The command runs exactly as if it had read the file itself
same process, same signals, same exit status, same terminal
The command runs exactly as if it had read the file itself -
same process, same signals, same exit status, same terminal -
and nothing in the file is ever expanded or executed,
so a value cannot mean one thing to a shell and another to the program.
No dependencies, standard library only,
Expand Down
26 changes: 13 additions & 13 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ Until 1.0 that means the newest `v0.x` tag and the binaries published with it:
there are no maintenance branches,
so an earlier release is superseded, never patched.

| Version | Supported |
| Version | Supported |
|-----------------------|:------------------:|
| latest `v0.x` release | :white_check_mark: |
| earlier releases | :x: |
| `main` at HEAD | :x: |
| earlier releases | :x: |
| `main` at HEAD | :x: |

## Verifying a release

Expand Down Expand Up @@ -43,7 +43,6 @@ because it is a list, and the attestation is what says who wrote the list.
Each release also carries one SPDX SBOM per archive,
generated from the binary's own build graph rather than from `go.mod`.


## Reporting a Vulnerability

To report a vulnerability:
Expand All @@ -53,6 +52,7 @@ To report a vulnerability:
- the first response on that form should be within 1 day Monday to Friday

If a vulnerability is:

- accepted: we can work together on a fix, and you will be credited (unless you prefer not to be) on the fix
- considered not to be an actual security issue: you will get a suggestion to open it,
as an issue on the github issue system for the repo
Expand All @@ -64,7 +64,7 @@ If a vulnerability is:

### The file is read, never sourced

envrun invokes no shell not to read the environment file,
envrun invokes no shell - not to read the environment file,
and not to start the command.
The file is parsed line by line,
and the command is started through `execve` with an argument array rather than a command string.
Expand All @@ -81,8 +81,8 @@ GLOB=* -> the value is one asterisk; no filenames
TILDE=~ -> the value is one tilde; no home directory
```

This is what makes a file from a semi-trusted source a checkout, a CI
artifact, a pasted snippet safe to load:
This is what makes a file from a semi-trusted source - a checkout, a CI
artifact, a pasted snippet - safe to load:
its contents can become the value of a variable, never a command envrun runs.

**Where this one stops.**
Expand All @@ -100,21 +100,21 @@ See [The environment file](docs/environment-file.md).
### Secrets in diagnostics

An environment file often holds secrets,
and envrun writes its diagnostics to standard error
and envrun writes its diagnostics to standard error -
which under CI is a log that is retained, searched, and often world-readable.

So envrun reports **names and line numbers, never the text after the first `=`**.
That guarantee holds at every point where the library builds a message:

- a rejected name is `line 5: invalid name "9LEADING"` the name is quoted only
- a rejected name is `line 5: invalid name "9LEADING"` - the name is quoted only
where the name is itself the fault;
- a NUL in a value is `line 3: value contains NUL`,
naming neither the value nor the variable that held it;
- a line too long to hold is `line 2: too long`, naming the line and never its contents;
- a variable that cannot be set is its name and the system's
`setenv: invalid argument`.

Values do reach the command envrun runs that is what envrun is for.
Values do reach the command envrun runs - that is what envrun is for.
This is a property of the diagnostics alone.

**Where this one stops.**
Expand All @@ -125,13 +125,13 @@ A line that is *only* a pasted secret, and whose own bytes contain an `=`,
is therefore reported as a bad name, with the secret inside it:

```
https://user:pw@h/p?k=v line 1: invalid name "https://user:pw@h/p?k"
aGVsbG8+d29ybGQ= line 1: invalid name "aGVsbG8+d29ybGQ"
https://user:pw@h/p?k=v -> line 1: invalid name "https://user:pw@h/p?k"
aGVsbG8+d29ybGQ= -> line 1: invalid name "aGVsbG8+d29ybGQ"
```

Both halves are needed for this: a stray line, and an `=` within it.
A stray line with no `=` at all is reported as `line 1: not a name=value pair`,
quoting nothing.

If it does happen, treat that value as disclosed wherever the log is kept, and rotate it.
If it does happen, treat that value as disclosed wherever the log is kept, and rotate it.
Nothing else in the file is affected, every other line being reported by number.
2 changes: 1 addition & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// Print the usage message on standard output and exit 0,
// without reading the environment or running a command.
//
// The file is read, never sourced no expansion and no execution so a value
// The file is read, never sourced - no expansion and no execution - so a value
// reaches the command exactly as written. Its variables are merged under the
// inherited environment, so a name already exported wins over the file.
//
Expand Down
54 changes: 27 additions & 27 deletions docs/adr/001-handing-control-to-the-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ $ kill -TERM <envrun pid>
Terminal use hides this,
because Ctrl-C signals the whole foreground process group
and so reaches the child directly anyway.
The gap shows when a signal is directed at the envrun process alone
The gap shows when a signal is directed at the envrun process alone -
a supervisor, a `just` target, or a plain `kill <pid>`.

The goal stated while preparing the fix reframes what a fix should achieve:
Expand All @@ -45,11 +45,11 @@ They are recorded here because they are expensive to reproduce
and easy to misremember,
not because they all favour one option.

### 1. Supervise, and forward signals the issue as filed
### 1. Supervise, and forward signals - the issue as filed

Register `signal.Notify`, relay to the child, and exit `128+signo` when the child dies of a signal.

- **A blanket `signal.Notify(c)` delivers SIGURG** 21 of them in one busy run.
- **A blanket `signal.Notify(c)` delivers SIGURG** - 21 of them in one busy run.
Go's runtime uses SIGURG for asynchronous preemption, so "forward everything" forwards runtime noise into the child.
- **`Notify` disables default behaviour at registration**,
which makes a denylist impossible rather than merely awkward:
Expand All @@ -58,7 +58,7 @@ Register `signal.Notify`, relay to the child, and exit `128+signo` when the chil
- so the set of signals to handle has to be an allowlist,
and the allowlist is exactly the thing that will be found incomplete later.
- **Some signals cannot be relayed at all.**
- `kill -SEGV` with `Notify` registered still dies through `runtime.throw` `sigcode=2` (`SI_USER`),
- `kill -SEGV` with `Notify` registered still dies through `runtime.throw` - `sigcode=2` (`SI_USER`),
register dump, exit 2.
- SIGSEGV, SIGBUS and SIGFPE therefore cannot be forwarded;
- SIGKILL and SIGSTOP cannot be caught in the first place.
Expand All @@ -76,7 +76,7 @@ The allowlist is unavoidable,
and three signals cannot be relayed even in principle,
so any version of this ships with a known-incomplete taxonomy.

### 2. Replace the process `syscall.Exec`
### 2. Replace the process - `syscall.Exec`

After `execve` there is no wrapper left: signals, exit status,
stdio and the controlling terminal all belong to the command,
Expand All @@ -103,7 +103,7 @@ because it *is* the process that was envrun.
SigCgt: 0000000000000400
```

- `SigBlk` and `SigIgn` are empty, so nothing is handed to the command blocked or ignored
- `SigBlk` and `SigIgn` are empty, so nothing is handed to the command blocked or ignored -
and that held across *two* chained `execve` calls,
because `sh -c` with a single command execs straight into `grep`.
- The `SigCgt` bit is signal 11, SIGSEGV, installed by `grep` itself after exec:
Expand All @@ -119,7 +119,7 @@ because it *is* the process that was envrun.
Measured 2026-08-23 under `go test -covermode=atomic -coverprofile`:
`runtime/coverage.WriteMetaDir` returns
"no meta-data available (binary not built with -cover?)",
and `WriteCountersDir` reports "invoked for program built with -covermode=<invalid>"
and `WriteCountersDir` reports "invoked for program built with -covermode=<invalid>" -
in the helper process and the test process alike.
Those APIs work only in a binary built with `go build -cover`.
`make cover` passes `-covermode=atomic`,
Expand All @@ -128,20 +128,20 @@ because it *is* the process that was envrun.
the in-process ENOEXEC test *executes* the `syscall.Exec` statement,
since exec returns on failure,
and statement coverage cannot tell the success half of a statement from the failure half.
A subprocess test is still wanted for **behaviour**
that a child's exit 42 passes through
A subprocess test is still wanted for **behaviour** -
that a child's exit 42 passes through -
but it never needed to reach the coverage profile.

**Verdict: chosen, on \*nix.** See *Decision*.

### 3. Fork and reap by hand `syscall.ForkExec` with our own `Wait4`
### 3. Fork and reap by hand - `syscall.ForkExec` with our own `Wait4`

The only option that can observe job-control stops,
and the only one that gives full control over process groups and death signals.

It is also the largest: it takes over responsibilities `os/exec` currently handles correctly,
for a capability nothing has asked for.
Recorded here so that it is visibly declined rather than overlooked
Recorded here so that it is visibly declined rather than overlooked -
it is a reasonable design for a process supervisor, but envrun is not one at this stage in its life.

**Verdict: declined at this stage in the project's lifetime, not missed.**
Expand All @@ -164,13 +164,13 @@ This is what the installation advice has to answer to:
the transparency envrun offers is only as good as the layer it is invoked through.

- **`go run` does not forward SIGTERM.**
Reproduced live: the child is orphaned to PID 1 the very failure #35 reports.
Reproduced live: the child is orphaned to PID 1 - the very failure #35 reports.
- **`go tool` forwards only `{HUP, INT, QUIT, TERM}`** (`cmd/go/internal/tool/signal.go:14`)
and **remaps a tool's signal death to exit 1** (`cmd/go/internal/tool/tool.go:429-433` in Go 1.27.0).

So under `go tool envrun`, exit-status transparency is lost at the `go` layer,
no matter how faithful envrun is, and USR1, USR2 and WINCH still orphan.
This is not an argument against exec supervision through the same layer is strictly worse.
This is not an argument against exec - supervision through the same layer is strictly worse.

**It is an argument about installation, and one route already avoids it.**
`go install github.com/fgm/envrun@latest` produces a binary in `$GOBIN`
Expand All @@ -187,13 +187,13 @@ installs the version that module's `go.mod` already pins,
so a consumer can hold the pin *and* get a wrapper-free binary.

- **`go tool envrun`** re-resolves the pin on every run,
so the version can never drift from `go.mod` but the `go` process interposes,
so the version can never drift from `go.mod` - but the `go` process interposes,
forwarding four signals and flattening signal death to exit 1.
- **A `go install`ed binary** is fully transparent but it pins at install time,
- **A `go install`ed binary** is fully transparent - but it pins at install time,
nothing re-checks it afterward, and `GOBIN` is shared,
so one project's install silently changes which envrun every other project gets.
- **A project-local build** `go build -o bin/ github.com/fgm/envrun`
from inside the consuming module is transparent *and* fixes the drift instead of relocating it.
- **A project-local build** - `go build -o bin/ github.com/fgm/envrun`
from inside the consuming module - is transparent *and* fixes the drift instead of relocating it.
It resolves through the same `go.mod` pin, it does not touch `GOBIN`,
so projects pinning different versions no longer fight,
and because the output is in the project it can be a build target with `go.mod` as its prerequisite:
Expand All @@ -208,7 +208,7 @@ so a consumer can hold the pin *and* get a wrapper-free binary.
Verified 2026-08-23: `go build -o <dir>/` creates the directory if absent,
so no `.gitkeep` is needed;
and building a tool dependency into a directory from a consuming module works as expected.
**Ignore the built binary, not the directory**
**Ignore the built binary, not the directory** -
`bin/` is frequently a tracked source directory rather than a scratch one
(one consuming project keeps six shell scripts and a `//go:build ignore` helper there),
so `/bin/envrun` in `.gitignore`, never `bin/`.
Expand All @@ -223,7 +223,7 @@ A README that says "prefer `go install`" without "and reinstall when you bump th

Note also that `go tool` remaps only `ExitCode() == -1`, that is death by signal;
an ordinary non-zero exit passes through untouched. So a consumer needing
exit-status fidelity for ordinary failures not signal transparency is served
exit-status fidelity for ordinary failures - not signal transparency - is served
by `go tool` plus a current pin, and need not change invocation at all.

#41 is therefore a matter of reach rather than of feasibility: signed release
Expand All @@ -239,8 +239,8 @@ Process creation goes through `CreateProcess`,
which always produces a *new* process with a new PID,
so "replace yourself with the command" cannot be expressed.

Its signal and exit-status model differ too console control events rather than POSIX signals,
and plain exit codes with no signal encoding so `128+signo` has no meaning there either.
Its signal and exit-status model differ too - console control events rather than POSIX signals,
and plain exit codes with no signal encoding - so `128+signo` has no meaning there either.

### Classifying a failure to start

Expand All @@ -259,7 +259,7 @@ where execve reports ENOEXEC and envrun stops at 126.
envrun becomes the command rather than supervising it,
so the transparency it offers is the process's own rather than something it emulates.

**Windows keeps `os/exec`**, the shape not being expressible there
**Windows keeps `os/exec`**, the shape not being expressible there -
see *Windows has no fork/exec pair*.
Supervision is not a fallback on that platform, it is the only available shape,
and it will not become available later.
Expand All @@ -269,7 +269,7 @@ Only `run`, the one step that starts the command, has two implementations,
selected by build tag; the program itself carries no constraint.
Tagging the program instead, with a stub `main` refusing to run off \*nix,
would turn a working platform into a hard failure
in order to improve a different one
in order to improve a different one -
envrun builds and works on windows/amd64 today, `os/exec` being portable.

**This release is therefore \*nix-only in what it guarantees**,
Expand Down Expand Up @@ -304,11 +304,11 @@ the platform is supported, the transparency is not.
- `tests.yml` runs a Windows job, so a regression in any of it fails visibly.
- **Testability.** Exec never returns on success,
so the tests split in two, and the split follows the statuses.
- The pre-exec failures 125, 126, 127 stay **in process**,
- The pre-exec failures - 125, 126, 127 - stay **in process**,
and are the ones #18 lists as uncovered.
ENOEXEC reaches the `syscall.Exec` call in process;
so do EINVAL (a NUL in a value, demonstrated) and, on linux,
ETXTBSY and E2BIG ENOEXEC is not the only one.
ETXTBSY and E2BIG - ENOEXEC is not the only one.
- Everything past the point where the command starts needs a **subprocess**,
and the test binary is its own: it re-enters as envrun,
or as a helper reporting one fact about the process it runs in.
Expand All @@ -319,15 +319,15 @@ the platform is supported, the transparency is not.
- The subprocess half contributes nothing to the coverage profile, and cannot:
the flush APIs need a `go build -cover` binary, as measured above.
It is there for behaviour, not for the percentage.
- **`exitStatus` needs three explicit arms** `ErrNotFound`, `fs.ErrPermission`,
- **`exitStatus` needs three explicit arms** - `ErrNotFound`, `fs.ErrPermission`,
and `EISDIR` and `ENOEXEC` each on its own,
for the reason measured under *Classifying a failure to start*.

### For the project

- **envrun stops being observable once the command starts.**
- No wrapper means no place to log, count, time, or retry.
- Anything of that kind must happen before `execve` or not at all
- Anything of that kind must happen before `execve` or not at all -
which also means #3's verbose flag can only ever report on the environment,
never on the command's behaviour.
- **The README gets shorter, not longer.** The reasoning moves here,
Expand Down
Loading