aboutsummaryrefslogtreecommitdiff
path: root/internal/journal/journal.go
blob: 465cd51ac1621fdd37b7a709a659ac95f763dda9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
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
}