// 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() }