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 | package audit |
| 2 | |
| 3 | // Reading the log back. The file is append-only and has no index, so the browser |
| 4 | // pages through it from the newest record backwards: a reader that only ever |
| 5 | // needs the most recent pages should not load an instance's whole history into |
| 6 | // memory. Format and Parse are inverses; a line this package did not write is |
| 7 | // reported rather than guessed at. |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "io" |
| 14 | "os" |
| 15 | "strings" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | // Entry is one parsed line: when it was recorded and what it recorded. |
| 20 | type Entry struct { |
| 21 | Time time.Time |
| 22 | Record |
| 23 | } |
| 24 | |
| 25 | // readChunk is how much of the file is read at a time while walking backwards. |
| 26 | // An audit line is at most five escaped fields plus a timestamp, so one chunk |
| 27 | // normally holds many lines. |
| 28 | const readChunk = 64 << 10 |
| 29 | |
| 30 | // maxPendingLine bounds the bytes held for a line that is split across chunks. |
| 31 | // The writer caps each field at FieldLimit, so a longer line is a corrupt file |
| 32 | // rather than a record worth displaying. |
| 33 | const maxPendingLine = 1 << 20 |
| 34 | |
| 35 | // Parse reads one line written by Format. A line with the wrong number of |
| 36 | // columns or an unreadable timestamp is an error: the caller decides whether to |
| 37 | // skip it, and the log itself is never rewritten to hide it. |
| 38 | func Parse(line string) (Entry, error) { |
| 39 | line = strings.TrimSuffix(line, "\r") |
| 40 | parts := strings.SplitN(line, "\t", 5) |
| 41 | if len(parts) != 5 { |
| 42 | return Entry{}, fmt.Errorf("audit: line has %d columns, want 5", len(parts)) |
| 43 | } |
| 44 | at, err := time.Parse(time.RFC3339, parts[0]) |
| 45 | if err != nil { |
| 46 | return Entry{}, fmt.Errorf("audit: bad timestamp %q: %w", parts[0], err) |
| 47 | } |
| 48 | return Entry{ |
| 49 | Time: at.UTC(), |
| 50 | Record: Record{ |
| 51 | Action: parts[1], |
| 52 | Actor: parts[2], |
| 53 | Subject: parts[3], |
| 54 | Detail: parts[4], |
| 55 | }, |
| 56 | }, nil |
| 57 | } |
| 58 | |
| 59 | // Tail returns up to limit entries, newest first, after skipping the newest |
| 60 | // offset entries. more reports whether an older entry exists beyond the page. |
| 61 | // |
| 62 | // A missing file is an empty log, not an error: an instance that has recorded |
| 63 | // nothing yet has nothing to show. A line that Parse rejects is skipped rather |
| 64 | // than failing the page, so one damaged record does not hide the rest of the |
| 65 | // history around it. |
| 66 | func Tail(path string, offset, limit int) ([]Entry, bool, error) { |
| 67 | if limit < 1 { |
| 68 | return nil, false, errors.New("audit: page size must be positive") |
| 69 | } |
| 70 | if offset < 0 { |
| 71 | offset = 0 |
| 72 | } |
| 73 | if path == "" { |
| 74 | return nil, false, ErrNoSink |
| 75 | } |
| 76 | |
| 77 | f, size, err := openLog(path) |
| 78 | if err != nil || f == nil { |
| 79 | return nil, false, err |
| 80 | } |
| 81 | defer f.Close() |
| 82 | |
| 83 | // One extra line is enough to know whether an older page exists, without |
| 84 | // counting the file or reading to its beginning. |
| 85 | want := offset + limit + 1 |
| 86 | entries := make([]Entry, 0, want) |
| 87 | var pending []byte |
| 88 | buf := make([]byte, readChunk) |
| 89 | for pos := size; pos > 0 && len(entries) < want; { |
| 90 | n := int64(len(buf)) |
| 91 | if pos < n { |
| 92 | n = pos |
| 93 | } |
| 94 | pos -= n |
| 95 | if _, err := f.ReadAt(buf[:n], pos); err != nil && err != io.EOF { |
| 96 | return nil, false, fmt.Errorf("audit: read %s: %w", path, err) |
| 97 | } |
| 98 | |
| 99 | // The chunk is read from its end forwards. `pending` holds a line whose |
| 100 | // beginning is in an earlier chunk, so it is appended after this chunk's |
| 101 | // bytes; parts[0] is then the tail of a line that starts before `pos`. |
| 102 | combined := make([]byte, 0, int(n)+len(pending)) |
| 103 | combined = append(combined, buf[:n]...) |
| 104 | combined = append(combined, pending...) |
| 105 | parts := bytes.Split(combined, []byte{'\n'}) |
| 106 | if pos == 0 { |
| 107 | pending = nil |
| 108 | } else { |
| 109 | pending = parts[0] |
| 110 | if len(pending) > maxPendingLine { |
| 111 | return nil, false, fmt.Errorf("audit: %s has a line longer than %d bytes", path, maxPendingLine) |
| 112 | } |
| 113 | parts = parts[1:] |
| 114 | } |
| 115 | for i := len(parts) - 1; i >= 0 && len(entries) < want; i-- { |
| 116 | if len(parts[i]) == 0 { |
| 117 | continue |
| 118 | } |
| 119 | e, err := Parse(string(parts[i])) |
| 120 | if err != nil { |
| 121 | continue |
| 122 | } |
| 123 | entries = append(entries, e) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | if len(entries) > offset { |
| 128 | entries = entries[offset:] |
| 129 | } else { |
| 130 | entries = nil |
| 131 | } |
| 132 | more := false |
| 133 | if len(entries) > limit { |
| 134 | entries = entries[:limit] |
| 135 | more = true |
| 136 | } |
| 137 | return entries, more, nil |
| 138 | } |
| 139 | |
| 140 | // openLog opens the audit file for reading without following a symlink, and |
| 141 | // verifies that what was opened is the regular file that was inspected. The log |
| 142 | // is a secret: startup already refuses a symlink or another owner's file, and a |
| 143 | // read surface must not quietly accept what startup would refuse. |
| 144 | func openLog(path string) (*os.File, int64, error) { |
| 145 | fi, err := os.Lstat(path) |
| 146 | if os.IsNotExist(err) { |
| 147 | return nil, 0, nil |
| 148 | } |
| 149 | if err != nil { |
| 150 | return nil, 0, fmt.Errorf("audit: %w", err) |
| 151 | } |
| 152 | if fi.Mode()&os.ModeSymlink != 0 { |
| 153 | return nil, 0, fmt.Errorf("audit: %s is a symlink, expected a file", path) |
| 154 | } |
| 155 | if !fi.Mode().IsRegular() { |
| 156 | return nil, 0, fmt.Errorf("audit: %s is not a regular file", path) |
| 157 | } |
| 158 | f, err := os.Open(path) |
| 159 | if err != nil { |
| 160 | return nil, 0, fmt.Errorf("audit: open %s: %w", path, err) |
| 161 | } |
| 162 | st, err := f.Stat() |
| 163 | if err != nil { |
| 164 | f.Close() |
| 165 | return nil, 0, fmt.Errorf("audit: %w", err) |
| 166 | } |
| 167 | if !st.Mode().IsRegular() || !os.SameFile(fi, st) { |
| 168 | f.Close() |
| 169 | return nil, 0, fmt.Errorf("audit: %s changed while it was opened", path) |
| 170 | } |
| 171 | return f, st.Size(), nil |
| 172 | } |
| 173 |