Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 5.1 KiB
1package 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
9import (
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.
20type 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.
28const 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.
33const 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.
38func 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.
66func 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.
144func 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