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.

Blame · internal/audit/read.go

Line attribution on main · 172 line(s)

43e681d8
Keenan Nemetz
4d ago
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 }