// 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 // PartlyUndone is set with Undone while fewer of the run's reversible // steps have been reversed, over all its undo runs, than it took // (triage 34l): some were declined, refused or failed. PartlyUndone bool UndoOf string // for an undo run, the run it reverses; "" otherwise } // reversible names the undo action of each logged action undo can reverse; // delete (permanent) has none, and mkdir's removal is tidiness, not a // restoration (ranAnyUndoStep). var reversible = map[string]string{ "move": "undo-move", "rename": "undo-rename", "copy": "undo-copy", "trash": "undo-trash", "displace": "undo-displace", } // 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 // A file whose chain ended in a permanent delete is never undone, so its // reversible steps do not count toward what a run took (plan 11 review // L8). type fileOf struct{ run, dir, file string } reversibleOf := map[fileOf]int{} deletedFile := map[fileOf]bool{} 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 _, ok := reversible[e.Action]; ok { reversibleOf[fileOf{e.Run, e.Dir, e.File}]++ } if e.Action == "delete" { deletedFile[fileOf{e.Run, e.Dir, e.File}] = true } } 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 reversed := make(map[string]int) // run ID -> reversals carried out by all its undo runs for undoRun, orig := range pendingUndo { r, ok := byID[undoRun] if !ok { continue } if ranAnyUndoStep(r.Counts) { undoes[orig] = true } for _, undo := range reversible { reversed[orig] += r.Counts[undo] } } 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 { r := &runs[i] r.Undone = undoes[r.ID] r.UndoOf = pendingUndo[r.ID] if r.Undone { took := 0 for f, n := range reversibleOf { if f.run == r.ID && !deletedFile[f] { took += n } } r.PartlyUndone = reversed[r.ID] < took } } 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() }