rakpak 1.0.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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rakpak
4
+ # What this machine can actually do. Nothing here is assumed; every codec
5
+ # is probed against PATH so the UI can grey out what is missing instead of
6
+ # failing halfway through a job.
7
+ module Tools
8
+ module_function
9
+
10
+ def which(bin)
11
+ return @which[bin] if defined?(@which) && @which&.key?(bin)
12
+
13
+ @which ||= {}
14
+ @which[bin] = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).lazy
15
+ .map { |d| File.join(d, bin) }
16
+ .find { |p| File.file?(p) && File.executable?(p) }
17
+ end
18
+
19
+ def available?(bin) = !which(bin).nil?
20
+
21
+ # Info-ZIP reports its compiled-in methods in `zip -v`.
22
+ def zip_has_bzip2?
23
+ return @zip_bz2 if defined?(@zip_bz2)
24
+
25
+ @zip_bz2 = available?("zip") && `zip -v 2>/dev/null`.include?("BZIP2_SUPPORT")
26
+ rescue StandardError
27
+ @zip_bz2 = false
28
+ end
29
+
30
+ # GNU tar, bsdtar (macOS) and busybox tar disagree about flags. Detect
31
+ # once so the UI only offers switches this tar actually understands.
32
+ def tar_flavor
33
+ return @tar_flavor if defined?(@tar_flavor)
34
+
35
+ @tar_flavor =
36
+ if !available?("tar") then :missing
37
+ else
38
+ v = begin
39
+ `tar --version 2>/dev/null`
40
+ rescue StandardError
41
+ ""
42
+ end
43
+ case v
44
+ when /GNU tar/ then :gnu
45
+ when /bsdtar|libarchive/ then :bsd
46
+ when /busybox/i then :busybox
47
+ else v.empty? ? :busybox : :unknown
48
+ end
49
+ end
50
+ end
51
+
52
+ # Long form works on GNU tar and bsdtar; -I is GNU-only.
53
+ def tar_pipes? = %i[gnu bsd unknown].include?(tar_flavor)
54
+
55
+ end
56
+
57
+ # A tar compression backend.
58
+ Codec = Struct.new(:id, :label, :ext, :bin, :levels, :default, :threads, :blurb,
59
+ :level_opt, keyword_init: true) do
60
+ def available? = bin.nil? || Tools.available?(bin)
61
+
62
+ def why_not = available? ? nil : "#{bin} not installed"
63
+ def container? = false
64
+
65
+ # ".tar.gz" for a tarball, ".gz" when compressing a lone file.
66
+ def single_ext = ext.delete_prefix(".tar")
67
+
68
+ # The compressor reading a named file and writing to stdout.
69
+ def argv(level)
70
+ return nil if bin.nil?
71
+
72
+ parts = [bin, "-c"]
73
+ # brotli's short -N form only accepts 0-9; 10 and 11 need -q N.
74
+ parts.concat(level_opt ? [level_opt, level.to_s] : ["-#{level}"]) if levels && level
75
+ parts.concat(threads) if threads
76
+ parts
77
+ end
78
+
79
+ # tar's --use-compress-program string; tar splits it on spaces itself.
80
+ # The long form is understood by both GNU tar and bsdtar.
81
+ def filter(level) = argv(level)&.join(" ")
82
+ end
83
+
84
+ TAR_CODECS = [
85
+ Codec.new(id: :none, label: "none", ext: ".tar", bin: nil,
86
+ levels: nil, default: nil, blurb: "no compression, fastest"),
87
+ Codec.new(id: :gzip, label: "gzip", ext: ".tar.gz", bin: "gzip",
88
+ levels: 1..9, default: 6, blurb: "universal, safe default"),
89
+ Codec.new(id: :zstd, label: "zstd", ext: ".tar.zst", bin: "zstd",
90
+ levels: 1..19, default: 3, threads: ["-T0"],
91
+ blurb: "best speed/ratio balance"),
92
+ Codec.new(id: :xz, label: "xz", ext: ".tar.xz", bin: "xz",
93
+ levels: 0..9, default: 6, threads: ["-T0"],
94
+ blurb: "smallest output, slow"),
95
+ Codec.new(id: :bzip2, label: "bzip2", ext: ".tar.bz2", bin: "bzip2",
96
+ levels: 1..9, default: 9, blurb: "legacy, slow"),
97
+ Codec.new(id: :lz4, label: "lz4", ext: ".tar.lz4", bin: "lz4",
98
+ levels: 1..12, default: 1, blurb: "extremely fast, low ratio"),
99
+ Codec.new(id: :brotli, label: "brotli", ext: ".tar.br", bin: "brotli",
100
+ levels: 0..11, default: 11, level_opt: "-q", blurb: "great on text")
101
+ ].freeze
102
+
103
+ def self.tar_codec(id) = TAR_CODECS.find { |c| c.id == id }
104
+
105
+ # A zip container written by Info-ZIP, with one of its entry methods.
106
+ ZipMethod = Struct.new(:id, :label, :flag, :levels, :default, :blurb, keyword_init: true) do
107
+ def available?
108
+ flag == "bzip2" ? Tools.zip_has_bzip2? : Tools.available?("zip")
109
+ end
110
+
111
+ def why_not
112
+ return nil if available?
113
+ return "zip not installed" unless Tools.available?("zip")
114
+
115
+ "this zip lacks BZIP2_SUPPORT"
116
+ end
117
+
118
+ def container? = true
119
+ end
120
+
121
+ ZIP_METHODS = [
122
+ ZipMethod.new(id: :zip, label: "zip", flag: "deflate", levels: 0..9, default: 6,
123
+ blurb: ".zip with deflate; reads everywhere"),
124
+ ZipMethod.new(id: :zip_bzip2, label: "zip (bzip2)", flag: "bzip2", levels: 1..9, default: 9,
125
+ blurb: "smaller .zip, needs a modern unzip"),
126
+ ZipMethod.new(id: :zip_store, label: "zip (store)", flag: "store", levels: nil, default: nil,
127
+ blurb: ".zip with no compression")
128
+ ].freeze
129
+
130
+ # Everything the zip target can produce: a .zip of anything, or a lone
131
+ # file run through one compressor (notes.txt.gz).
132
+ COMPRESSORS = (ZIP_METHODS + TAR_CODECS.reject { |c| c.id == :none }).freeze
133
+
134
+ def self.compressor(id) = COMPRESSORS.find { |c| c.id == id }
135
+
136
+ # Toggleable switches, rendered as a checklist.
137
+ Flag = Struct.new(:id, :label, :args, :on, :blurb, :needs, keyword_init: true) do
138
+ # `needs` lists the tar flavours that understand this switch.
139
+ def supported?(flavor) = needs.nil? || needs.include?(flavor)
140
+ end
141
+
142
+ GNU = %i[gnu unknown].freeze
143
+ BOTH = %i[gnu bsd unknown].freeze
144
+ ALL = %i[gnu bsd busybox unknown].freeze
145
+
146
+ # Only flags this machine's tar understands are offered.
147
+ def self.tar_flags(flavor = Tools.tar_flavor)
148
+ [
149
+ Flag.new(id: :verbose, label: "verbose", args: ["-v"], on: true, needs: ALL,
150
+ blurb: "list each file (drives the progress readout)"),
151
+ Flag.new(id: :preserve, label: "preserve permissions", args: ["-p"], on: true, needs: BOTH,
152
+ blurb: "keep modes exactly as on disk"),
153
+ Flag.new(id: :xattrs, label: "extended attributes", args: ["--xattrs", "--acls"], on: false, needs: BOTH,
154
+ blurb: "store xattrs and POSIX ACLs"),
155
+ Flag.new(id: :deref, label: "follow symlinks", args: ["-h"], on: false, needs: BOTH,
156
+ blurb: "archive link targets instead of the links"),
157
+ Flag.new(id: :onefs, label: "one file system", args: ["--one-file-system"], on: false, needs: BOTH,
158
+ blurb: "do not cross mount points"),
159
+ Flag.new(id: :numeric, label: "numeric owner", args: ["--numeric-owner"], on: false, needs: BOTH,
160
+ blurb: "store uid/gid, not names"),
161
+ Flag.new(id: :sparse, label: "sparse files", args: ["-S"], on: false, needs: GNU,
162
+ blurb: "store holes efficiently"),
163
+ Flag.new(id: :excl_vcs, label: "exclude VCS dirs", args: ["--exclude-vcs"], on: false, needs: GNU,
164
+ blurb: "skip .git, .hg, .svn"),
165
+ Flag.new(id: :excl_ign, label: "honour .gitignore", args: ["--exclude-vcs-ignores"], on: false, needs: GNU,
166
+ blurb: "skip files your VCS ignores"),
167
+ Flag.new(id: :sorted, label: "reproducible order", args: ["--sort=name"], on: false, needs: GNU,
168
+ blurb: "deterministic member order"),
169
+ Flag.new(id: :keepgoing, label: "ignore read errors", args: ["--ignore-failed-read"], on: false, needs: GNU,
170
+ blurb: "do not abort on unreadable files")
171
+ ].select { |f| f.supported?(flavor) }
172
+ end
173
+
174
+ def self.zip_flags
175
+ [
176
+ Flag.new(id: :verbose, label: "verbose", args: [], on: true,
177
+ blurb: "list each file (drives the progress readout)"),
178
+ Flag.new(id: :symlinks, label: "store symlinks", args: ["-y"], on: true,
179
+ blurb: "keep links as links, do not follow"),
180
+ Flag.new(id: :dirs, label: "directory entries", args: [], on: true,
181
+ blurb: "record folders explicitly"),
182
+ Flag.new(id: :junk, label: "junk paths", args: ["-j"], on: false,
183
+ blurb: "flatten everything into the root"),
184
+ Flag.new(id: :noextra, label: "strip extra attributes", args: ["-X"], on: false,
185
+ blurb: "no uid/gid or timestamps beyond the basics"),
186
+ Flag.new(id: :oldest, label: "archive time = newest entry", args: ["-o"], on: false,
187
+ blurb: "reproducible-ish archive mtime")
188
+ ]
189
+ end
190
+ end
data/lib/rakpak/job.rb ADDED
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "plan"
4
+
5
+ module Rakpak
6
+ # Runs a plan's steps on a worker thread, streaming output into a ring
7
+ # buffer. The UI polls it; backgrounding is just "stop looking at it".
8
+ class Job
9
+ KEEP = 400
10
+
11
+ attr_reader :plan, :state, :started_at, :finished_at, :exit_status,
12
+ :error, :step_index, :steps, :file_count, :current_file
13
+
14
+ def initialize(plan, total_files: nil)
15
+ @plan = plan
16
+ @steps = plan.steps
17
+ @total_files = total_files
18
+ @lines = []
19
+ @lock = Mutex.new
20
+ @state = :pending
21
+ @step_index = 0
22
+ @file_count = 0
23
+ @current_file = nil
24
+ @verbose = true
25
+ @cancel = false
26
+ @pid = nil
27
+ @spawned = nil # output path of the step currently being written
28
+ @started_at = nil
29
+ @finished_at = nil
30
+ end
31
+
32
+ def start
33
+ @started_at = now
34
+ @state = :running
35
+ @thread = Thread.new { run_all }
36
+ @thread.abort_on_exception = false
37
+ self
38
+ end
39
+
40
+ def running? = @state == :running
41
+ def done? = %i[done failed cancelled].include?(@state)
42
+ def ok? = @state == :done
43
+
44
+ def elapsed
45
+ return 0 unless @started_at
46
+
47
+ (@finished_at || now) - @started_at
48
+ end
49
+
50
+ def tail(n)
51
+ @lock.synchronize { @lines.last(n).dup }
52
+ end
53
+
54
+ def label
55
+ @lock.synchronize { @steps[[@step_index, @steps.size - 1].min]&.first || "archive" }
56
+ end
57
+
58
+ def total_files = @total_files
59
+
60
+ # nil when there is nothing to measure progress against: no file total,
61
+ # or a step whose tool is not listing members (verbose off).
62
+ def fraction
63
+ return nil unless @total_files&.positive? && @verbose
64
+
65
+ per = 1.0 / @steps.size
66
+ base = @step_index * per
67
+ [base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
68
+ end
69
+
70
+ # Bytes on disk for whatever this step is writing right now.
71
+ def output_size
72
+ out = @plan.outputs[[@step_index, @plan.outputs.size - 1].min]
73
+ return nil unless out
74
+
75
+ File.size(out)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ def summary
81
+ @plan.outputs.map do |o|
82
+ size = begin
83
+ File.size(o)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ [File.basename(o), size]
88
+ end
89
+ end
90
+
91
+ def cancel
92
+ @cancel = true
93
+ kill_current
94
+ end
95
+
96
+ # Blocks until the worker is finished, for at most `secs`.
97
+ def wait(secs = nil) = @thread&.join(secs)
98
+
99
+ private
100
+
101
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+
103
+ def kill_current
104
+ pid = @pid
105
+ return unless pid
106
+
107
+ begin
108
+ Process.kill("TERM", -pid) # the whole group: tar and its compressor
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ Thread.new do
113
+ sleep 2
114
+ # Once waitpid has reaped it the pid may belong to someone else.
115
+ next unless @pid == pid
116
+
117
+ begin
118
+ Process.kill("KILL", -pid)
119
+ rescue StandardError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+
125
+ def push(line)
126
+ return if line.empty?
127
+
128
+ @lock.synchronize do
129
+ @lines << line
130
+ @lines.shift(@lines.size - KEEP) if @lines.size > KEEP
131
+ end
132
+ end
133
+
134
+ def run_all
135
+ @steps.each_with_index do |(label, argv, verbose, stdout), idx|
136
+ @step_index = idx
137
+ @file_count = 0
138
+ @verbose = verbose
139
+ # A cancel that lands between steps must not start the next one.
140
+ return finish(:cancelled) if @cancel
141
+
142
+ push("▸ #{label}: #{Plan.show_cmd(argv, stdout)}")
143
+ status = run_step(argv, stdout)
144
+ return finish(:cancelled) if @cancel
145
+
146
+ unless status&.success?
147
+ @error = failure_message(label, status)
148
+ return finish(:failed)
149
+ end
150
+ @spawned = nil
151
+ end
152
+ finish(:done)
153
+ rescue StandardError => e
154
+ @error = "#{e.class}: #{e.message}"
155
+ finish(:failed)
156
+ end
157
+
158
+ def failure_message(label, status)
159
+ # status is nil when the command could not be spawned at all; in that
160
+ # case run_step already recorded the useful message.
161
+ return @error || "#{label} could not be started" if status.nil?
162
+
163
+ if status.signaled?
164
+ name = Signal.signame(status.termsig) || status.termsig.to_s
165
+ return "#{label} killed by SIG#{name}"
166
+ end
167
+ @exit_status = status.exitstatus
168
+ "#{label} exited with status #{@exit_status}"
169
+ end
170
+
171
+ def finish(state)
172
+ cleanup_incomplete if %i[failed cancelled].include?(state)
173
+ @state = state
174
+ @finished_at = now
175
+ end
176
+
177
+ # A half-written archive is worse than no archive: it opens, lists a few
178
+ # members, then fails. Remove it, but only the output of the step that
179
+ # was actually interrupted. Archives finished by earlier steps are whole
180
+ # and must survive, and a step that never spawned wrote nothing.
181
+ def cleanup_incomplete
182
+ path = @spawned
183
+ return unless path && File.exist?(path)
184
+
185
+ File.unlink(path)
186
+ push("removed incomplete #{File.basename(path)}")
187
+ rescue StandardError => e
188
+ push("could not remove #{File.basename(path)}: #{e.message}")
189
+ end
190
+
191
+ # `stdout` names a file the command's output is the archive for (gzip -c);
192
+ # otherwise stdout joins stderr in the log.
193
+ def run_step(argv, stdout = nil)
194
+ out = @plan.outputs[@step_index]
195
+ # Every tool here is asked to create the archive, and the confirm
196
+ # screen has said an existing one will be overwritten. zip would
197
+ # otherwise update it in place, keeping members that no longer exist.
198
+ File.unlink(out) if out && File.exist?(out)
199
+ @spawned = out
200
+
201
+ rd, wr = IO.pipe
202
+ begin
203
+ pid = Process.spawn(*argv, out: stdout ? [stdout, "wb"] : wr, err: wr, in: File::NULL,
204
+ pgroup: true, chdir: @plan.base)
205
+ rescue StandardError => e
206
+ # Nothing was spawned, so nothing will close these for us.
207
+ rd.close
208
+ wr.close
209
+ @error = e.message
210
+ return nil
211
+ end
212
+ wr.close
213
+ @pid = pid
214
+ # A cancel that raced the spawn saw no pid to signal; do it now.
215
+ kill_current if @cancel
216
+
217
+ buf = String.new("", encoding: Encoding::UTF_8)
218
+ begin
219
+ loop do
220
+ chunk = begin
221
+ rd.read_nonblock(16_384)
222
+ rescue IO::WaitReadable
223
+ IO.select([rd], nil, nil, 0.25)
224
+ next
225
+ rescue EOFError
226
+ break
227
+ end
228
+ # A pipe hands back binary. Filenames are arbitrary bytes, so this
229
+ # has to be made printable before it can meet the UTF-8 frame.
230
+ buf << chunk.force_encoding(Encoding::UTF_8).scrub("·")
231
+ # tar emits newlines, zip rewrites lines with \r.
232
+ while (m = buf.match(/[\r\n]/))
233
+ line = buf.slice!(0, m.end(0)).chomp("\n").chomp("\r").rstrip
234
+ record(line)
235
+ end
236
+ end
237
+ ensure
238
+ record(buf.rstrip) unless buf.strip.empty?
239
+ rd.close unless rd.closed?
240
+ end
241
+
242
+ _, status = Process.waitpid2(@pid)
243
+ @pid = nil
244
+ status
245
+ end
246
+
247
+ def record(line)
248
+ return if line.empty?
249
+
250
+ case line
251
+ when /\A\s*(?:adding|updating|deflated|stored):\s*(.+?)(?:\s*\(|\z)/
252
+ @file_count += 1
253
+ @current_file = Regexp.last_match(1)
254
+ when /\A(?:tar|zip|gzip|zstd|xz|bzip2|brotli|lz4):/, /\A\s*(?:total bytes|zip warning)/
255
+ @current_file = line
256
+ else
257
+ # Without -v the only lines are diagnostics, not members.
258
+ @file_count += 1 if @verbose
259
+ @current_file = line
260
+ end
261
+ push(line)
262
+ end
263
+ end
264
+ end