aboutsummaryrefslogtreecommitdiff
path: root/internal/journal/read.go
blob: 9099c0d71e3e0ff98a7f09b2137b6a2679e8cad6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// SPDX-License-Identifier: GPL-3.0-or-later

package journal

import (
	"fmt"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"
)

// wantFields is the number of tab-separated columns a well-formed line has.
const wantFields = 13

// undoOfPrefix is the exact, single-source convention linking an undo run
// back to the run it reverses: an undo run's run-start entry carries
// Detail = undoOfPrefix + the original run's ID, verbatim, and nowhere
// else - never repeated on the undo-* step entries, so there is only one
// place to write it and one place that can drift. A log truncated before
// its run-start line loses the link; the accepted cost is that `krino undo`
// may then re-offer an already-reversed run, whose per-file refusal checks
// decline every file because they are already back in place.
const undoOfPrefix = "undo of "

// UndoOf returns the Detail value an undo run's run-start entry carries to
// record which run it reverses (see undoOfPrefix). Fix wave item 2 /
// final-wave item 17: before this, internal/engine wrote the same text as
// a bare string literal with nothing tying it to undoOfPrefix, so a typo in
// either would silently break Runs' Undone marking while every test stayed
// green. This is the one place that string is built; internal/engine calls
// it rather than keeping its own copy.
func UndoOf(run string) string {
	return undoOfPrefix + run
}

// Run summarises one logged run, for `krino log` and for choosing what
// `krino undo` reverses.
type Run struct {
	ID     string
	Start  time.Time
	Dirs   []string
	Counts map[string]int // action -> count of status "ok"
	Undone bool           // a later run reversed this one
	UndoOf string         // for an undo run, the run it reverses; "" otherwise
}

// ReversedKey identifies one reversal an undo run carried out: the file's
// directory and name, the undo action and the path it started from - enough
// to tell which step of the original run it reversed.
type ReversedKey struct {
	Dir, File, Action, Src string
}

// ReversedSteps counts, for runID, every reversal that earlier undo runs of
// it completed ("ok" undo- entries of runs whose run-start says they undo
// runID), so a later undo of the same run can offer only what is left
// (review M10). An undo run's own unparsable lines are skipped; a missing
// reversal is then offered again, where its own checks refuse it if it had
// in fact happened.
func ReversedSteps(path, runID string) (map[ReversedKey]int, error) {
	lines, err := readLines(path)
	if err != nil {
		return nil, err
	}
	undoRuns := map[string]bool{}
	for _, line := range lines {
		if e, ok := parseLine(line); ok && e.Action == "run-start" && e.Detail == UndoOf(runID) {
			undoRuns[e.Run] = true
		}
	}
	out := map[ReversedKey]int{}
	for _, line := range lines {
		e, ok := parseLine(line)
		if !ok || !undoRuns[e.Run] || e.Status != "ok" || !strings.HasPrefix(e.Action, "undo-") {
			continue
		}
		out[ReversedKey{Dir: e.Dir, File: e.File, Action: e.Action, Src: e.Src}]++
	}
	return out, nil
}

// Entries returns every entry belonging to runID, in file order. Since plan
// 10 (re-review N1), an unparsable line whose run column names another run
// is ignored, and one of this run whose directory and file columns are still
// readable is returned as a "damaged" entry for that file, so undo refuses
// that file alone. Otherwise a line that fails to parse is skipped, but
// Entries fails closed within the run's own window - from its run-start line to its run-end line, or to end of
// file when there is no run-end (a crashed run, which is precisely when
// corruption is likely): any unparsable line found inside that window sets
// the returned error, whether or not the line's own Run column can still be
// read back. The mere possibility that it belonged to this run is enough,
// because an incomplete chain must refuse the whole run rather than let an
// undo reverse it partway (spec §10). A line outside the window is ignored
// even when unparsable, since it cannot belong to this run.
//
// The residual risk this leaves is a false refusal, not a false success:
// krino's lock is per directory, not global, so two processes could in
// principle write to the log at once, and an unattributable corrupt line
// that falls inside this run's window might really belong to the other
// run - this run would then be refused unnecessarily. That is the safe
// direction, and it is rare. A nil error is what proves the run's chain
// parsed completely; a non-nil error is proof only that it cannot be
// trusted as complete, not that the run itself is corrupt.
//
// A run also fails closed if it has no readable run-start: every run Apply
// writes begins with one, so once at least one entry for the run has
// parsed, a missing run-start means either corruption or a log truncated
// at the front, and either way the chain cannot be trusted. The same rule
// does not apply to run-end - a crashed run legitimately has none, and the
// window rule above already covers that case correctly. The cost is
// symmetric with the one above: if the log's front were ever trimmed, the
// oldest surviving run would refuse to undo. krino never trims the log -
// it is append-only with no rotation - so this only bites a hand-edited
// file, which is exactly the case where refusing is right.
func Entries(path, runID string) ([]Entry, error) {
	lines, err := readLines(path)
	if err != nil {
		return nil, err
	}
	var out []Entry
	badLine := 0
	inWindow := false
	sawRunStart := false
	for i, line := range lines {
		e, ok := parseLine(line)
		if ok {
			if e.Run != runID {
				continue
			}
			out = append(out, e)
			switch e.Action {
			case "run-start":
				inWindow = true
				sawRunStart = true
			case "run-end":
				inWindow = false
			}
			continue
		}
		run, runFound := runFieldOf(line)
		if runFound && run != runID {
			// Another run's damaged line: runs of different directories can
			// interleave, and it says nothing about this one (re-review N1).
			continue
		}
		ours := inWindow || (runFound && run == runID)
		if !ours {
			continue
		}
		if dir, file, ok := fileFieldsOf(line); ok {
			// A line of this run cut or damaged where its file is still
			// readable: that file's chain may be missing a step, so it is
			// returned as damaged and PlanUndo refuses just that file; the
			// rest of the run stays undoable (re-review N1).
			out = append(out, Entry{Run: runID, Dir: dir, File: file, Action: "damaged", Status: "damaged", Detail: fmt.Sprintf("line %d", i+1)})
			continue
		}
		if badLine == 0 {
			badLine = i + 1
		}
	}
	if badLine != 0 {
		return out, fmt.Errorf("journal: entries: run %s: unparsable line %d", runID, badLine)
	}
	if len(out) > 0 && !sawRunStart {
		return out, fmt.Errorf("journal: entries: run %s: no readable run-start", runID)
	}
	return out, nil
}

// runFieldOf best-effort extracts a line's Run column even when the line
// otherwise fails to parse, so Entries can tell whether an unparsable line
// belonged to the run it was asked for. The column counts only when a tab
// ends it: a line cut inside it holds a prefix of some run's ID, which names
// no run (plan 10 re-check R2).
func runFieldOf(line string) (string, bool) {
	f := strings.SplitN(line, "\t", 3)
	if len(f) < 3 {
		return "", false
	}
	return unescape(f[1]), true
}

// fileFieldsOf best-effort extracts a line's directory and file columns when
// the line otherwise fails to parse; ok is false when the line is cut before
// them or names no file (a run-start or run-end line).
func fileFieldsOf(line string) (dir, file string, ok bool) {
	f := strings.SplitN(line, "\t", 5)
	if len(f) < 5 || f[3] == "" {
		return "", "", false
	}
	return unescape(f[2]), unescape(f[3]), true
}

// Runs summarises every run found in the log, newest first. n <= 0 means
// all. As with Entries, an unparsable line is skipped rather than failing
// the read - here silently and always, even when it belonged to the run
// being summarised: a listing that refuses to print anything because one
// old line is corrupt is worse than one that just omits it.
//
// A run is marked Undone when a later run's run-start entry's Detail is
// undoOfPrefix followed by this run's ID, AND that later run actually
// reversed something (fix wave item 2): a fully declined undo - every file
// the reviewer chose not to reverse - still opens with that same run-start
// (ApplyUndo logs a declined file exactly as spec §9 asks the forward path
// to), so the Detail alone is not proof anything happened. Reproduced by
// the reviewer: `krino undo` with every file declined left `krino log`
// reporting the original run "(undone)" regardless. What actually happened
// is provable from the same file: at least one "ok" undo-* entry.
func Runs(path string, n int) ([]Run, error) {
	lines, err := readLines(path)
	if err != nil {
		return nil, err
	}

	order := make([]string, 0)
	byID := make(map[string]*Run)
	pendingUndo := make(map[string]string) // undo run ID -> the run ID it claims to undo

	for _, line := range lines {
		e, ok := parseLine(line)
		if !ok {
			continue
		}
		r, seen := byID[e.Run]
		if !seen {
			r = &Run{ID: e.Run, Start: e.Time, Counts: make(map[string]int)}
			byID[e.Run] = r
			order = append(order, e.Run)
		}
		if e.Dir != "" && !contains(r.Dirs, e.Dir) {
			r.Dirs = append(r.Dirs, e.Dir)
		}
		if e.Status == "ok" {
			r.Counts[e.Action]++
		}
		if e.Action == "run-start" {
			if orig, ok := strings.CutPrefix(e.Detail, undoOfPrefix); ok && orig != "" {
				pendingUndo[e.Run] = orig
			}
		}
	}

	// Resolved only once the whole file has been scanned: an undo run's
	// run-start line - and therefore its claim on pendingUndo - is always
	// written before its own step entries, so whether it actually reversed
	// anything cannot be known until its Counts are complete.
	undoes := make(map[string]bool) // run IDs actually reversed by some later run
	for undoRun, orig := range pendingUndo {
		if r, ok := byID[undoRun]; ok && ranAnyUndoStep(r.Counts) {
			undoes[orig] = true
		}
	}

	runs := make([]Run, len(order))
	for i, id := range order {
		runs[i] = *byID[id]
	}
	sort.SliceStable(runs, func(i, j int) bool { return runs[i].Start.After(runs[j].Start) })
	for i := range runs {
		runs[i].Undone = undoes[runs[i].ID]
		runs[i].UndoOf = pendingUndo[runs[i].ID]
	}

	if n > 0 && n < len(runs) {
		runs = runs[:n]
	}
	return runs, nil
}

// ranAnyUndoStep reports whether counts - a run's own tally of "ok" actions,
// by action name - includes at least one undo- action that actually
// restored something, as opposed to merely having been started and then
// declining every file (Important 2), or having failed to restore anything
// while a wholly unrelated undo-mkdir still happened to succeed (the
// coordinator's tightening of that same fix): "undo-mkdir" is deliberately
// excluded, the one undo- action package journal cannot help but name
// directly (this package must not import internal/engine to reuse its
// isFileAffecting predicate - journal is the lower layer), but which draws
// exactly the same line that predicate does. Removing a directory once it
// turns out empty is tidiness, not a restoration: a file's own chain stops
// after a failed file-affecting reversal, but a failed or refused
// undo-mkdir never stops anything (see internal/engine's isFileAffecting
// and undoFile), so it can succeed for one file while every file-affecting
// reversal in the whole run failed - and marking the original run Undone
// from that alone would be Important 2's bug again, by a narrower route.
func ranAnyUndoStep(counts map[string]int) bool {
	for action, n := range counts {
		if n > 0 && action != "undo-mkdir" && strings.HasPrefix(action, "undo-") {
			return true
		}
	}
	return false
}

func contains(ss []string, s string) bool {
	for _, x := range ss {
		if x == s {
			return true
		}
	}
	return false
}

// readLines reads path and splits it into lines, dropping the single
// trailing empty element a final newline produces. It does not itself
// validate line structure; parseLine does that per line.
func readLines(path string) ([]string, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("journal: %w", err)
	}
	if len(data) == 0 {
		return nil, nil
	}
	lines := strings.Split(string(data), "\n")
	if lines[len(lines)-1] == "" {
		lines = lines[:len(lines)-1]
	}
	return lines, nil
}

// parseLine parses one log line into an Entry. It reports false for
// anything that does not look like a complete, well-formed line: wrong
// column count, or a time/step/size column that does not parse. That is the
// only contract a crash mid-write needs: the truncated final line always
// fails one of these checks, and everything before it still parses.
func parseLine(line string) (Entry, bool) {
	f := strings.Split(line, "\t")
	if len(f) != wantFields {
		return Entry{}, false
	}
	t, err := time.Parse(time.RFC3339, f[0])
	if err != nil {
		return Entry{}, false
	}
	step, err := strconv.Atoi(f[4])
	if err != nil {
		return Entry{}, false
	}
	size, err := strconv.ParseInt(f[10], 10, 64)
	if err != nil {
		return Entry{}, false
	}
	mtime, err := time.Parse(time.RFC3339, f[11])
	if err != nil {
		return Entry{}, false
	}
	return Entry{
		Time:    t,
		Run:     unescape(f[1]),
		Dir:     unescape(f[2]),
		File:    unescape(f[3]),
		Step:    step,
		Action:  unescape(f[5]),
		Status:  unescape(f[6]),
		Rule:    unescape(f[7]),
		Src:     unescape(f[8]),
		Dst:     unescape(f[9]),
		Size:    size,
		ModTime: mtime,
		Detail:  unescape(f[12]),
	}, true
}

// unescape reverses escape: \t, \n, \\ and \xNN. Any other backslash
// sequence - which a well-formed log never contains - is left as a literal
// backslash rather than silently eaten, so a hand-edited line does not lose
// data.
func unescape(s string) string {
	if !strings.Contains(s, `\`) {
		return s
	}
	var b strings.Builder
	b.Grow(len(s))
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c != '\\' || i+1 >= len(s) {
			b.WriteByte(c)
			continue
		}
		switch s[i+1] {
		case 't':
			b.WriteByte('\t')
			i++
		case 'n':
			b.WriteByte('\n')
			i++
		case '\\':
			b.WriteByte('\\')
			i++
		case 'x':
			if i+3 < len(s) {
				if v, err := strconv.ParseUint(s[i+2:i+4], 16, 8); err == nil {
					b.WriteByte(byte(v))
					i += 3
					continue
				}
			}
			b.WriteByte(c)
		default:
			b.WriteByte(c)
		}
	}
	return b.String()
}