From 3f8679be9373ee7508d512dfdfc1dda0839c7f90 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 20:14:47 +0200 Subject: krino: acting — trash, journal, apply, lock, review, undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/journal/read.go | 344 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 internal/journal/read.go (limited to 'internal/journal/read.go') 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() +} -- cgit v1.3