vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | // Frontmatter reads and writes the fenced block that every metadata file |
| 2 | // on refs/heads/vole begins with: `key: value` lines between two `---` fences, |
| 3 | // then the markdown body. |
| 4 | // |
| 5 | // --- |
| 6 | // title: "crash on startup" |
| 7 | // state: open |
| 8 | // labels: |
| 9 | // - bug |
| 10 | // --- |
| 11 | // It broke. |
| 12 | // |
| 13 | // The writer decides the shape of the file so the reader can stay boring. Free |
| 14 | // text is always double-quoted, which means no YAML indicator, reserved word or |
| 15 | // `#` can change what it means. Constrained values (a state, a timestamp, an |
| 16 | // account name) are written bare and quoted only when the parser would not read |
| 17 | // them back unchanged. A list is one item per line, so adding an entry is an |
| 18 | // added line and never a rewritten one — which is the whole reason this format |
| 19 | // replaced JSON. |
| 20 | // |
| 21 | // The one thing quoting cannot fix is a hand-edit that drops the quotes on a |
| 22 | // title containing ` # `: YAML would read a comment and silently store a |
| 23 | // shorter title. RoundTrip closes that hole by comparing what the parser saw |
| 24 | // with what the line said. |
| 25 | package metadata |
| 26 | |
| 27 | import ( |
| 28 | "errors" |
| 29 | "fmt" |
| 30 | "io" |
| 31 | "strings" |
| 32 | |
| 33 | "gopkg.in/yaml.v3" |
| 34 | ) |
| 35 | |
| 36 | const fence = "---\n" |
| 37 | |
| 38 | // Split separates a file into its head (the text between the fences, without |
| 39 | // them) and its body. Only the first closing fence ends the head, so a body may |
| 40 | // contain `---` rules of its own. |
| 41 | func Split(raw []byte) (head, body string, err error) { |
| 42 | s := string(raw) |
| 43 | if !strings.HasPrefix(s, fence) { |
| 44 | return "", "", errors.New("a metadata file must open with --- and its frontmatter") |
| 45 | } |
| 46 | rest := s[len(fence):] |
| 47 | if i := strings.Index(rest, "\n"+fence); i >= 0 { |
| 48 | return rest[:i], rest[i+1+len(fence):], nil |
| 49 | } |
| 50 | // A hand-written file may end at the closing fence without a trailing newline. |
| 51 | if strings.HasSuffix(rest, "\n---") { |
| 52 | return rest[:len(rest)-len("\n---")], "", nil |
| 53 | } |
| 54 | return "", "", errors.New("unterminated frontmatter: the closing --- is missing") |
| 55 | } |
| 56 | |
| 57 | // Decode reads the head into v, refusing fields v does not declare. An empty head |
| 58 | // decodes to v unchanged. |
| 59 | func Decode(head string, v any) error { |
| 60 | dec := yaml.NewDecoder(strings.NewReader(head)) |
| 61 | dec.KnownFields(true) |
| 62 | if err := dec.Decode(v); err != nil { |
| 63 | if errors.Is(err, io.EOF) { |
| 64 | return nil |
| 65 | } |
| 66 | return err |
| 67 | } |
| 68 | return nil |
| 69 | } |
| 70 | |
| 71 | // Value reads one top-level scalar out of a head without decoding the whole |
| 72 | // document, and without refusing fields it does not know. Decode is strict on |
| 73 | // purpose — a metadata tree should not carry stray keys — but a reader that only |
| 74 | // wants "who wrote this" must not fail on a file whose other fields belong to a |
| 75 | // different entity type. |
| 76 | func Value(head, key string) (string, bool) { |
| 77 | var doc map[string]any |
| 78 | if err := yaml.Unmarshal([]byte(head), &doc); err != nil { |
| 79 | return "", false |
| 80 | } |
| 81 | v, ok := doc[key] |
| 82 | if !ok { |
| 83 | return "", false |
| 84 | } |
| 85 | s, ok := v.(string) |
| 86 | if !ok { |
| 87 | return "", false |
| 88 | } |
| 89 | return s, true |
| 90 | } |
| 91 | |
| 92 | // RoundTrip rejects an unquoted field that the parser read as something other |
| 93 | // than what the line says. A quoted value is taken at face value: the writer |
| 94 | // chose the quotes, and YAML reads them back exactly. |
| 95 | // |
| 96 | // Without this, `title: fix #12 crash` is stored as "fix" and nobody finds out |
| 97 | // until the bug is quoted in a search that misses it. |
| 98 | func RoundTrip(head, key, got string) error { |
| 99 | raw, ok := bareValue(head, key) |
| 100 | if !ok { |
| 101 | return nil |
| 102 | } |
| 103 | if raw == got { |
| 104 | return nil |
| 105 | } |
| 106 | return fmt.Errorf("%s must be double-quoted: the line reads %q but the value parsed as %q", key, raw, got) |
| 107 | } |
| 108 | |
| 109 | // Text is the reader-side check for a free-text field: it must have survived the |
| 110 | // parse unchanged, and it must fit on one line. Both halves matter — a truncated |
| 111 | // title and a two-line title are the same bug seen from different ends. |
| 112 | func Text(head, key, value string) error { |
| 113 | if err := RoundTrip(head, key, value); err != nil { |
| 114 | return err |
| 115 | } |
| 116 | return ValidText(value) |
| 117 | } |
| 118 | |
| 119 | // bareValue returns the text of a top-level unquoted `key: value` line, and |
| 120 | // reports whether the line was bare (rather than quoted or absent). |
| 121 | func bareValue(head, key string) (string, bool) { |
| 122 | for _, line := range strings.Split(head, "\n") { |
| 123 | if !strings.HasPrefix(line, key+":") { |
| 124 | continue |
| 125 | } |
| 126 | value := strings.TrimSpace(strings.TrimPrefix(line, key+":")) |
| 127 | if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, "'") { |
| 128 | return "", false |
| 129 | } |
| 130 | return value, true |
| 131 | } |
| 132 | return "", false |
| 133 | } |
| 134 | |
| 135 | // Doc accumulates fields in the order they are written and renders the file. It |
| 136 | // records the first problem instead of returning an error from every setter, so |
| 137 | // a constructor can read as a list of fields. |
| 138 | type Doc struct { |
| 139 | lines []string |
| 140 | err error |
| 141 | } |
| 142 | |
| 143 | // New starts an empty document. |
| 144 | func NewDoc() *Doc { return &Doc{} } |
| 145 | |
| 146 | // Text writes a free-text field, always double-quoted. |
| 147 | func (d *Doc) Text(key, value string) { |
| 148 | q, err := quote(value) |
| 149 | if err != nil { |
| 150 | d.fail(err) |
| 151 | return |
| 152 | } |
| 153 | d.lines = append(d.lines, key+": "+q) |
| 154 | } |
| 155 | |
| 156 | // Word writes a constrained value bare, quoting it only if the parser would not |
| 157 | // read it back unchanged. |
| 158 | func (d *Doc) Word(key, value string) { |
| 159 | if plain(value) { |
| 160 | d.lines = append(d.lines, key+": "+value) |
| 161 | return |
| 162 | } |
| 163 | q, err := quote(value) |
| 164 | if err != nil { |
| 165 | d.fail(err) |
| 166 | return |
| 167 | } |
| 168 | d.lines = append(d.lines, key+": "+q) |
| 169 | } |
| 170 | |
| 171 | // List writes a sequence with one item per line. Labels are quoted only when a |
| 172 | // bare item would not survive the parse (`null` is the one a forge actually |
| 173 | // hits). |
| 174 | func (d *Doc) List(key string, values []string) { |
| 175 | if len(values) == 0 { |
| 176 | return |
| 177 | } |
| 178 | d.lines = append(d.lines, key+":") |
| 179 | for _, v := range values { |
| 180 | if plain(v) { |
| 181 | d.lines = append(d.lines, " - "+v) |
| 182 | continue |
| 183 | } |
| 184 | q, err := quote(v) |
| 185 | if err != nil { |
| 186 | d.fail(err) |
| 187 | return |
| 188 | } |
| 189 | d.lines = append(d.lines, " - "+q) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Bytes renders the document and the body that follows it. An empty body leaves |
| 194 | // the file ending at the closing fence, which is what a record with no prose |
| 195 | // (an approval) looks like. |
| 196 | func (d *Doc) Bytes(body string) ([]byte, error) { |
| 197 | if d.err != nil { |
| 198 | return nil, d.err |
| 199 | } |
| 200 | var b strings.Builder |
| 201 | b.WriteString(fence) |
| 202 | for _, line := range d.lines { |
| 203 | b.WriteString(line) |
| 204 | b.WriteByte('\n') |
| 205 | } |
| 206 | b.WriteString(fence) |
| 207 | if body == "" { |
| 208 | return []byte(b.String()), nil |
| 209 | } |
| 210 | b.WriteString(body) |
| 211 | if !strings.HasSuffix(body, "\n") { |
| 212 | b.WriteByte('\n') |
| 213 | } |
| 214 | return []byte(b.String()), nil |
| 215 | } |
| 216 | |
| 217 | func (d *Doc) fail(err error) { |
| 218 | if d.err == nil { |
| 219 | d.err = err |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // ValidText reports whether s can be written as a quoted scalar: one line, no |
| 224 | // control characters. The writer refuses exactly these, so a value the validator |
| 225 | // accepts is a value the writer can reproduce — a tree the reader takes but the |
| 226 | // writer cannot render is a tree nobody can close an issue in. |
| 227 | func ValidText(s string) error { |
| 228 | for _, r := range s { |
| 229 | if r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029' { |
| 230 | return fmt.Errorf("control character %q is not allowed here; keep the value on one line", r) |
| 231 | } |
| 232 | } |
| 233 | return nil |
| 234 | } |
| 235 | |
| 236 | // quote renders a double-quoted scalar. |
| 237 | func quote(s string) (string, error) { |
| 238 | if err := ValidText(s); err != nil { |
| 239 | return "", err |
| 240 | } |
| 241 | var b strings.Builder |
| 242 | b.WriteByte('"') |
| 243 | for _, r := range s { |
| 244 | switch r { |
| 245 | case '"': |
| 246 | b.WriteString(`\"`) |
| 247 | case '\\': |
| 248 | b.WriteString(`\\`) |
| 249 | default: |
| 250 | b.WriteRune(r) |
| 251 | } |
| 252 | } |
| 253 | b.WriteByte('"') |
| 254 | return b.String(), nil |
| 255 | } |
| 256 | |
| 257 | // plain reports whether s can be written bare and read back unchanged. It asks |
| 258 | // the parser rather than carrying a list of YAML's reserved words, so the answer |
| 259 | // cannot drift from the reader. |
| 260 | func plain(s string) bool { |
| 261 | if s == "" || strings.TrimSpace(s) != s { |
| 262 | return false |
| 263 | } |
| 264 | var probe struct { |
| 265 | K string `yaml:"k"` |
| 266 | } |
| 267 | if err := yaml.Unmarshal([]byte("k: "+s+"\n"), &probe); err != nil { |
| 268 | return false |
| 269 | } |
| 270 | return probe.K == s |
| 271 | } |
| 272 |