// 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). 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: 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. 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. 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. 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. 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. 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: a fully declined undo - every file declined rather // than reversed - 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. Without this check, `krino // undo` with every file declined would leave `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. 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, or having failed to restore anything // while a wholly unrelated undo-mkdir still happened to succeed: // "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 the same // 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() }