rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "fmt"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "regexp"
9
10 "amadan.net/rastrillo/fiesta/internal/site"
11)
12
13// The ownership check is rastrillo's dsgen's, with the stamp renamed.
14// That command once emptied its output root with no such check and,
15// pointed at the wrong directory, wiped 152 files that were not its
16// own; a version after it removed only the paths it was about to
17// write, which was safe and left a renamed file published for as long
18// as the build directory lived. The stamp is how it gets both.
19
20// stampName is the file fiesta build, fiesta gallery, fiesta release
21// and fiesta site leave in a directory to say the directory is its
22// own. Dotted so it stays out of the way of whatever serves the tree.
23// One name for every command, and the body says which wrote it: a
24// directory one of them wrote is not a directory another may empty.
25// The name is site's, because site.Release skips exactly this file in
26// a committed release directory and publishes nothing else hidden.
27const stampName = site.StampName
28
29// stampWriter reads the command a stamp names.
30var stampWriter = regexp.MustCompile(`written by (fiesta [a-z]+),`)
31
32// writerOf is the command a stamp's body names, or "" when it names
33// none.
34func writerOf(body []byte) string {
35 if m := stampWriter.FindSubmatch(body); m != nil {
36 return string(m[1])
37 }
38 return ""
39}
40
41// stampBody is what the stamp says, naming the command that wrote it.
42func stampBody(command string) string {
43 return "This directory is written by " + command + ", which EMPTIES it on every\n" +
44 "run. Do not keep anything here. Delete this file and " + command + " will\n" +
45 "refuse the directory instead.\n"
46}
47
48// claim makes root a directory command (fiesta build or fiesta gallery)
49// owns and empties it, or fails without touching anything.
50//
51// Ownership is the whole of the safety story, and it is deliberately
52// conservative: an absent directory is created, an empty one is
53// adopted, one holding this command's own stamp and no other stamp
54// anywhere below it is emptied and reused, and anything else is
55// refused with an explanation. So the command can be pointed at the
56// wrong directory, and the wrong directory is almost never empty.
57//
58// The other command's stamp counts as anything else, at the root and
59// at any depth: fiesta build writes DIR/<version>/ and fiesta gallery
60// writes DIR, so a gallery pointed at a release, or at a directory a
61// release has since been built inside, would otherwise empty the
62// release. Both are refused, naming the stamp that stopped them.
63//
64// Emptying rather than removing named paths is the other half.
65// Removing only what the current build produces looks safer and is
66// not: a file renamed between versions leaves its old name behind,
67// published, linked, and invisible to a check that looks at which
68// files are present rather than which are not. The answer is that
69// there is never a second copy to drift: the output is the build,
70// every time.
71//
72// Symbolic links, two rules. A root that is a link is resolved first,
73// so reading the stamp, walking the tree and emptying it all see the
74// same real directory: ReadDir and ReadFile follow a link and WalkDir
75// does not, and a gallery pointed at a link to a directory with a
76// release inside passed the check and emptied the release. A link
77// anywhere inside the tree is refused by name, whatever it points at:
78// neither command writes one, so it is not their output, and refusing
79// it is the one rule that both never follows a link out of the tree
80// when emptying and never empties a foreign-stamped directory through
81// one.
82//
83// The stamp is written before the files, so a run interrupted halfway
84// leaves a directory the command still owns and will clean out on the
85// next run. It is not atomic: a failed run leaves a partial tree
86// behind, and the only thing saying so is the non-zero exit status.
87func claim(root, command, flag string) error {
88 if real, err := filepath.EvalSymlinks(root); err == nil {
89 root = real
90 } else if !os.IsNotExist(err) {
91 return fmt.Errorf("resolving %s: %w", root, err)
92 }
93 entries, err := os.ReadDir(root)
94 switch {
95 case os.IsNotExist(err):
96 if err := os.MkdirAll(root, 0o755); err != nil {
97 return fmt.Errorf("creating %s: %w", root, err)
98 }
99 case err != nil:
100 return fmt.Errorf("reading %s: %w", root, err)
101 case len(entries) > 0:
102 stamp := filepath.Join(root, stampName)
103 body, err := os.ReadFile(stamp)
104 if err != nil {
105 return fmt.Errorf("refusing to empty %s: it is not empty and holds no %s stamp, "+
106 "so %s has not written it before. Empty it yourself, or point %s somewhere else", root, stampName, command, flag)
107 }
108 if by := writerOf(body); by != command {
109 if by == "" {
110 return fmt.Errorf("refusing to empty %s: its %s stamp names no command, so it may be something else's. "+
111 "Empty it yourself, or point %s somewhere else", root, stamp, flag)
112 }
113 return fmt.Errorf("refusing to empty %s: its %s stamp says it is written by %s, and %s would empty that command's output. "+
114 "Point %s somewhere else", root, stamp, by, command, flag)
115 }
116 if err := foreignBelow(root, command, flag); err != nil {
117 return err
118 }
119 for _, e := range entries {
120 if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil {
121 return fmt.Errorf("emptying %s: %w", root, err)
122 }
123 }
124 }
125 if err := os.WriteFile(filepath.Join(root, stampName), []byte(stampBody(command)), 0o644); err != nil {
126 return fmt.Errorf("writing %s: %w", filepath.Join(root, stampName), err)
127 }
128 return nil
129}
130
131// foreignBelow walks root and refuses the first thing under it that
132// is not the command's own output: a stamp other than root's, or a
133// symbolic link. It only reads; nothing is removed until it returns
134// nil. An unreadable directory is an error, and so a refusal.
135func foreignBelow(root, command, flag string) error {
136 return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
137 if err != nil {
138 return fmt.Errorf("reading %s: %w", root, err)
139 }
140 if d.Type()&fs.ModeSymlink != 0 {
141 return fmt.Errorf("refusing to empty %s: %s is a symbolic link, which %s never writes, so it is not this command's output. "+
142 "Remove it yourself, or point %s somewhere else", root, p, command, flag)
143 }
144 if !d.IsDir() && d.Name() == stampName && filepath.Dir(p) != root {
145 return fmt.Errorf("refusing to empty %s: %s is a stamp %s did not write, so another command's output is inside. "+
146 "Empty it yourself, or point %s somewhere else", root, p, command, flag)
147 }
148 return nil
149 })
150}
151