@@ -57,58 +57,150 @@ Castle's job is configuration and release management on a running node.
5757 result has to land in; the first materialisation therefore copies it to
5858 ` sys.config.pristine ` and every later one seeds from there.
5959
60- That copy is staged under a name of its own, given the mode ` sys.config ` has,
61- and published by hard link. Not written to its final name, and an exclusive
62- create is not enough either: exclusivity makes * creation* atomic, not
63- publication, so the file exists and is empty between the open and the write —
64- long enough for a racing reader to see something that is not a configuration,
65- and, if the install died there, long enough to leave a truncated base that
66- every later evaluation would prefer to the original still in ` sys.config ` . A
67- link publishes a file that is already complete, and refuses rather than
68- replaces, so the loser of a race reads what the winner published instead of
69- its own copy. Staging that never gets published is left where it is: an
70- install cannot tell its own leftovers from another install's work in progress,
71- so it does not try, and nothing reads that name. Do not "tidy up" stray
72- ` castle-*.pristine ` files in code for the same reason.
73-
74- ** Every file this module creates comes into existence through
75- ` Castle.Peer.write_private/2 ` , and new ones must too.** Each of them holds a
60+ That copy is staged in the working directory below, given the mode
61+ ` sys.config ` has, and published by hard link. Not written to its final name,
62+ and an exclusive create is not enough either: exclusivity makes * creation*
63+ atomic, not publication, so the file exists and is empty between the open and
64+ the write — long enough for a racing reader to see something that is not a
65+ configuration, and, if the install died there, long enough to leave a truncated
66+ base that every later evaluation would prefer to the original still in
67+ ` sys.config ` . A link publishes a file that is already complete, and refuses
68+ rather than replaces, so the loser of a race reads what the winner published
69+ instead of its own copy. Staging that never gets published is left where it is:
70+ an install cannot tell its own leftovers from another install's work in
71+ progress, so it does not try, and nothing reads that name. Do not "tidy up"
72+ stray ` castle-* ` names — files or directories — in code for the same reason.
73+ ` Castle.Peer ` removes the working directory * it* made, on every way out, and
74+ nothing else.
75+
76+ ** Both files this module creates are made inside an owner-only working
77+ directory and moved out of it, and a new one must be too.** Each holds a
7678 release's configuration — the base, and the scratch copy the providers resolve
77- into — so none of them may be readable by anyone the ` sys.config ` it came from
78- or is about to become would exclude: an operator who restricts that file has
79- said something, and it has to hold for the copies.
80-
81- The file is created owner-only at 0600, filled through that, and given the
82- model's mode ** last** — ` write_like/3 ` is the two of those together, for a
83- file written once. Not the other way round, which is the obvious reading and
84- is wrong: a ` sys.config ` at 0440 is an operator declaring their configuration
85- read-only, and a file chmodded to 0440 before being filled cannot be filled.
86- ` File.write/2 ` reopens the path rather than writing through a handle held from
87- creation, so the fill fails ` :eacces ` against a file its own owner has just
88- made read-only, and the install stops. Do not "simplify" the ordering back.
89-
90- 0600 satisfies both constraints at once rather than trading between them: it
91- grants nothing to group or other, so the transient state is * narrower* than
92- the destination rather than merely different from it, and it leaves the file
93- writable by its owner while there is writing to do. For the scratch that is
94- until the last of three writes — this module fills it, the peer's pipeline
95- writes the resolved configuration over it, this module writes it again — so
96- the mode goes on after all of them, immediately before the rename. A failure
97- part-way leaves the file narrower than intended, never wider. The two
98- operations that move one of these files into place, the link that publishes
99- the base and the rename that replaces ` sys.config ` , need permission on the
100- directory rather than on the file, so a restrictive mode never has to be
101- relaxed again.
102-
103- The ordering lives inside the primitive rather than at the call sites because
104- remembering it at the call sites is what failed, three times. ` File.write/2 `
105- creates with the process umask and never looks at a mode. ` File.cp/2 ` * does*
106- carry the mode — and was adopted here for that reason — but it writes the whole
107- file first and narrows it afterwards (` :file.copy ` , then ` copy_file_mode/2 ` at
108- ` file.ex:1285 ` ), which is the same exposure with a shorter window. The end
109- state is identical either way, which is exactly why no test of the end state
110- caught it. Do not add a fourth way to write one of these files; extend the
111- primitive.
79+ into — so none may be readable by anyone the ` sys.config ` it came from or is
80+ about to become would exclude: an operator who restricts that file has said
81+ something, and it has to hold for the copies.
82+
83+ ** The protection is the directory, not the file's own mode, and it has to be:
84+ OTP cannot create a file with a mode.** ` :file.open/2 ` 's modes say how a file
85+ is to be read and written and nothing about the permissions it is created
86+ with — kernel's ` mode() ` type is the whole list — and an unrecognised option is
87+ ignored rather than refused, so ` {:mode, 0o600} ` is * accepted* and does
88+ nothing. The inode is created 0666 against the umask whichever way in you
89+ take. So a mode can only be applied to a file that already exists, and ` chmod `
90+ does not revoke a descriptor somebody already holds: a reader who opened the
91+ path while it was 0644 goes on reading everything written afterwards. That is
92+ a standing read channel, not a blink, and no amount of care at the call site
93+ closes it. Do not go looking for an atomic create-with-mode; there isn't one.
94+
95+ What works is ` Castle.Peer.work_dir/1 ` : ` mkdir ` a directory in the version
96+ directory, chmod it 0700, and then ** check that it is still empty** , refusing
97+ and removing it if it is not. ` plan/2 ` is the one place the two file paths are
98+ decided, and it puts both inside that directory under the names they will have
99+ when they leave. Each is created ** exclusively** , so a name already at the
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.
127+
128+ The particular argument it replaced, so nobody reconstructs it: an empty
129+ directory has nothing behind the window, and permission to traverse a
130+ directory is checked on every lookup rather than captured at open the way
131+ permission to read a file is, so a stale directory descriptor grants nothing
132+ once the chmod has happened. Both halves are true, and both are about
133+ * reading* . A directory the umask left group-writable — 0002 is an ordinary
134+ umask and 0000 exists — can be written * into* during that window, and the child
135+ names are predictable, so an interloper needs no descriptor at all: it plants
136+ ` sys.config ` as a symlink to a file it can read and waits for the
137+ configuration to arrive through it. Empty is safe to read. It is not safe to
138+ write into.
139+
140+ Exclusivity and privacy are separate properties and neither substitutes for
141+ the other. ` :exclusive ` on the open says nothing about the permissions the
142+ inode arrives with, which is why it is no answer to the paragraph above — that
143+ was measured, and ` {:mode, _} ` alongside it is silently ignored. A private
144+ directory says nothing about what a name already inside it would do, which is
145+ why it is no answer to a planted symlink: ` File.write/2 ` follows one, truncates
146+ what it points at, chmods * that* to 0600, fills it with the configuration, and
147+ creates the target outright if the link dangles. All measured, all refused by
148+ ` :exclusive ` , which returns ` :eexist ` for a regular file, a symlink and a
149+ dangling symlink alike. ` File.mkdir/1 ` refuses all three too, which is what
150+ stops the working directory's own name being taken first.
151+
152+ It does not defend a version directory other accounts can write to: whoever
153+ can create a name there can replace ` sys.config ` itself, so that release is
154+ compromised before Castle is asked to configure it. The case defended is the
155+ ordinary one, a version directory anyone may traverse and read.
156+
157+ ` write_private/2 ` ** refuses** to create a file in a directory that grants
158+ anything to group or other, rather than trusting the caller to have picked a
159+ path inside the working directory — the invariant is in the primitive because
160+ remembering it at the call sites is what failed, four times over. It is a
161+ guard against the next call site rather than against an attacker — a directory
162+ can be chmodded between the check and the create, which is the gap the
163+ exclusive create covers — and it also catches a filesystem that took the
164+ ` mkdir ` and ignored the ` chmod ` , where none of this can be honoured and the
165+ operator's own mode on ` sys.config ` would not be either.
166+
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.
195+
196+ ` File.write/2 ` creates with the process umask and never looks at a mode.
197+ ` File.cp/2 ` * does* carry the mode — and was adopted here for that reason — but
198+ it writes the whole file first and narrows it afterwards (` :file.copy ` , then
199+ ` copy_file_mode/2 ` at ` file.ex:1285 ` ), which is the same exposure with a
200+ shorter window. The end state is identical either way, which is exactly why no
201+ test of the end state caught any of it. Do not add another way to write one of
202+ these files, and do not create one next to ` sys.config ` however carefully;
203+ extend the primitive, and put the file in the working directory.
112204
113205 ** Ownership and group are not reproduced — only the mode bits are.** This is a
114206 property of the design, not an oversight. Reproducing them needs ` chown ` ,
@@ -154,9 +246,8 @@ Castle's job is configuration and release management on a running node.
154246 never answers cannot hold an install open. Everything that can refuse — a
155247 missing boot script, an emulator that is not there, a provider that raises, a
156248 compile environment that does not agree — refuses before ` install_release/1 `
157- is called. The resolved configuration is assembled in a copy beside
158- ` sys.config ` and renamed onto it, so a version never holds half a
159- configuration.
249+ is called. The resolved configuration is assembled in the working directory
250+ and renamed onto ` sys.config ` , so a version never holds half a configuration.
160251
161252 The control connection is a socket rather than ` connection: :standard_io ` ,
162253 which is what the issue suggested. Standard IO multiplexes the peer's console
@@ -311,24 +402,53 @@ commit — and the other once with the environment as it ended up. The two
311402` sys.config ` terms have to be equal. That is why ` Castle.SyntheticRelease ` makes
312403its symlinks idempotently: a root has to be able to hold two versions.
313404
314- ` Castle.Peer.write_like/3 ` , ` write_private/2 ` , ` create_private/1 ` and
315- ` publish/2 ` are public for the same kind of reason: what they guarantee is about
316- * intermediate* states, and a window nothing can stand in is a window nothing can
317- test. One test takes ` write_like/3 ` and ` publish/2 ` one at a time and looks at
318- the destination in between — where it finds no file, rather than a partial one —
319- then checks that publishing again is refused rather than allowed to replace.
320- Another calls ` create_private/1 ` and finds the file already at 0600, which is the
321- state that makes the window harmless whatever mode the file ends up with.
405+ ` Castle.Peer.work_dir/1 ` , ` secure_dir/1 ` , ` write_like/3 ` , ` write_private/2 ` ,
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.
423+
424+ ` secure_dir/1 ` is public so that the ` mkdir ` -to-` chmod ` window can be stood in:
425+ a test creates a directory at 0777, plants a ` sys.config ` symlink inside it, and
426+ asserts that securing it is refused, the directory removed and what the symlink
427+ pointed at untouched. That is the only way to observe it — nothing about the end
428+ state distinguishes a directory that was empty when it was narrowed from one that
429+ was not, which is what made the same mistake possible a sixth time at the
430+ directory after five at the file. A companion test plants a name inside an
431+ already-private directory and asserts ` write_private/2 ` refuses it rather than
432+ writing through it, which is the half a private directory does not cover.
322433
323434Those have to be written that way. The mode a file * ends up* with is the same
324435whether it was set before or after the content, so a test of the end state
325- passes either way — which is how the exposure survived a round of review that had
326- already identified the class. The in-peer observation of the scratch file's mode
327- is a regression guard on the site that was wrong, not a discriminator: it passes
328- against the version that had the window too. What * is* a discriminator, and the
329- reason the ordering can no longer be reversed by accident, is the release whose
330- ` sys.config ` is 0440: it materialises twice and both files end at 0440, where
331- setting the mode first fails to write the file at all.
436+ passes either way — which is how the exposure survived two rounds of review that
437+ had already identified the class. The in-peer observation of the scratch file's
438+ mode is a regression guard on the site that was wrong, not a discriminator: it
439+ passes against a version that had the window too.
440+
441+ What * is* a discriminator, and what makes the exposure unreachable rather than
442+ merely narrow, is the in-peer walk of the version directory taken while both
443+ files exist and both hold configuration: the only thing Castle has put there is
444+ one directory at 0700, every configuration-bearing file it made is inside that,
445+ and the version directory itself holds nothing of Castle's but the two names it
446+ publishes. Against the version that created those files next to ` sys.config `
447+ there is no such directory at all, so the assertion cannot pass by accident.
448+ The other discriminator, and the reason the mode ordering cannot be reversed by
449+ accident either, is the release whose ` sys.config ` is 0440: it materialises twice
450+ and both files end at 0440, where setting the mode first fails to write the file
451+ at all.
332452
333453Two of these tests would pass for the wrong reason if written carelessly, so
334454they are written to fail when what they rest on moves. The compile-environment
0 commit comments