aboutsummaryrefslogtreecommitdiff
path: root/internal/journal/read.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/journal/read.go')
-rw-r--r--internal/journal/read.go344
1 files changed, 344 insertions, 0 deletions
diff --git a/internal/journal/read.go b/internal/journal/read.go
new file mode 100644
index 0000000..3de15d2
--- /dev/null
+++ b/internal/journal/read.go
@@ -0,0 +1,344 @@
+// 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
+}
+
+// Entries returns every entry belonging to runID, in file order. 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
+ }
+ if badLine != 0 {
+ continue
+ }
+ if inWindow {
+ badLine = i + 1
+ continue
+ }
+ if run, found := runFieldOf(line); found && run == runID {
+ 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.
+func runFieldOf(line string) (string, bool) {
+ f := strings.SplitN(line, "\t", 3)
+ if len(f) < 2 {
+ return "", false
+ }
+ return unescape(f[1]), 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]
+ }
+
+ 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()
+}