env

package
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package env reads an environment file and hands back what it declared.

It exists because envrun has two callers wanting opposite things. The command must not put the variables into its own process: what it needs is the set to hand to the command it runs, which under ADR-001 becomes that command's environment across execve. A Go program importing this package wants the opposite — the variables in its own process, with no wrapper process at all.

So there are two entry points over one core: Load resolves and reads, applying nothing, and Apply applies what it read.

This package writes no output, and takes no logger: everything it observed is returned, so presentation belongs to the caller, which is the only party that knows the format it needs. Result.Path names the file it used, Result.Env holds what that file declared, and a rejected file yields a ParseError whose problems are reachable one by one. A library writing text under an application's slog.JSONHandler would interleave non-JSON lines into a JSON stream.

It is deliberately not a general .env package. The file format is narrow — see ParseError for what it refuses and why — and a feature is not adopted here on the grounds that other .env libraries carry it. See docs/adr/002-splitting-the-command-from-the-library.md.

Index

Examples

Constants

View Source
const (
	// AppName is the name of the application, regardless of its invocation.
	AppName = "envrun"
)
View Source
const DefaultPath = ".env"

DefaultPath is where Load and Apply look when given no path of their own.

Variables

View Source
var (
	// ErrInvalidName reports a name outside the accepted set: a letter or an
	// underscore, then letters, digits, dots, dashes or underscores.
	ErrInvalidName = errors.New("invalid name")
	// ErrNotAPair reports a line holding no "=" at all.
	ErrNotAPair = errors.New("not a name=value pair")
	// ErrNUL reports a value holding a NUL, which execve cannot carry.
	ErrNUL = errors.New("value contains NUL")
	// ErrTooLong reports a line the scanner cannot hold whole. It is a fault in
	// the file rather than a failure to read it, so it is a problem like the
	// others: truncating the line would corrupt a value silently.
	ErrTooLong = errors.New("too long")
)

Why a line can be rejected.

Sentinels rather than an enum, so a caller asks the question it actually has — errors.Is(err, env.ErrInvalidName) — without needing to know that Problem or ParseError exist, let alone how to walk them. ParseError wraps every problem it collected, so errors.Is reaches all of them, not just the first.

Their text is the whole message bar the line number, so the rendered form and the sentinel cannot drift apart.

Functions

This section is empty.

Types

type CloseError

type CloseError struct {
	// Err is what Close returned.
	Err error
}

CloseError reports that the environment file could not be closed once it had been read.

It is a Note rather than an error returned from Load, because everything the file had to give has already been read: refusing to run over it would withhold a working environment for a problem that no longer affects it.

It is also an error, the one note that genuinely reports a failure, so the cause — an *io/fs.PathError, which names the file itself — stays reachable:

if e, ok := n.(error); ok && errors.Is(e, fs.ErrPermission) { … }

func (CloseError) Error

func (e CloseError) Error() string

func (CloseError) String

func (e CloseError) String() string

func (CloseError) Unwrap

func (e CloseError) Unwrap() error

type Note

type Note fmt.Stringer

Note is a non-fatal finding: something Load observed that did not stop it.

It is a fmt.Stringer rather than an error, because most notes report nothing that failed. A repeated name is the coming case, and a file holding one is valid: the format resolves it last-wins, which is why its fixture is named pass-duplicate-name.env. Typing that as an error would have the API contradict the parser.

A note that does report a failure implements error as well, so its cause stays reachable through errors.Is and errors.As. CloseError is the one such note today.

A caller needing more than the text discriminates by type, which is why the implementations are exported.

Like Problem, a note may name a variable and the lines involved, never a value.

type ParseError

type ParseError struct {
	// Path is the file the problems were found in.
	Path     string
	Problems []Problem
}

ParseError reports a file rejected as a whole, and why, line by line.

Every problem in the file is collected before it is returned, so one run names every line at fault rather than the first.

A rejection is fatal, unlike a Result.Notes entry, because envrun is the only thing that reads the file: a line it cannot honour would have reached nothing without it, so passing that line over leaves the command running without the configuration the operator meant to set — worse than not running it at all.

FromEnviron does the opposite with the same shape of row, and the difference is the baseline rather than the format: an inherited row would have reached the command anyway.

Two ways in, because callers want two different things. To ask whether a kind of problem occurred at all:

if errors.Is(err, env.ErrInvalidName) { … }

To count them, or to locate them in the file:

if perr, ok := errors.AsType[*env.ParseError](err); ok {
	for _, p := range perr.Problems { … }
}

The second is what [Unwrap] cannot serve: errors.As stops at the first match in the tree, so a caller enumerating through it would have to walk the multi-error interface by hand.

Example

ExampleParseError shows the two ways into a rejected file, which answer different questions: whether a kind of problem occurred at all, and where every one of them is.

package main

import (
	"errors"
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/fgm/envrun/env"
)

// writeEnv puts body in a file of its own, returning its path and a function to
// remove it. Examples call it so that they read as being about envrun rather
// than about temporary files.
//
// They write their own file rather than reading testdata, because only go test
// runs a binary with the package directory as its working directory. An example
// is also compiled as a standalone program — by pkg.go.dev's Run button, say —
// where a relative path would resolve against somewhere else entirely.
func writeEnv(body string) (path string, remove func()) {
	dir, err := os.MkdirTemp("", env.AppName)
	if err != nil {
		log.Fatal(err)
	}
	path = filepath.Join(dir, ".env")
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		log.Fatal(err)
	}
	return path, func() { os.RemoveAll(dir) }
}

func main() {
	path, remove := writeEnv("GOOD=1\nSPA CED=bad\nNOEQUALS\n")
	defer remove()

	_, err := env.Load(path)

	// Did this kind of problem occur? A sentinel reaches every problem the file
	// produced, not only the first.
	fmt.Println("an invalid name:", errors.Is(err, env.ErrInvalidName))
	fmt.Println("a NUL value:    ", errors.Is(err, env.ErrNUL))

	// Where are they? Every line at fault is collected before the error is
	// returned, so one run names them all.
	if perr, ok := errors.AsType[*env.ParseError](err); ok {
		fmt.Println("file:", filepath.Base(perr.Path))
		for _, p := range perr.Problems {
			fmt.Printf("  line %d: %v (name %q)\n", p.Line, p.Err, p.Name)
		}
	}
}
Output:
an invalid name: true
a NUL value:     false
file: .env
  line 2: invalid name (name "SPA CED")
  line 3: not a name=value pair (name "")

func (*ParseError) Error

func (e *ParseError) Error() string

func (*ParseError) Unwrap

func (e *ParseError) Unwrap() []error

Unwrap exposes every problem to errors.Is and errors.As.

The []error form is what errors.Join produces, and what the errors package walks: returning it directly gives the same reach without giving up the fields ParseError carries or the single-line message above.

type Problem

type Problem struct {
	// Line is 1-based, as an editor counts.
	Line int
	// Err is one of [ErrNotAPair], [ErrInvalidName], [ErrNUL] or [ErrTooLong].
	Err error
	// Name is the offending name for [ErrInvalidName],
	// the name whose value was refused for [ErrNUL],
	// and empty for [ErrNotAPair] and [ErrTooLong],
	// where no name could be read from the line.
	Name string
}

Problem locates one rejected line.

It carries the line number and, where there was one to read, the name — and never the value. A malformed line may hold a secret, and a caller printing what it is handed must not be able to print that secret by accident.

func (Problem) Error

func (p Problem) Error() string

func (Problem) Unwrap

func (p Problem) Unwrap() error

type Result

type Result struct {
	// Path is the file actually used, which discovery makes worth reporting:
	// with several candidates, the caller cannot otherwise tell which one won.
	Path string

	// Env is what the file declared, and never the merge with the inherited
	// environment.
	//
	// The merged view would be redundant: after applying, os.Environ is that merge.
	// The file's own set is the part that cannot be recovered afterwards,
	// an applied variable being indistinguishable from an inherited one.
	//
	// That distinction is what #39 needs: -clean hands the set to the command
	// as its whole environment, where the default merges it under the inherited one.
	Env Vars

	// Notes are non-fatal findings, in the order they were seen, and are
	// returned even beside an error. A failed close is the only one so far;
	// a repeated name, silently overwritten today, is the next. See #3.
	Notes []Note
}

Result is everything Load observed, for a caller to present as it likes.

func Apply

func Apply(paths ...string) (Result, error)

Apply is Load followed by Vars.Export.

This is the importer's entry point, and the one line the library exists to spare them: an API that returned the merge-and-apply loop to its caller would not be simplifying anything.

It mutates process state, so it belongs at the top of main, or in TestMain, before anything concurrent starts. Go-to-Go access is safe on its own, since syscall.Setenv and Getenv share a mutex. What that mutex does not reach is:

  • a C library calling getenv on another thread;
  • any reader that captured a value before the change, and never learns of it.

There is no concurrency-safe way to mutate a process environment. Callers who need one want Load, which touches nothing and hands back the same Result.

The precedence is Vars.Export's: a name already set in the process is left alone, so a variable the caller exported survives the call.

Example

ExampleApply is the importer's entry point: one call at startup, before anything concurrent begins, and the variables are in the process.

package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/fgm/envrun/env"
)

// writeEnv puts body in a file of its own, returning its path and a function to
// remove it. Examples call it so that they read as being about envrun rather
// than about temporary files.
//
// They write their own file rather than reading testdata, because only go test
// runs a binary with the package directory as its working directory. An example
// is also compiled as a standalone program — by pkg.go.dev's Run button, say —
// where a relative path would resolve against somewhere else entirely.
func writeEnv(body string) (path string, remove func()) {
	dir, err := os.MkdirTemp("", env.AppName)
	if err != nil {
		log.Fatal(err)
	}
	path = filepath.Join(dir, ".env")
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		log.Fatal(err)
	}
	return path, func() { os.RemoveAll(dir) }
}

func main() {
	path, remove := writeEnv("FRESH=fromfile\nTAKEN=fromfile\nBRACED=${FRESH}\n")
	defer remove()

	// The state a parent shell, or an earlier line of main, would leave behind:
	// TAKEN already set, the other two not.
	os.Setenv("TAKEN", "frominherited")
	os.Unsetenv("FRESH")
	os.Unsetenv("BRACED")

	res, err := env.Apply(path)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%-18s %s\n", "file read:", filepath.Base(res.Path))
	fmt.Printf("%-18s %-14s(was unset, so the file's value applies)\n", "FRESH:", os.Getenv("FRESH"))
	fmt.Printf("%-18s %-14s(was already set, so the environment wins)\n", "TAKEN:", os.Getenv("TAKEN"))
	fmt.Printf("%-18s %-14s(never expanded: the file is read, not sourced)\n", "BRACED:", os.Getenv("BRACED"))
	fmt.Printf("%-18s %-14s(what the file declared, kept though it lost)\n", "Result.Env[TAKEN]:", res.Env["TAKEN"])
}
Output:
file read:         .env
FRESH:             fromfile      (was unset, so the file's value applies)
TAKEN:             frominherited (was already set, so the environment wins)
BRACED:            ${FRESH}      (never expanded: the file is read, not sourced)
Result.Env[TAKEN]: fromfile      (what the file declared, kept though it lost)

func Load

func Load(paths ...string) (Result, error)

Load finds an environment file, reads it, and applies nothing.

This is the command's entry point: envrun must not put the variables into its own process, because what it needs is the set to hand to the command it runs. An importer wants Apply instead.

paths is a search path, not a list to merge: the first candidate that exists wins, the rest are never read, and composition — .env then .env.local — stays a separate feature rather than a second meaning for this parameter. With no path at all it looks for DefaultPath in the working directory. The command's -f flag overrides the search by naming its one candidate.

The error says which half of the job failed:

  • the file's contents, as a *ParseError, whose problems are reachable one by one with errors.AsType;
  • reaching the file at all, as an *io/fs.PathError. errors.Is against fs.ErrNotExist then separates "no file" — which may be no error at all for a caller with an optional one — from a file that is there but could not be read.

A failing Result is not empty: Result.Path and Result.Notes are returned alongside the error whenever a file was opened at all. Only Result.Env is nil, since a rejected file declares nothing.

type Vars

type Vars map[string]string

Vars is a set of environment variables, as the file declared them or as a process inherited them.

It is a map because nothing here needs an order: execve takes an array the kernel does not order, and the one place order matters — reporting — can sort at presentation time, where the caller knows what it is sorting for.

func FromEnviron

func FromEnviron(rows []string) (Vars, []string)

FromEnviron parses environment rows as os.Environ returns them, returning the pairs it can represent and, separately, the rows it cannot.

envp is a plain array at the execve level: the kernel enforces neither shape nor uniqueness, so any parent can hand us a row that is not name=value, and indexing a split on the assumption that it is one used to panic. Two kinds arrive, and they are not the same thing despite being handled alike:

  • a row without "=", which no name can match, since getenv compares a name against the text before the "=";
  • a row with an empty name, such as "=value", which getenv("") does find.

Both are passed through rather than dropped, because a command run without envrun would see them. Only the first is genuinely unreadable.

Dedup keeps the first occurrence, as getenv does when scanning envp. It never fires on os.Environ output, which syscall.copyenv has already deduped, but the rows are the caller's to supply, and the map must not impose the opposite rule on them.

func (Vars) Environ

func (v Vars) Environ(opaque []string) []string

Environ renders the variables as execve takes them: a plain array of rows. It is the inverse of FromEnviron, and opaque is what that returned second.

The rows envrun could not represent follow the pairs rather than keeping their original position, since a map has no order to preserve them in.

func (Vars) Export

func (v Vars) Export() error

Export sets the variables into this process, and is what Apply does with what it read.

A name already set in the process is left alone, which is the same precedence the command applies: it merges the file under the inherited environment, so the inherited value wins. A variable the caller exported therefore survives the call.

It mutates process state, with the consequences for concurrent readers set out in Apply.

It is separate from Apply so that a caller who took Load to avoid the mutation can still choose it afterwards, rather than writing the loop itself.

func (Vars) Merge

func (v Vars) Merge(w Vars) Vars

Merge combines two sets of variables.

If names overlap, the argument wins over the receiver, as in PHP array_merge. The command's merge is fileEnv.Merge(inherited), so the inherited environment overrides the file — see PR #6 for the request to reverse that.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL