diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
| commit | 3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch) | |
| tree | ec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/journal/journal.go | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip | |
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/journal/journal.go')
| -rw-r--r-- | internal/journal/journal.go | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/internal/journal/journal.go b/internal/journal/journal.go new file mode 100644 index 0000000..465cd51 --- /dev/null +++ b/internal/journal/journal.go @@ -0,0 +1,162 @@ +// 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 "<t as 20060102T150405>-<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_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("journal: %w", err) + } + return &Writer{f: f}, nil +} + +// 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 +} |
