oops-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ require "pty"
2
+ require "io/console"
3
+
4
+ module Oops
5
+ # The pty relay behind `oops record`: runs the shell on a new pty, relays
6
+ # input and output between it and the user's terminal, strips the hook
7
+ # markers from the output and writes finished commands to the session log.
8
+ #
9
+ # Rule one is never to break the user's shell. Failing to set up the log
10
+ # only disables recording; any failure after the shell is running switches
11
+ # logging off and the relay goes on.
12
+ class Recorder
13
+ READ_SIZE = 65_536
14
+ # Input waiting for the shell beyond this stays in the kernel's tty
15
+ # buffer (it is not read) until the shell catches up.
16
+ MAX_PENDING_INPUT = 1_048_576
17
+ # Backstop for a missed SIGCHLD; normally the self-pipe wakes the loop.
18
+ POLL_INTERVAL = 1.0
19
+ # Pause after an unexpected error in the relay loop, so an error that
20
+ # repeats cannot spin the CPU.
21
+ ERROR_BACKOFF = 0.05
22
+
23
+ # The shell could not be started on the new pty.
24
+ class ChildStartError < StandardError; end
25
+
26
+ # Feeds child output through the StreamParser and appends records to the
27
+ # log. Failures never lose output: a failing log is dropped, and if the
28
+ # parser itself fails, bytes are forwarded unparsed from then on.
29
+ #
30
+ # A pause marker counts only while the log's pause flag file exists (and
31
+ # a resume marker only while it does not): `oops pause` creates the file
32
+ # before it prints the marker, while program output that merely contains
33
+ # the marker cannot.
34
+ class OutputFilter
35
+ def initialize(log:, on_error:, max_bytes: 1_048_576)
36
+ @log = log
37
+ @on_error = on_error
38
+ @parser = StreamParser.new(max_bytes: max_bytes, pause_gate: pause_gate(log)) { |record| store(record) }
39
+ @broken = false
40
+ end
41
+
42
+ # Returns the bytes to show on the terminal.
43
+ def feed(bytes)
44
+ return bytes.b if @broken
45
+
46
+ @parser.feed(bytes)
47
+ rescue StandardError => e
48
+ parser_failed(e)
49
+ bytes.b
50
+ end
51
+
52
+ # Returns held-back bytes; an unfinished command is logged with exit nil.
53
+ def finish
54
+ return "".b if @broken
55
+
56
+ @parser.finish
57
+ rescue StandardError => e
58
+ parser_failed(e)
59
+ "".b
60
+ end
61
+
62
+ private
63
+
64
+ def pause_gate(log)
65
+ return nil unless log
66
+
67
+ lambda do |kind|
68
+ paused = begin
69
+ log.paused?
70
+ rescue SystemCallError
71
+ false
72
+ end
73
+ kind == :pause ? paused : !paused
74
+ end
75
+ end
76
+
77
+ def store(record)
78
+ return unless @log
79
+
80
+ @log.append(record)
81
+ rescue StandardError => e
82
+ @log = nil
83
+ @on_error.call(e.message)
84
+ end
85
+
86
+ def parser_failed(error)
87
+ @broken = true
88
+ @log = nil
89
+ @on_error.call("stream parser failed: #{error.class}: #{error.message}")
90
+ end
91
+ end
92
+
93
+ def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
94
+ @argv = argv.empty? ? [default_shell(env)] : argv
95
+ @env = env
96
+ @stdin = stdin
97
+ @stdout = stdout
98
+ @stderr = stderr
99
+ @raw = false
100
+ @log_failed = false
101
+ end
102
+
103
+ def run
104
+ tty = Tty.name(@stdin)
105
+ return exec_shell({}) if tty && @env["OOPS_TTY"] == tty
106
+
107
+ install_traps
108
+ log = open_session
109
+ master, pid = start_child
110
+ unless pid
111
+ discard_session
112
+ return exec_shell({ "OOPS_DISABLE" => "1" })
113
+ end
114
+
115
+ @master = master
116
+ @pid = pid
117
+ @filter = OutputFilter.new(log: log, on_error: method(:log_failed))
118
+ with_raw_mode { relay }
119
+ # Waited for with the terminal already restored: a shell that closed
120
+ # the pty but keeps running must not leave the terminal raw.
121
+ @status ||= Process.wait2(@pid)[1]
122
+ exit_code(@status)
123
+ ensure
124
+ restore_traps
125
+ @master.close if @master && !@master.closed?
126
+ @wake_r&.close
127
+ @wake_w&.close
128
+ end
129
+
130
+ private
131
+
132
+ def default_shell(env)
133
+ shell = env["SHELL"].to_s
134
+ shell.empty? ? "/bin/sh" : shell
135
+ end
136
+
137
+ # Replaces this process with the shell. Returns only when exec fails.
138
+ def exec_shell(extra_env)
139
+ Kernel.exec(extra_env, [@argv[0], @argv[0]], *@argv[1..])
140
+ rescue SystemCallError => e
141
+ @stderr.puts "oops: cannot run #{@argv[0]}: #{e.message}"
142
+ 127
143
+ end
144
+
145
+ # --- session log ---
146
+
147
+ def open_session
148
+ dir = Paths.sessions_dir(env: @env)
149
+ @session_path = SessionLog.new_path(dir)
150
+ Paths.ensure_sessions_dir!(env: @env)
151
+ rotate_logs(dir)
152
+ File.open(@session_path, File::WRONLY | File::CREAT | File::APPEND, 0o600, &:itself)
153
+ write_status("ok")
154
+ SessionLog.new(@session_path)
155
+ rescue StandardError => e
156
+ @log_failed = true
157
+ warn_line "oops: warning: session log unavailable (#{e.message}); recording disabled"
158
+ write_status("log-error: #{e.message}")
159
+ nil
160
+ end
161
+
162
+ # The shell runs unrecorded, so an empty log would only mislead.
163
+ def discard_session
164
+ return unless @session_path
165
+
166
+ [@session_path, "#{@session_path}.status"].each do |f|
167
+ File.delete(f)
168
+ rescue SystemCallError
169
+ nil
170
+ end
171
+ end
172
+
173
+ # Rotation only frees space; failing to rotate must not stop recording.
174
+ def rotate_logs(dir)
175
+ SessionLog.rotate!(dir)
176
+ rescue SystemCallError
177
+ nil
178
+ end
179
+
180
+ def write_status(text)
181
+ return unless @session_path && File.directory?(File.dirname(@session_path))
182
+
183
+ File.open("#{@session_path}.status", File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
184
+ f.write(text)
185
+ end
186
+ rescue SystemCallError, IOError
187
+ nil
188
+ end
189
+
190
+ # Called at most once: the first log failure switches logging off.
191
+ def log_failed(message)
192
+ return if @log_failed
193
+
194
+ @log_failed = true
195
+ warn_line "oops: warning: session log write failed (#{message}); recording disabled"
196
+ write_status("log-error: #{message}")
197
+ end
198
+
199
+ def warn_line(message)
200
+ @stderr.write(message, @raw ? "\r\n" : "\n")
201
+ @stderr.flush
202
+ rescue SystemCallError, IOError
203
+ nil
204
+ end
205
+
206
+ # --- child process ---
207
+
208
+ # Returns [master, pid], or nil after printing why the pty or the shell
209
+ # could not be started.
210
+ def start_child
211
+ master, slave = PTY.open
212
+ copy_termios(slave)
213
+ copy_winsize(master)
214
+ [master, fork_child(slave.path)]
215
+ rescue SystemCallError, IOError, NotImplementedError, ChildStartError => e
216
+ master&.close
217
+ @stderr.puts "oops: recording disabled: #{e.message}"
218
+ nil
219
+ ensure
220
+ # Kept open until the child has opened the slave itself, so the pty is
221
+ # never without a slave in between (that would read as a hangup).
222
+ slave&.close
223
+ end
224
+
225
+ # Forks the shell as a session leader with the pty slave as its
226
+ # controlling terminal. A close-on-exec pipe reports whether exec
227
+ # succeeded: EOF means it did, otherwise it carries the error.
228
+ def fork_child(slave_path)
229
+ report_r, report_w = IO.pipe
230
+ child_env = { "OOPS_SESSION" => @session_path, "OOPS_TTY" => slave_path }
231
+ pid = fork do
232
+ report_r.close
233
+ begin
234
+ Process.setsid
235
+ File.open(slave_path, File::RDWR) do |tty|
236
+ Kernel.exec(child_env, [@argv[0], @argv[0]], *@argv[1..], in: tty, out: tty, err: tty)
237
+ end
238
+ rescue StandardError => e
239
+ report_w.write("cannot run #{@argv[0]}: #{e.message}")
240
+ ensure
241
+ exit!(127)
242
+ end
243
+ end
244
+ report_w.close
245
+ error = report_r.read
246
+ return pid if error.empty?
247
+
248
+ Process.wait(pid)
249
+ raise ChildStartError, error
250
+ ensure
251
+ report_r&.close
252
+ report_w&.close unless report_w.nil? || report_w.closed?
253
+ end
254
+
255
+ # The shell starts with the user's terminal settings (erase key, UTF-8
256
+ # input, ...), as script(1) does.
257
+ def copy_termios(slave)
258
+ return unless @stdin.respond_to?(:console_mode) && @stdin.tty?
259
+
260
+ slave.console_mode = @stdin.console_mode
261
+ rescue SystemCallError, IOError
262
+ nil
263
+ end
264
+
265
+ def copy_winsize(master)
266
+ return unless @stdin.respond_to?(:winsize) && @stdin.tty?
267
+
268
+ rows, cols = @stdin.winsize
269
+ master.winsize = [rows, cols] if rows.positive? && cols.positive?
270
+ rescue SystemCallError, IOError
271
+ nil
272
+ end
273
+
274
+ # --- signals ---
275
+
276
+ def install_traps
277
+ @wake_r, @wake_w = IO.pipe
278
+ wake = @wake_w
279
+ @old_traps = %w[WINCH CHLD].to_h do |sig|
280
+ [sig, trap(sig) { wake.write_nonblock(sig[0], exception: false) }]
281
+ end
282
+ end
283
+
284
+ def restore_traps
285
+ @old_traps&.each { |sig, old| trap(sig, old || "DEFAULT") }
286
+ end
287
+
288
+ # --- terminal ---
289
+
290
+ def with_raw_mode
291
+ return yield unless @stdin.respond_to?(:raw!) && @stdin.tty?
292
+
293
+ saved = @stdin.console_mode
294
+ begin
295
+ @stdin.raw!
296
+ @raw = true
297
+ yield
298
+ ensure
299
+ @raw = false
300
+ begin
301
+ @stdin.console_mode = saved
302
+ rescue SystemCallError, IOError
303
+ nil
304
+ end
305
+ end
306
+ end
307
+
308
+ # --- relay loop ---
309
+
310
+ def relay
311
+ @input = +"".b
312
+ @stdin_open = true
313
+ @output_open = true
314
+ @status = nil
315
+ step while @output_open && @status.nil?
316
+ drain_output if @output_open
317
+ write_terminal(@filter.finish)
318
+ end
319
+
320
+ # Errors the relay expects (EIO, EOF, EPIPE) are handled where they
321
+ # occur; anything else is reported once and the relay goes on, because
322
+ # ending it would hang up the shell.
323
+ def step
324
+ relay_step
325
+ rescue StandardError => e
326
+ unless @relay_error_reported
327
+ @relay_error_reported = true
328
+ warn_line "oops: warning: unexpected relay error (#{e.class}: #{e.message}); continuing"
329
+ end
330
+ sleep ERROR_BACKOFF
331
+ end
332
+
333
+ def relay_step
334
+ readers = [@master, @wake_r]
335
+ readers << @stdin if @stdin_open && @input.bytesize < MAX_PENDING_INPUT
336
+ writers = @input.empty? ? nil : [@master]
337
+ readable, writable = IO.select(readers, writers, nil, POLL_INTERVAL)
338
+ readable ||= []
339
+ handle_wakeup if readable.include?(@wake_r)
340
+ read_input if readable.include?(@stdin)
341
+ write_input if writable&.include?(@master)
342
+ read_output if readable.include?(@master)
343
+ reap_child
344
+ end
345
+
346
+ def handle_wakeup
347
+ signals = @wake_r.read_nonblock(READ_SIZE, exception: false)
348
+ copy_winsize(@master) if signals.is_a?(String) && signals.include?("W")
349
+ end
350
+
351
+ # The user's terminal went away: hang up the shell, as closing a
352
+ # terminal window does.
353
+ def read_input
354
+ @input << @stdin.readpartial(READ_SIZE).b
355
+ rescue EOFError, Errno::EIO
356
+ @stdin_open = false
357
+ hang_up_shell if @stdin.tty?
358
+ end
359
+
360
+ def write_input
361
+ written = @master.write_nonblock(@input, exception: false)
362
+ @input = @input.byteslice(written..) if written.is_a?(Integer)
363
+ rescue Errno::EIO, Errno::EPIPE
364
+ @input.clear
365
+ end
366
+
367
+ def read_output
368
+ data = @master.read_nonblock(READ_SIZE, exception: false)
369
+ return if data == :wait_readable
370
+
371
+ data.nil? ? (@output_open = false) : write_terminal(@filter.feed(data))
372
+ rescue Errno::EIO
373
+ # The child side closed the pty: the shell is gone.
374
+ @output_open = false
375
+ end
376
+
377
+ # After the shell exited, a background job may still hold the pty open;
378
+ # forward what is already there instead of waiting for it.
379
+ def drain_output
380
+ loop do
381
+ data = @master.read_nonblock(READ_SIZE, exception: false)
382
+ break if data.nil? || data == :wait_readable
383
+
384
+ write_terminal(@filter.feed(data))
385
+ end
386
+ rescue Errno::EIO
387
+ nil
388
+ end
389
+
390
+ def reap_child
391
+ _, status = Process.wait2(@pid, Process::WNOHANG)
392
+ @status = status if status
393
+ end
394
+
395
+ def write_terminal(bytes)
396
+ return if bytes.empty? || @terminal_gone
397
+
398
+ @stdout.write(bytes)
399
+ @stdout.flush
400
+ rescue Errno::EIO, Errno::EPIPE
401
+ # The terminal is gone; hang up the shell as closing it would.
402
+ @terminal_gone = true
403
+ hang_up_shell
404
+ end
405
+
406
+ def hang_up_shell
407
+ Process.kill("HUP", @pid)
408
+ rescue SystemCallError
409
+ nil
410
+ end
411
+
412
+ def exit_code(status)
413
+ status.exitstatus || (128 + status.termsig.to_i)
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,85 @@
1
+ module Oops
2
+ # Masks likely secrets in captured text. Each pattern replaces either its
3
+ # whole match or, when it defines a named group `secret`, only that group.
4
+ module Secrets
5
+ TOKEN = "[secret]".freeze
6
+
7
+ # Octet 0-255, used by the IPv4 pattern.
8
+ OCTET = "(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)".freeze
9
+
10
+ # Order matters: specific token formats first, then URL userinfo before
11
+ # email, and the generic key/value rule last so it skips values that an
12
+ # earlier pattern already replaced.
13
+ BUILTIN = [
14
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, # AWS access key id
15
+ /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{20,})/, # GitHub tokens
16
+ /\bxox[abprs]-[A-Za-z0-9-]+/, # Slack tokens
17
+ /\bsk-[A-Za-z0-9_-]{20,}/, # OpenAI / Anthropic keys
18
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/, # Stripe secret keys
19
+ /\bglpat-[A-Za-z0-9_-]{20,}/, # GitLab tokens
20
+ /\bAIza[0-9A-Za-z_-]{35}/, # Google API keys
21
+ /\bnpm_[A-Za-z0-9]{36}/, # npm tokens
22
+ %r{hooks\.slack\.com/services/(?<secret>[A-Za-z0-9/]+)}, # Slack webhook URLs
23
+ /\beyJ[\w-]+\.[\w-]+\.[\w-]+/, # JWT
24
+ %r{Authorization:[ \t]*(?:Bearer|Basic|token)[ \t]+(?<secret>[\w.~+/=-]+)}i,
25
+ # Without an END line (cut off by head, a failed command or the capture
26
+ # limit) everything after BEGIN is masked.
27
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)/m,
28
+ %r{\b[a-z][a-z0-9+.-]*://(?<secret>[^\s/@:]+:[^\s/@]+)@}i, # URL userinfo
29
+ %r{[A-Za-z0-9._%+-]+@(?>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?!:[\w~/])}, # email, not scp-style git@host:path
30
+ # Public IPv4: not private/loopback/unspecified, and not part of a longer
31
+ # dotted number such as a version string (1.2.3.4.5).
32
+ Regexp.new(
33
+ "(?<![\\w.])" \
34
+ "(?!127\\.|0\\.0\\.0\\.0|10\\.|192\\.168\\.|172\\.(?:1[6-9]|2\\d|3[01])\\.)" \
35
+ "(?<secret>#{OCTET}(?:\\.#{OCTET}){3})(?!\\w|\\.\\d)"
36
+ ),
37
+ # Passwords given as command-line arguments. The spans between the
38
+ # command name and the option are bounded so a long line of repeated
39
+ # command names cannot make matching quadratic.
40
+ /(?<![\w-])--(?:password|passwd|pwd)[ \t]+(?<q>["'])?
41
+ (?<secret>(?![-\[])(?(<q>)(?>\\.|(?!\k<q>)[^\\\n])++|[^\s"']+))\k<q>?/ix,
42
+ /\bcurl\b[^\n|;&]{0,200}?\s(?:-u|--user)[ \t]+(?<secret>[^\s:]+:\S+)/,
43
+ /\bmysql(?:dump|admin|pump)?\b[^\n|;&]{0,200}?\s-p(?<secret>[^\s"']+)/,
44
+ /\bdocker\b[^\n|;&]{0,200}?\blogin\b[^\n|;&]{0,200}?\s(?:-p|--password)[ \t]+(?<secret>\S+)/,
45
+ # name containing key/token/secret/password/passwd/pwd, then = or :
46
+ # Quoted values run to the matching closing quote (backslash escapes
47
+ # allowed) using an atomic body, so matching stays linear; an unterminated
48
+ # quote masks to the end of the line. Unquoted values stop at whitespace,
49
+ # quotes, , ; and & (so query strings keep each pair apart). The name
50
+ # parts are bounded: with unbounded repeats on both sides of the keyword,
51
+ # a long word that is full of keywords took quadratic time.
52
+ /(?<![\w.-])[\w.-]{0,64}(?:key|token|secret|password|passwd|pwd)[\w.-]{0,64}["']?[ \t]*[=:][ \t]*
53
+ (?<q>["'])?
54
+ (?<secret>(?!\[secret\])(?(<q>)(?>\\.|(?!\k<q>)[^\\\n])++|[^\s"',;&]+))
55
+ \k<q>?/ix
56
+ ].freeze
57
+
58
+ module_function
59
+
60
+ # Returns [masked_text, number_of_replacements]. `extra` regexps run first.
61
+ # Input is scrubbed (invalid UTF-8 bytes become U+FFFD) so regexps never
62
+ # raise on arbitrary captured output; the returned copy is always valid.
63
+ def mask(text, extra: [])
64
+ out = text.scrub
65
+ count = 0
66
+ (extra + BUILTIN).each do |pattern|
67
+ out = out.gsub(pattern) do
68
+ count += 1
69
+ replacement(Regexp.last_match)
70
+ end
71
+ end
72
+ [out, count]
73
+ end
74
+
75
+ def replacement(match)
76
+ return TOKEN unless match.names.include?("secret") && match[:secret]
77
+
78
+ # Byte offsets: char offsets would cost O(n) per match on long input.
79
+ whole = match[0]
80
+ start = match.byteoffset(0).first
81
+ from, to = match.byteoffset(:secret).map { |offset| offset - start }
82
+ whole.byteslice(0, from) + TOKEN + whole.byteslice(to..)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,213 @@
1
+ module Oops
2
+ # Append-only JSONL log of captured commands for one shell session. A log
3
+ # that grows past its cap is rewritten to keep only its newest records.
4
+ class SessionLog
5
+ CHUNK_SIZE = 64 * 1024
6
+ DEFAULT_MAX_BYTES = 50 * 1024 * 1024
7
+ # When a log passes its cap it is cut to this share of the cap, so a busy
8
+ # tab is not rewritten on every command.
9
+ TRIM_TO = 0.75
10
+ LOG_NAME = /\A(\d{8}-\d{6})-(\d+)\.jsonl\z/
11
+
12
+ attr_reader :path
13
+
14
+ def self.new_path(dir, pid: Process.pid, now: Time.now)
15
+ stamp = now.strftime("%Y%m%d-%H%M%S")
16
+ File.join(dir, "#{stamp}-#{pid}.jsonl")
17
+ end
18
+
19
+ def self.process_alive?(pid)
20
+ Process.kill(0, pid)
21
+ true
22
+ rescue Errno::EPERM
23
+ true
24
+ rescue SystemCallError
25
+ false
26
+ end
27
+
28
+ # Deletes the oldest logs of finished sessions until the total size of
29
+ # all session logs is at most max_bytes. Logs of live sessions are kept.
30
+ def self.rotate!(dir, max_bytes: DEFAULT_MAX_BYTES, alive: method(:process_alive?))
31
+ logs = Dir.children(dir).filter_map do |name|
32
+ m = LOG_NAME.match(name)
33
+ next unless m
34
+
35
+ size = begin
36
+ File.size(File.join(dir, name))
37
+ rescue SystemCallError
38
+ next
39
+ end
40
+ { name: name, pid: m[2].to_i, size: size }
41
+ end
42
+ total = logs.sum { |l| l[:size] }
43
+ logs.sort_by { |l| l[:name] }.each do |log|
44
+ break if total <= max_bytes
45
+ next if alive.call(log[:pid])
46
+
47
+ remove_log(File.join(dir, log[:name]))
48
+ total -= log[:size]
49
+ end
50
+ rescue Errno::ENOENT
51
+ nil
52
+ end
53
+
54
+ def self.remove_log(file)
55
+ [file, "#{file}.paused", "#{file}.status", "#{file}.trim"].each do |f|
56
+ File.delete(f)
57
+ rescue Errno::ENOENT
58
+ nil
59
+ end
60
+ end
61
+ private_class_method :remove_log
62
+
63
+ def initialize(path, max_bytes: DEFAULT_MAX_BYTES)
64
+ @path = path
65
+ @max_bytes = max_bytes
66
+ end
67
+
68
+ # Raises if the record cannot be written. Trimming an oversized log is
69
+ # best effort and never raises.
70
+ def append(record)
71
+ line = record.to_json_line
72
+ size = File.open(@path, File::APPEND | File::CREAT | File::WRONLY, 0o600) do |f|
73
+ f.syswrite(line)
74
+ f.size
75
+ end
76
+ trim if size > @max_bytes
77
+ end
78
+
79
+ # Yields records newest first. Unparsable lines are skipped. The block can
80
+ # return :stop to end the iteration.
81
+ def each_backward
82
+ return enum_for(:each_backward) unless block_given?
83
+
84
+ File.open(@path, "rb") do |io|
85
+ each_line_backward(io) do |line|
86
+ record = parse(line)
87
+ next unless record
88
+ return nil if yield(record) == :stop
89
+ end
90
+ end
91
+ nil
92
+ rescue Errno::ENOENT
93
+ nil
94
+ end
95
+
96
+ # Newest count finished, non-oops records, oldest first.
97
+ def last(count, skip_oops: true)
98
+ found = []
99
+ each_backward do |r|
100
+ next if r.exit.nil? || (skip_oops && r.oops_self?)
101
+
102
+ found << r
103
+ :stop if found.size >= count
104
+ end
105
+ found.reverse
106
+ end
107
+
108
+ def last_failed
109
+ each_backward do |r|
110
+ return r if r.exit.is_a?(Integer) && r.exit != 0 && !r.oops_self?
111
+ end
112
+ nil
113
+ end
114
+
115
+ def paused?
116
+ File.exist?(pause_path)
117
+ end
118
+
119
+ def pause!
120
+ File.open(pause_path, File::CREAT | File::WRONLY, 0o600, &:itself)
121
+ end
122
+
123
+ def resume!
124
+ File.delete(pause_path)
125
+ rescue Errno::ENOENT
126
+ nil
127
+ end
128
+
129
+ private
130
+
131
+ def pause_path
132
+ "#{@path}.paused"
133
+ end
134
+
135
+ # Rewrites the log with the newest complete records that fit in TRIM_TO
136
+ # of the cap (always at least the newest one). The new file is written
137
+ # next to the log and renamed over it, so a reader sees either the old or
138
+ # the new log, never a mix.
139
+ def trim
140
+ keep = newest_lines((@max_bytes * TRIM_TO).to_i)
141
+ tmp = "#{@path}.trim"
142
+ remove_file(tmp)
143
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
144
+ keep.reverse_each { |l| f.write(l, "\n") }
145
+ end
146
+ File.rename(tmp, @path)
147
+ rescue StandardError
148
+ remove_file(tmp) if tmp
149
+ nil
150
+ end
151
+
152
+ # Newest lines first, as many as fit in limit bytes (newlines included),
153
+ # but at least one.
154
+ def newest_lines(limit)
155
+ lines = []
156
+ total = 0
157
+ File.open(@path, "rb") do |io|
158
+ each_line_backward(io) do |line|
159
+ next if line.empty?
160
+ break if !lines.empty? && total + line.bytesize + 1 > limit
161
+
162
+ lines << line
163
+ total += line.bytesize + 1
164
+ end
165
+ end
166
+ lines
167
+ end
168
+
169
+ def remove_file(file)
170
+ File.delete(file)
171
+ rescue SystemCallError
172
+ nil
173
+ end
174
+
175
+ def parse(line)
176
+ return nil if line.empty?
177
+
178
+ Record.from_json_line(line)
179
+ rescue Record::ParseError
180
+ nil
181
+ end
182
+
183
+ def read_chunk(io, offset, length)
184
+ io.seek(offset)
185
+ io.read(length)
186
+ end
187
+
188
+ # Yields complete lines (without newline) from the end of the file. An
189
+ # unterminated tail, if any, is ignored as a partial write.
190
+ def each_line_backward(io, &)
191
+ pos = io.size
192
+ return if pos.zero?
193
+
194
+ discard = read_chunk(io, pos - 1, 1) != "\n"
195
+ carry = "".b
196
+ while pos.positive?
197
+ len = [CHUNK_SIZE, pos].min
198
+ pos -= len
199
+ parts = (read_chunk(io, pos, len) + carry).split("\n", -1)
200
+ if discard
201
+ if parts.size > 1
202
+ parts.pop
203
+ discard = false
204
+ else
205
+ parts.clear
206
+ end
207
+ end
208
+ carry = pos.positive? && !parts.empty? ? parts.shift : "".b
209
+ parts.reverse_each(&)
210
+ end
211
+ end
212
+ end
213
+ end