Skip to content

Commit a8157d6

Browse files
ausimianclaude
andcommitted
fix: write through the handle rather than reopening the name
write_private/2 opened the file exclusively, closed the handle, and then reopened the same name with File.write/2 to put the content in. The exclusive open is what establishes that the name did not exist, and closing it to reopen by path gives that proof back: anything able to create the name in between is handed the configuration, which is the disclosure the exclusive open was added to prevent. So the bytes now go through the handle the open returned, and the name is never reopened to place content. The content can only reach the inode this call created, whatever becomes of the name afterwards. Creation and filling are one movement for the same reason - there is no longer a way here to bring one of these files into existence without also placing its content, because separating the two is what let the reopen back in, so create_private/1 is gone and write_private/2 is what call sites use. chmod is now the only step that goes by path, and it has to: OTP has nothing that sets a mode on an open file. :file.change_mode/2 rejects a handle with badarg and :file.write_file_info/2 with function_clause, and those, with raw_write_file_info/2, are the whole of what :file offers. The asymmetry is accepted rather than overlooked. By the time the chmod runs the content is already committed to this call's inode, so a name swapped underneath it does not receive the configuration - it gets narrowed, which is Castle setting some other file to 0600 inside a directory it verified empty and made 0700. A nuisance, not a disclosure. The test asserts both halves of that, so the cost is pinned rather than merely asserted in a comment. The mode still goes on after the content rather than before, and the reason is now stated properly: it is about the writes that come later. The scratch is written twice more after Castle creates it - the peer's pipeline writes the resolved configuration over it, then this module writes it again - and both reopen the name, so a 0440 model applied any earlier would leave a file that cannot be reopened for writing. One of those two is Config.Provider.write_config!, which is File.write/2 in Elixir's own code, running in the peer's VM. Driving Elixir's pipeline is what this module exists for, so that is not ours to change and is not worked around. Holding the creation handle open across the peer's run would not cover it either, and the comment says why it must not be tried: the peer writes by name in a VM of its own, and a handle kept here would point at whichever inode the name had when it was opened. Elixir truncates the same inode today; one that wrote a temporary file and renamed it would leave the handle on an orphan and the configuration written through it would silently be nobody's. Those two writes rest on the directory - verified empty, 0700, holding only names this call created - and that is what the directory is for. The test stands between the exclusive open and the write, which is why create_exclusive/1 and fill/3 are public: the name is swapped for a symlink to a decoy in between, and the configuration is found in neither the decoy nor anywhere the name leads. With the name left alone the two behaviours are identical, so there is no other way to tell content that went to the inode from content that went to the name. It fails against ad68d0e because the handle it writes through does not exist there - write_private/2 had no seam to stand in, which is the same shape of discrimination as secure_dir/1 last time and worth saying plainly rather than dressing up. Refs: #13 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent ad68d0e commit a8157d6

3 files changed

Lines changed: 201 additions & 97 deletions

File tree

AGENTS.md

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,33 @@ Castle's job is configuration and release management on a running node.
9797
and removing it if it is not. `plan/2` is the one place the two file paths are
9898
decided, and it puts both inside that directory under the names they will have
9999
when they leave. Each is created **exclusively**, so a name already at the
100-
path is refused rather than followed or truncated.
101-
102-
**The rule is create, narrow, then verify.** The verification is not
103-
decoration and it is not defence in depth: it is there because five successive
104-
attempts to reason about whether a window was harmless were all wrong, and a
105-
sixth judgement of the same kind is worth nothing. Do not remove it on the
106-
grounds that you can see why the window is safe. That is precisely the
107-
sentence that preceded each of the previous five findings.
100+
path is refused rather than followed or truncated, and each is **written
101+
through the handle that exclusive open returned**.
102+
103+
**Never reopen a name to place content.** The exclusive open's whole value is
104+
that it establishes the name did not exist; closing the handle and reopening
105+
the same name by path throws that away, and anything able to create the name
106+
in between is handed the configuration. `write_private/2` therefore creates
107+
and fills in one movement — there is deliberately no way here to bring one of
108+
these files into existence without also placing its content, because
109+
separating the two is what let a reopen back in.
110+
111+
The `chmod` is the one step that still goes by path, because OTP has nothing
112+
that sets a mode on an open file: `:file.change_mode/2` and
113+
`:file.write_file_info/2` take a name and reject a handle (`:badarg` and
114+
`:function_clause`). That is an accepted asymmetry rather than an oversight.
115+
By the time it runs the content is already committed to the inode the open
116+
created, so a name swapped underneath it does not receive the configuration —
117+
it gets narrowed, which is Castle setting some other file to 0600 inside a
118+
directory it verified empty and made 0700. A nuisance, not a disclosure.
119+
120+
**The rule is create, narrow, then verify — and write through the handle you
121+
created.** The verification is not decoration and it is not defence in depth:
122+
it is there because five successive attempts to reason about whether a window
123+
was harmless were all wrong, and a sixth judgement of the same kind is worth
124+
nothing. Do not remove it on the grounds that you can see why the window is
125+
safe. That is precisely the sentence that preceded each of the previous five
126+
findings.
108127

109128
The particular argument it replaced, so nobody reconstructs it: an empty
110129
directory has nothing behind the window, and permission to traverse a
@@ -145,30 +164,34 @@ Castle's job is configuration and release management on a running node.
145164
`mkdir` and ignored the `chmod`, where none of this can be honoured and the
146165
operator's own mode on `sys.config` would not be either.
147166

148-
Inside the directory the file is still created owner-only at 0600, filled
149-
through that, and given the model's mode **last**`write_like/3` is the two
150-
of those together, for a file written once. Not the other way round, which is
151-
the obvious reading and is wrong: a `sys.config` at 0440 is an operator
152-
declaring their configuration read-only, and a file chmodded to 0440 before
153-
being filled cannot be filled. `File.write/2` reopens the path rather than
154-
writing through a handle held from creation, so the fill fails `:eacces`
155-
against a file its own owner has just made read-only, and the install stops.
156-
Do not "simplify" the ordering back.
157-
158-
0600 satisfies both constraints at once rather than trading between them: it
159-
grants nothing to group or other, so every transient state is *narrower* than
160-
the destination rather than merely different from it, and it leaves the file
161-
writable by its owner while there is writing to do. For the scratch that is
162-
until the last of three writes — this module fills it, the peer's pipeline
163-
writes the resolved configuration over it, this module writes it again — so
164-
the mode goes on after all of them, immediately before the rename. A failure
165-
part-way leaves the file narrower than intended, never wider. It is no longer
166-
the 0600 that closes the window — the directory is — so treat it as the belt to
167-
that braces, and keep it. The two operations that move one of these files into
168-
place, the link that publishes the base and the rename that replaces
169-
`sys.config`, need permission on the directories rather than on the file, so a
170-
restrictive mode never has to be relaxed again; nor does removing what is left
171-
in the working directory afterwards.
167+
The file is given 0600 on creation and the model's mode **last**
168+
`write_like/3` is `write_private/2` plus that, for a file written once. The
169+
ordering matters for the writes that come *after*, not for the first one: a
170+
`sys.config` at 0440 is an operator declaring their configuration read-only,
171+
and the scratch is written twice more after Castle creates it — the peer's
172+
pipeline writes the resolved configuration over it, then this module writes it
173+
again — with both of those reopening the name, and a file at 0440 cannot be
174+
reopened for writing. So the model's mode goes on last of all, immediately
175+
before the rename, and a failure part-way leaves the file narrower than
176+
intended rather than wider. Do not "simplify" the ordering back.
177+
178+
**The scratch's later writes rest on the directory, not on the handle.** One of
179+
them is `Config.Provider.write_config!`, which is `File.write/2` in Elixir's
180+
own code, in the peer's VM — driving Elixir's pipeline is what this module is
181+
for, so that is not ours to change and must not be worked around. Nor may the
182+
creation handle be held open across the peer's run to cover them: the peer
183+
writes by name in a VM of its own, and a handle kept here would go on pointing
184+
at whichever inode the name had when it was opened. Elixir truncates the same
185+
inode today; one that wrote a temporary file and renamed it would leave the
186+
handle on an orphan and the configuration written through it would silently be
187+
nobody's. What protects those writes is that the directory was verified empty,
188+
is 0700, and holds only names Castle created.
189+
190+
The two operations that move one of these files into place, the link that
191+
publishes the base and the rename that replaces `sys.config`, need permission
192+
on the directories rather than on the file, so a restrictive mode never has to
193+
be relaxed again; nor does removing what is left in the working directory
194+
afterwards.
172195

173196
`File.write/2` creates with the process umask and never looks at a mode.
174197
`File.cp/2` *does* carry the mode — and was adopted here for that reason — but
@@ -380,13 +403,23 @@ commit — and the other once with the environment as it ended up. The two
380403
its symlinks idempotently: a root has to be able to hold two versions.
381404

382405
`Castle.Peer.work_dir/1`, `secure_dir/1`, `write_like/3`, `write_private/2`,
383-
`create_private/1` and `publish/2` are public for the same kind of reason: what
384-
they guarantee is about *intermediate* states, and a window nothing can stand in
385-
is a window nothing can test. One test takes `work_dir/1`, `write_like/3` and
386-
`publish/2` one at a time and looks at the destination in between — where it finds
387-
no file, rather than a partial one — then checks that publishing again is refused
388-
rather than allowed to replace. Another calls `work_dir/1` and finds the
389-
directory at 0700 and still empty.
406+
`create_exclusive/1`, `fill/3` and `publish/2` are public for the same kind of
407+
reason: what they guarantee is about *intermediate* states, and a window nothing
408+
can stand in is a window nothing can test. One test takes `work_dir/1`,
409+
`write_like/3` and `publish/2` one at a time and looks at the destination in
410+
between — where it finds no file, rather than a partial one — then checks that
411+
publishing again is refused rather than allowed to replace. Another calls
412+
`work_dir/1` and finds the directory at 0700 and still empty. Call sites use
413+
`write_private/2`; `create_exclusive/1` and `fill/3` are public only so that the
414+
window between them can be stood in, and never to be called in sequence by
415+
anything else.
416+
417+
`fill/3` being separable is what lets a test swap the name between the exclusive
418+
open and the write, and assert that the content reached the inode that was
419+
created while the file the name now points at never saw it — with that same test
420+
asserting the acknowledged cost, that the by-path `chmod` does land on the
421+
swapped name. With the name left alone the two behaviours are identical, so
422+
there is no other way to tell them apart.
390423

391424
`secure_dir/1` is public so that the `mkdir`-to-`chmod` window can be stood in:
392425
a test creates a directory at 0777, plants a `sys.config` symlink inside it, and

lib/castle/peer.ex

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -658,27 +658,44 @@ defmodule Castle.Peer do
658658
# the `mkdir` and ignored the `chmod`, where nothing here can be honoured and
659659
# the operator's mode on `sys.config` would not be honoured either.
660660
#
661-
# Inside the directory the file is still created owner-only, filled through
662-
# that, and given the model's mode last. Not the other way round, which is the
663-
# obvious reading of "set the mode before there is anything to read" and is
664-
# wrong: a `sys.config` at 0440 is an operator declaring their configuration
665-
# read-only, and a file chmodded to 0440 before being filled cannot be filled.
666-
# `File.write/2` reopens the path rather than writing through a handle held
667-
# from creation, so the fill fails with `:eacces` and the install stops. Do not
668-
# "simplify" the ordering back.
661+
# Inside the directory the bytes go through the handle the exclusive open
662+
# returned, and the name is never reopened to place content. That is the point
663+
# of opening `:exclusive` rather than a refinement of it: the exclusive open
664+
# *establishes* that the name did not exist, and closing the handle to reopen
665+
# the same name by path throws that proof away - whatever can create the name
666+
# in between is handed the content. Written through the handle, the content can
667+
# only ever reach the inode this call created, whatever becomes of the name
668+
# afterwards.
669669
#
670-
# 0600 satisfies both constraints at once rather than trading between them. It
671-
# grants nothing at all to group or other, so every intermediate state is
672-
# narrower than the destination rather than merely different from it, and it
673-
# leaves the file writable by its owner for as long as there is writing to do.
674-
# Which, for the scratch, is until the last of three writes: this module fills
675-
# it, the peer's pipeline writes the resolved configuration over it, and this
676-
# module writes it again. The model's mode goes on after all of that, as the
677-
# last thing done before the file leaves the working directory under its final
678-
# name - which is also why a failure part-way through leaves it narrower than
679-
# intended rather than wider. It is no longer the 0600 that closes the window,
680-
# the directory is; what it buys now is that a file's own mode is never the
681-
# umask's choice, at the cost of one `chmod`.
670+
# The mode still goes on by path, because OTP has nothing that sets a mode on
671+
# an open file: `:file.change_mode/2` and `:file.write_file_info/2` take a name
672+
# and reject a handle - `:badarg` and `:function_clause` respectively. That
673+
# asymmetry is deliberate, and it is cheap. By the time the `chmod` runs the
674+
# content is already committed to this call's inode, so a name swapped
675+
# underneath it does not receive the configuration; it gets narrowed. The worst
676+
# it buys is that Castle sets somebody else's file to 0600, inside a directory
677+
# it verified empty and made 0700. A nuisance, not a disclosure.
678+
#
679+
# The mode goes on *after* the content rather than before, and that ordering is
680+
# about the writes which come later rather than this one. A `sys.config` at
681+
# 0440 is an operator declaring their configuration read-only, and the scratch
682+
# is written twice more after this: the peer's pipeline writes the resolved
683+
# configuration over it, and this module writes it again. Both of those reopen
684+
# the name - `Config.Provider.write_config!` is a `File.write/2` in Elixir's
685+
# own code, and Elixir's pipeline is what this module exists to drive - and a
686+
# file at 0440 cannot be reopened for writing. So the model's mode goes on last
687+
# of all, immediately before the file leaves under its final name, which is
688+
# also why a failure part-way through leaves it narrower than intended rather
689+
# than wider. Do not "simplify" the ordering back.
690+
#
691+
# Holding this handle open across the peer's run would not extend the
692+
# guarantee to those two writes, and must not be tried. The peer writes in a VM
693+
# of its own and by name, while a handle kept here would go on pointing at
694+
# whichever inode the name had when it was opened: today Elixir truncates the
695+
# same one, but an Elixir that wrote a temporary file and renamed it would
696+
# leave this pointing at an orphan, and the configuration written through it
697+
# would silently be nobody's. What those two writes rest on is the directory -
698+
# verified empty, 0700, and holding only names this call created.
682699
#
683700
# Neither of the obvious primitives has any of this. `File.write/2` creates
684701
# with the process umask and never looks at a mode. `File.cp/2` does carry the
@@ -688,12 +705,16 @@ defmodule Castle.Peer do
688705
#
689706
# What is not reproduced is ownership - only the mode bits are. See AGENTS.md.
690707
#
691-
# `work_dir/1` is public along with `write_like/3`, `write_private/2`,
692-
# `create_private/1` and `publish/2`, because the intermediate states are the
693-
# point: a mode that is only ever correct once the content is written looks
694-
# exactly like a mode that was correct all along, a directory that was secured
695-
# after it was filled looks exactly like one secured before, and a window
696-
# nothing can stand in is a window nothing can test.
708+
# `work_dir/1` is public along with `secure_dir/1`, `write_like/3`,
709+
# `write_private/2`, `create_exclusive/1`, `fill/3` and `publish/2`, because the
710+
# intermediate states are the point: a mode that is only ever correct once the
711+
# content is written looks exactly like a mode that was correct all along, a
712+
# directory that was secured after it was filled looks exactly like one secured
713+
# before, content that went to a reopened name looks exactly like content that
714+
# went to the handle it was created with, and a window nothing can stand in is a
715+
# window nothing can test. `write_private/2` is what call sites use; the two it
716+
# is made of are public so that the window between them can be stood in, and
717+
# for no other reason.
697718

698719
# Created rather than ensured: a name that is already there is not adopted, and
699720
# `File.mkdir/1` is what refuses it - measured against a directory, a regular
@@ -758,43 +779,59 @@ defmodule Castle.Peer do
758779
with :ok <- write_private(path, bytes), do: carry_mode(model, path)
759780
end
760781

782+
# Created and filled in one movement, because separating the two is what put a
783+
# reopened name between them. There is no way to bring one of these files into
784+
# existence here without also placing its content.
761785
@doc false
762786
@spec write_private(Path.t(), iodata()) :: :ok | {:error, String.t()}
763787
def write_private(path, bytes) do
764788
with :ok <- private_dir(Path.dirname(path)),
765-
:ok <- create_private(path) do
766-
write(path, bytes)
789+
{:ok, handle} <- create_exclusive(path) do
790+
fill(handle, path, bytes)
767791
end
768792
end
769793

770794
# Exclusively, so that a name already at this path is refused rather than
771795
# followed or truncated. `File.write/2` here would do neither safely: pointed at
772796
# a symlink it truncates what the link points at, chmods *that* to 0600, and
773797
# fills it with the configuration - and creates the target outright if the link
774-
# dangles. All three measured. The mode still goes on afterwards, because there
775-
# is still no way to ask for it at creation.
798+
# dangles. All three measured. What the open returns is the handle the content
799+
# goes through, and the caller's obligation is to `fill/3` it: that is what
800+
# keeps the proof this open just established.
776801
@doc false
777-
@spec create_private(Path.t()) :: :ok | {:error, String.t()}
778-
def create_private(path) do
779-
with :ok <- create_exclusive(path), do: chmod(path, 0o600)
780-
end
781-
782-
defp create_exclusive(path) do
802+
@spec create_exclusive(Path.t()) :: {:ok, File.io_device()} | {:error, String.t()}
803+
def create_exclusive(path) do
783804
case File.open(path, [:write, :exclusive, :raw]) do
784-
{:ok, handle} -> close(path, handle)
805+
{:ok, handle} -> {:ok, handle}
785806
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
786807
end
787808
end
788809

789-
# Closed rather than written through, because what is written to these files is
790-
# written more than once and by more than this module - the peer's own pipeline
791-
# writes the scratch too - so no handle could serve all of it. Nothing can reach
792-
# the path between the close and the write: the directory holding it has been
793-
# verified private.
794-
defp close(path, handle) do
810+
# The content through the handle, the mode by path, and the handle closed
811+
# whichever way the write went. A write error is reported ahead of a close
812+
# error, being the one that says what actually happened, and the mode is set
813+
# only once both have succeeded - so a file that was not written in full is
814+
# never given the mode that says it was.
815+
@doc false
816+
@spec fill(File.io_device(), Path.t(), iodata()) :: :ok | {:error, String.t()}
817+
def fill(handle, path, bytes) do
818+
written = written(handle, path, bytes)
819+
closed = closed(handle, path)
820+
821+
with :ok <- written, :ok <- closed, do: chmod(path, 0o600)
822+
end
823+
824+
defp written(handle, path, bytes) do
825+
case IO.binwrite(handle, bytes) do
826+
:ok -> :ok
827+
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
828+
end
829+
end
830+
831+
defp closed(handle, path) do
795832
case File.close(handle) do
796833
:ok -> :ok
797-
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
834+
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
798835
end
799836
end
800837

0 commit comments

Comments
 (0)