| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 27 | const stampName = site.StampName |
| 28 | |
| 29 | // stampWriter reads the command a stamp names. |
| 30 | var 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. |
| 34 | func 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. |
| 42 | func 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. |
| 87 | func 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. |
| 135 | func 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 | |