o'ailly

Chapter 5 — Editing Without an Editor

Draft status: author draft, gate-checked; human verification pending. Every worked edit in this chapter runs in a scratch directory created by the listing itself; none touches real configuration.

The costume and the change

Chapter 1 classed editors among the traps with no non-interactive flag, because editing is the interactive act — a human, a cursor, and a buffer in conversation. But step back from the mechanism and every editor invocation in administration is the same underlying event: a file had one content before, and must have another content after. The cursor was never essential; it was the human interface to a substitution. The register performs the substitution directly, and it has a ladder of instruments for doing so, ordered by how much of the file they touch: append a line, substitute within lines, apply a structured diff, or replace the whole file. Each rung has its tool, its characteristic accident, and its verification, and this chapter climbs them in order. Two rules span all rungs and are the chapter’s spine. First: read before you edit — every mechanical edit is composed against an assumption about what the file currently contains, and the assumption must be checked in the same session, because in this register nobody is watching the file between your turns. Second: an edit is not done when the write succeeds; it is done when the read-back proves the file now says what you intended — chapter 2’s ask-and-verify, applied to the substrate everything else configures itself through.

Reading before editing has one subtlety worth its own paragraph: make sure the file you read is the file you will edit. Configuration trees are full of symlinks — /etc/resolv.conf is famously one on most modern systems — and an edit aimed at a link can follow it or replace it depending on the tool, two very different outcomes. The one-shot check is readlink -f, which resolves the entire chain to the physical target:

cd "$(mktemp -d)"
mkdir real
printf "x=1\n" > real/app.conf
ln -s real/app.conf app.conf
readlink -f app.conf
/tmp/tmp.kqCRsHy8rJ/real/app.conf

The name on the surface and the file on disk differ, and later rungs of the ladder treat them differently: sed -i and the atomic-replace pattern both replace the link itself with a regular file unless pointed at the resolved target — silently severing an arrangement someone built on purpose. Resolve first; edit the target.

Appending, and the accident of doing it twice

The lowest rung is adding lines to a file, and its tool is the shell’s own >> — with printf rather than echo supplying the bytes, since printf behaves identically everywhere while echo’s treatment of flags and escapes varies by shell and mode. The rung’s characteristic accident is not the append that fails but the append that succeeds twice. One-shot operators re-run commands: a turn times out with its work half-known, a script is retried after a fix, an agent replays a step from an earlier plan. An append inside that replay duplicates the line — and duplicated configuration is not always harmless; a repeated PATH export is noise, but a repeated firewall rule, kernel parameter, or cron entry can change behavior. The register’s discipline is to make every append conditional on its own absence — a guarded append, idempotent by construction:

cd "$(mktemp -d)"
printf "PATH=/usr/bin\n" > env.conf
for attempt in 1 2; do
  grep -q "^EDITOR=" env.conf || printf "EDITOR=false\n" >> env.conf
done
cat env.conf
echo "lines: $(wc -l < env.conf)"
PATH=/usr/bin
EDITOR=false
lines: 2

The loop simulates the retry: two attempts, one appended line, because the second attempt’s grep -q found the first’s work and the || skipped the write. Run the unguarded version and the file ends at three lines — a fact the listing’s final wc -l exists to make checkable, since an idempotence claim is exactly the kind of claim chapter 2 says to verify rather than assert. For multi-line insertions the same guard anchors on a marker comment (grep -q "^# BEGIN myblock"), which also gives a future removal a handle to find the block by. Guarded appends are the smallest instance of a theme this chapter returns to at the top of the ladder: in a register where re-execution is routine, the well-formed edit is one whose second application changes nothing.

The multi-line append’s instrument is the here-document, and it carries a quoting trap sharp enough to demonstrate rather than describe. The delimiter’s quoting decides whether the shell expands variables inside the block:

cd "$(mktemp -d)"
name=world
cat > expanded.txt <<EOF
hello $name
EOF
cat > literal.txt <<'EOF'
hello $name
EOF
cat expanded.txt literal.txt
hello world
hello $name

Unquoted EOF: the block is a template, and $name became world. Quoted 'EOF': the block is literal, and $name survived as text. Both behaviors are wanted — the first for generating config from session facts, the second for writing files that themselves contain shell syntax (a script, a crontab line, a systemd ExecStart with specifiers). The accident is using the first mode while believing you are in the second: every $ in the payload silently expands — usually to empty, per chapter 2’s unset-variable economics — and the written file is a corrupted version of the intended one that looks right at a glance because its shape survived. When a here-doc’s payload contains a single $, `, or backslash you intend literally, quote the delimiter; make the exceptions deliberate.

Substitution in place, guarded

The middle rung changes existing lines, and its tool is sed -i. Used bare, it is the most accident-prone instrument in this chapter, for a structural reason: sed applies a pattern to whatever matches, and the register’s operator is not watching matches happen. A pattern that matches zero times edits nothing — silently, exit status 0, the calm face again. A pattern that matches more lines than intended edits all of them, equally silently. Both accidents are the same root cause — the edit’s precondition lived only in the operator’s head — and both have the same cure: count the matches first, in the same shot, and proceed only when the count is the expected one:

cd "$(mktemp -d)"
printf "retries = 3\ntimeout = 30\n" > service.conf
n=$(grep -c "^retries = " service.conf)
[ "$n" -eq 1 ] \
  && sed -i "s/^retries = .*/retries = 5/" service.conf \
  && grep "^retries" service.conf
retries = 5

Three moves in one transcript: the count established the precondition (exactly one line will be touched), the substitution ran only inside that guarantee, and the read-back printed the proof. Had the file held two retries lines, or none, the chain would have stopped before the edit with the count as its explanation — a failure that costs one turn and explains itself, against a silent mis-edit that costs a debugging session weeks later. The pattern discipline inside the sed expression matters equally: anchor to the line’s start (^retries =), match the whole value (.*), and prefer patterns that restate the line’s full grammar over minimal fragments that happen to work today. sed -i also accepts a backup suffix (-i.orig), which drops a sibling copy before rewriting — cheap insurance, though the chapter’s top rung offers something better, and one caveat repeats from the symlink section: -i writes a new file over the name, so pointed at a link it replaces the link.

For edits beyond a line’s internals — inserting a block after a marker, deleting a stanza — resist the temptation to compose ever-cleverer sed programs. The register has a better instrument one rung up.

The diff is the native edit

An interactive human edits by manipulating a buffer; the register’s structurally best edit format is the unified diff — precisely because it is both the change and its documentation, in a form patch can apply, git can ingest, and a supervising reader can review in the transcript before anything happens. A diff states its context lines, so it refuses to apply against a file that has drifted from the version it was composed against — the read-before-edit rule, enforced by the file format itself.

cd "$(mktemp -d)"
printf "alpha\nbeta\ngamma\n" > config.txt
printf "alpha\nBETA\ngamma\n" > intended.txt
diff -u config.txt intended.txt > change.diff
patch --dry-run -p0 config.txt < change.diff \
  && patch -p0 config.txt < change.diff \
  && cat config.txt
checking file config.txt
patching file config.txt
alpha
BETA
gamma

The rehearsal is the point of the composition: --dry-run verifies the diff applies cleanly — against the real file, changing nothing — and only its success unlocks the real application. That two-step is chapter 6’s dry-run principle arriving early, and with diffs it is airtight in a way sed guards approximate: the dry run checks the entire precondition (every context line), not just a count. Two status notes for the transcript reader: diff itself answers like grep — 0 for identical, 1 for different, 2 for trouble — so a diff “failing” with 1 mid-script is the expected answer the files differ, not an error; and a real patch failure leaves .rej files naming exactly the hunks that could not land, which are evidence to read, not litter to delete. On any machine with git present, git diff --no-index, git apply --check, and git apply make the same ladder with sharper diagnostics; and inside an actual repository, the repository’s own tooling — not this chapter’s — is the right instrument, with version control providing the undo channel the register otherwise has to build by hand.

Replace the whole file, atomically

The top rung retires editing altogether: generate the complete intended content, validate it, and swap it into place. This is the register’s preferred rung for any file whose entire content the operator can own — because it is idempotent by construction (generating the same content twice converges), reviewable (the new content can be shown whole in the transcript), and, done correctly, atomic. The correctness hinges on one syscall guarantee: rename(2) within a filesystem is atomic — any process opening the path sees the old complete file or the new complete file, never a half-written intermediate. mv onto an existing name, same filesystem, is that syscall in shell clothing:

cd "$(mktemp -d)"
printf '{"port": 8080}\n' > app.json
printf '{"port": 9090}\n' > app.json.new
python3 -c 'import json; json.load(open("app.json.new"))' \
  && mv app.json.new app.json \
  && cat app.json
{"port": 9090}

The sequence is validate-then-swap, and the order carries the safety: the JSON parse ran against the staged file, so a generation bug — truncated output, an unclosed brace, chapter 2’s substitution silently emptying a variable — is caught while the live file is still intact, and the broken candidate never existed at the live path for even a millisecond. Any consumer that opened app.json mid-operation got a complete document. The pattern’s fine print earns respect: the staging file must be in the same directory as the target (cross-filesystem mv degrades to copy-then-delete, which is not atomic — and /tmp is routinely a different filesystem, so staging there forfeits the guarantee); the swap replaces metadata along with content, so files with deliberate modes or owners want chmod/chown on the staged copy before the mv; and the validator should be the consumer’s grammar — python3 -c json.load for JSON, sshd -t or visudo -c (privileged, fragments by this book’s rules) for the system files that ship their own checkers, a service’s own config-test flag where one exists. A validator that could have run and did not is the difference between an edit and a gamble.

When the unit of edit is a directory

The atomic swap has a limit the honest version of this chapter must state: it covers one file. A change spanning several files — a config directory, an application release, a static site — cannot be made atomic by renaming them one at a time; between the first rename and the last, every reader sees a mixture of versions, and the mixture is exactly the corruption atomicity exists to prevent. The filesystem has no multi-file transaction. What it has is one more atomic rename, applied a level up — the symlink flip, the pattern every deployment tool reinvents:

cd "$(mktemp -d)"
mkdir -p releases/v1 releases/v2
printf "old\n" > releases/v1/app.txt
printf "new\n" > releases/v2/app.txt
ln -s releases/v1 current
cat current/app.txt
ln -sfn releases/v2 current.new && mv -T current.new current
cat current/app.txt
readlink current
old
new
releases/v2

The live name, current, is a symlink; versions are complete, immutable sibling trees; and the “edit” is a rename of a freshly built link onto the live name — one rename(2), so every reader holds either wholly-v1 or wholly-v2, never a blend. The awkward spelling of the flip is load-bearing and worth reading closely: ln -sfn onto the live name directly would not be atomic (with a directory target it can pass through a deleted-then-recreated state, and some implementations descend into the target instead), so the new link is created beside the live one and mv -T — the -T forbidding the same descend-into misreading — performs the actual instantaneous cutover. Rollback is the same gesture pointed backward, which places this pattern on the top rung of chapter 6’s reversibility ladder: the entire previous version still exists, untouched, one flip away. The pattern’s tax is discipline about state: nothing writes into a released tree (releases are built complete, then flipped), and anything the application mutates at runtime lives outside the versioned trees entirely. Paid, the tax buys the multi-file edit this chapter otherwise could not offer.

Structured formats want structured editors

Every instrument so far treats files as lines of text, and for the classic key = value formats that is the truth of them. But a growing share of what administration edits is structured — JSON, YAML, TOML — and line tools are the wrong instrument for tree-shaped data, in a way the register’s operators are specially positioned to get wrong: a sed substitution against a JSON file often works, today, against this file, and that success teaches a habit that fails the first time the target key appears twice at different depths, or gains a string value containing the pattern, or arrives reserialized with different whitespace. Line tools match rendering; the file’s meaning lives in its parse. The correct instrument edits the parse:

cd "$(mktemp -d)"
printf '{"port": 8080, "workers": 4, "debug": false}\n' > app.json
python3 - <<'PYEOF'
import json, pathlib
p = pathlib.Path("app.json")
cfg = json.loads(p.read_text())
cfg["workers"] = 8
tmp = p.with_suffix(".json.new")
tmp.write_text(json.dumps(cfg, indent=2) + "\n")
tmp.replace(p)
print("workers now:", json.loads(p.read_text())["workers"])
PYEOF
workers now: 8

Twelve lines that assemble the whole chapter in miniature: parse (which is the read-before-edit — a malformed file dies here, before harm), modify by addressing the key in the tree rather than a pattern in the text, stage, atomically replace (Path.replace is the same rename(2) under the mv of the previous section), and re-parse as the read-back proof. For operators with jq installed, jq '.workers = 8' app.json > app.json.new reaches the same place for JSON one-liners; python3 earns the listing because it is already there on effectively every machine and speaks YAML and TOML through the same pattern (the standard library reads TOML natively; YAML needs the common third-party module). One structural honesty note: parse-and-reserialize normalizes formatting and drops comments where the format allows them (YAML, TOML), which is a real cost in human-maintained files — one more argument for the drop-in answer below, where your generated file is wholly yours and the human’s stays untouched.

Mode, owner, and the moment of creation

An edit’s content can be right while the file itself is wrong: readable by the world when it holds a secret, owned by root when a service user must write it — failures invisible in a cat and fatal in operation. The register’s discipline is to set metadata at creation, in the same shot, never as a remembered follow-up. The shell’s default is governed by umask (measured 0022 on the authoring machine: new files arrive world-readable), which is the wrong default for credentials, and the fix-it-later chmod leaves a window in which the secret was exposed — a window an operator with no continuous presence cannot even measure. The single-shot instrument is install, which combines copy, mode, and (with privilege) ownership in one atomic gesture:

cd "$(mktemp -d)"
printf "secret=1\n" > cred.new
install -m 600 cred.new cred.conf
stat -c "%n mode %a" cred.conf
cred.conf mode 600

The stat read-back is the metadata edition of the chapter’s standing rule, and belongs after any operation whose point was a mode or owner. Fold this into the atomic-swap pattern (stage, chmod/chown the staged copy, then mv) and the replacement arrives with content and metadata correct in the same instant — no window, nothing to remember, nothing for the next operator to discover the hard way.

Do not edit what you do not own

The ladder’s final lesson is about choosing not to climb it. Much of what administration edits — package-installed configuration, another tool’s managed files — has an owner that will edit it again: the package manager on upgrade, the provisioning system on its next run, the tool regenerating its own state. Editing such files puts two writers on one file, and the second writer always wins eventually. Modern configuration design offers the way out this chapter’s systemd threads have already pointed at: the drop-in directory. <unit>.d/*.conf, sudoers.d/, sshd_config.d/ — the pattern is general: the owned file stays owned, and local intent lives in a separate file the owner promises to include. A drop-in converts every rung of this chapter into its safest form at once: creating a file is naturally guarded (it exists or it does not), naturally atomic (stage and rename), naturally reviewable (the whole local intent in one small file), and removable by deleting one path — an undo channel requiring no memory of what the file looked like before, which for an operator with no memory is not a convenience but the whole point. When a drop-in mechanism exists, the register’s edit of choice is not an edit at all; it is a new file with your name on it, placed where the owner agreed to look.

The tools that insist

A last practicality: some tools open an editor as their interface, and the ladder must be threaded through them rather than around them. Each has its non-interactive door, usually less advertised than the editing form. crontab -e has crontab <file> — generate the full table (top rung: whole-file replacement), validate by listing it back with crontab -l, and install it as data; the editor was never required. git commit takes -m; the interactive rebase’s editor can be replaced wholesale by setting GIT_SEQUENCE_EDITOR to a script that rewrites the todo file — an editor implemented as a one-shot edit, the chapter’s thesis made literal. visudo, whose editor session exists to guarantee syntax checking, splits into its two halves: visudo -c -f candidate runs the checker alone against a staged file, which slots exactly into the validate-then-swap pattern — and better still, the file being staged belongs in sudoers.d, converting the whole operation into the drop-in form below. The general method when meeting a new insistent tool: read its manual for the non-interactive door first (-c flags, --file forms, EDITOR overrides — the environment variable is honored by most, and an EDITOR that is itself a script receives the temp file’s path as its argument, making any scripted transformation into a legal “editor”). The door almost always exists, because scripts needed it decades before this register’s operators did — the same inheritance chapter 1 traced, paying out one tool at a time.

The chapter’s ladder, bottom to top: guard your appends, count before you substitute, rehearse your diffs, validate then swap, and prefer the drop-in that makes the whole question moot. Every rung ended in a read-back, because in this register the file’s final state is the only witness to the edit that matters. What editing has not yet faced is the operation that cannot be read back — the one that removes, overwrites, or reaches beyond the machine. That is chapter 6, and it is the chapter this whole book exists to make safe.

1 / 1