// SPDX-License-Identifier: GPL-3.0-or-later // Package journal is the append-only log every krino action is recorded in, // and the only record krino undo reads back. See docs/design.md §9. package journal import ( "crypto/rand" "encoding/hex" "fmt" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" ) // Entry is one logged event. Field order here IS the column order in the // file; never reorder it. type Entry struct { Time time.Time // RFC 3339 with offset Run string // e.g. "20260911T100203-4f2a" Dir string // the directory's name from krino.conf File string // the file's Rel within that directory Step int // 1-based index within the file's chain; 0 for run-start/run-end Action string // run-start mkdir copy move rename trash delete displace run-end, and undo- forms Status string // ok failed skipped declined Rule string Src string Dst string Size int64 // of the file at Dst after the step ModTime time.Time // of the file at Dst after the step Detail string } // NewRunID returns "-<4 hex>": a run identifier that // sorts lexically by start time and does not collide across runs started in // the same second. func NewRunID(t time.Time) string { var b [2]byte _, _ = rand.Read(b[:]) // crypto/rand.Read never fails on supported platforms return t.Format("20060102T150405") + "-" + hex.EncodeToString(b[:]) } // Writer appends entries to a log file, one line per Append call. type Writer struct { f *os.File } // Open opens the log at path for appending, creating its parent directories // and the file itself if necessary. It never truncates an existing log. func Open(path string) (*Writer, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("journal: %w", err) } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return nil, fmt.Errorf("journal: %w", err) } if err := endWithNewline(f); err != nil { f.Close() return nil, fmt.Errorf("journal: %w", err) } return &Writer{f: f}, nil } // endWithNewline restores a missing final newline. A crash can cut the log's // last line short (spec §15.1: krino survives a truncated line); appending // straight after the fragment would glue the next run's first line onto it, // and that run could then never be undone. func endWithNewline(f *os.File) error { fi, err := f.Stat() if err != nil || fi.Size() == 0 { return err } last := make([]byte, 1) if _, err := f.ReadAt(last, fi.Size()-1); err != nil { return err } if last[0] == '\n' { return nil } _, err = f.Write([]byte{'\n'}) return err } // Append writes e as one line and flushes it before returning. The whole // line is written with a single Write call so that two concurrent runs // appending to the same file cannot interleave a partial line. // // Time and ModTime are formatted with RFC3339Nano, not RFC3339: spec §9 // asks for "RFC 3339 with offset", which RFC3339Nano still is (it only adds // an optional fractional-second field; a zero-nanosecond time formats // identically under both). Task 5's undo needs the fractional seconds: a // refusal check comparing a file's current mtime against the mtime this // line records must not be fooled by a file rewritten within the same // whole second. read.go's parser already accepts fractional seconds under // either constant (a documented time.Parse special case for RFC3339), so // only this side needed to change. func (w *Writer) Append(e Entry) error { line := strings.Join([]string{ e.Time.Format(time.RFC3339Nano), escape(e.Run), escape(e.Dir), escape(e.File), strconv.Itoa(e.Step), escape(e.Action), escape(e.Status), escape(e.Rule), escape(e.Src), escape(e.Dst), strconv.FormatInt(e.Size, 10), e.ModTime.Format(time.RFC3339Nano), escape(e.Detail), }, "\t") + "\n" if _, err := w.f.Write([]byte(line)); err != nil { return fmt.Errorf("journal: %w", err) } return nil } // Close closes the underlying file. func (w *Writer) Close() error { if err := w.f.Close(); err != nil { return fmt.Errorf("journal: %w", err) } return nil } // escape encodes s so it can never contain a tab or a newline, and so every // byte round-trips exactly: \t, \n, \\ are backslash-escaped, and every // other control byte or byte that is not part of valid UTF-8 becomes \xNN. // A byte loop is used rather than strconv.Quote, which would also escape // non-ASCII text and make names like "zażółć" unreadable in the log. func escape(s string) string { if !needsEscape(s) { return s } var b strings.Builder b.Grow(len(s) + 8) i := 0 for i < len(s) { c := s[i] switch c { case '\t': b.WriteString(`\t`) i++ continue case '\n': b.WriteString(`\n`) i++ continue case '\\': b.WriteString(`\\`) i++ continue } if c < 0x20 || c == 0x7f { fmt.Fprintf(&b, `\x%02x`, c) i++ continue } r, size := utf8.DecodeRuneInString(s[i:]) if r == utf8.RuneError && size <= 1 { fmt.Fprintf(&b, `\x%02x`, c) i++ continue } b.WriteString(s[i : i+size]) i += size } return b.String() } // needsEscape reports whether s contains anything escape would change, so // the common case (a plain name) avoids allocating a builder. func needsEscape(s string) bool { for i := 0; i < len(s); i++ { c := s[i] if c == '\t' || c == '\n' || c == '\\' || c < 0x20 || c == 0x7f || c >= 0x80 { return true } } return false }