rakpak 1.0.0 → 1.2.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.
@@ -2,24 +2,28 @@
2
2
 
3
3
  require "set"
4
4
  require_relative "dir_cache"
5
+ require_relative "plan"
5
6
  require_relative "theme"
6
7
  require_relative "text"
7
8
 
8
9
  module Rakpak
9
- # The yazi-style three-pane file browser: parent, current, preview.
10
- # Owns navigation and the tag set; knows nothing about archiving.
11
10
  class Browser
12
- attr_reader :cwd, :tags, :filter
11
+ # Marking exclusions mid-pack: walking stays under root, back is where to return.
12
+ Scope = Struct.new(:root, :paths, :back)
13
+
14
+ attr_reader :cwd, :tags, :excludes, :filter
13
15
 
14
16
  def initialize(start_dir = Dir.pwd, sizer: nil)
15
17
  @dirs = DirCache.new
16
18
  @sizer = sizer
17
19
  @tags = Set.new
20
+ @excludes = Set.new
21
+ @scope = nil
18
22
  @cursor = {}
19
23
  @show_hidden = false
20
24
  @filter = nil
21
25
  @filtering = false
22
- @show_queue = true # on until someone presses t
26
+ @show_queue = true
23
27
  @pending = nil
24
28
  @preview_cache = {}
25
29
  @cwd = resolve(start_dir)
@@ -31,10 +35,12 @@ module Rakpak
31
35
  d
32
36
  end
33
37
 
34
- # ---------------------------------------------------------------- state
35
-
36
38
  def entries
37
39
  list = @dirs.list(@cwd, hidden: @show_hidden) || []
40
+ # Above the packed items, only they are reachable.
41
+ if @scope && @cwd == @scope.root && !@scope.paths.include?(@cwd)
42
+ list = list.select { |e| @scope.paths.include?(e.path) }
43
+ end
38
44
  return list unless @filter && !@filter.empty?
39
45
 
40
46
  needle = @filter.downcase
@@ -54,18 +60,19 @@ module Rakpak
54
60
  def show_queue? = @show_queue
55
61
  def filtering? = @filtering
56
62
 
63
+ def scoped? = !@scope.nil?
64
+
57
65
  def tag(path)
58
66
  path = File.expand_path(path)
67
+ @excludes.delete(path)
59
68
  return unless @tags.add?(path)
60
69
 
61
- # A figure may linger from a cursor-only pack of this path; a new tag
62
- # must not trust it.
70
+ # A figure may linger from a cursor-only pack of this path; don't trust it.
63
71
  @sizer&.forget(path)
64
72
  @sizer&.request(path)
65
73
  end
66
74
 
67
- # Every path out of the tag set goes through here so the sizer drops
68
- # its figure; re-tagging later must measure again, not serve a stale one.
75
+ # All tag removals must pass here so the sizer drops its now-stale figure.
69
76
  def untag(path)
70
77
  return unless @tags.delete?(path)
71
78
 
@@ -78,17 +85,55 @@ module Rakpak
78
85
  @tags.include?(entry.path) ? untag(entry.path) : tag(entry.path)
79
86
  end
80
87
 
88
+ def exclude(path)
89
+ path = File.expand_path(path)
90
+ untag(path)
91
+ @excludes << path
92
+ end
93
+
94
+ def toggle_exclude(entry = current)
95
+ return unless entry
96
+
97
+ @excludes.delete?(entry.path) || exclude(entry.path)
98
+ end
99
+
81
100
  def tag_all = entries.each { |e| tag(e.path) }
82
101
  def untag_all_here = entries.each { |e| untag(e.path) }
83
- def clear_tags = @tags.to_a.each { |p| untag(p) }
102
+
103
+ def clear_tags
104
+ @tags.to_a.each { |p| untag(p) }
105
+ @excludes.clear
106
+ end
107
+
108
+ # One packed folder opens inside it; several open at their common parent.
109
+ def scope_to(paths, base)
110
+ root = paths.size == 1 && File.directory?(paths.first) ? paths.first : base
111
+ @scope = Scope.new(root, paths, [@cwd, @cursor[@cwd], @filter])
112
+ @cwd = root
113
+ @filter = nil
114
+ end
115
+
116
+ def leave_scope
117
+ @cwd, cursor, @filter = @scope.back
118
+ @cursor[@cwd] = cursor
119
+ @scope = nil
120
+ end
121
+
122
+ def mark_in_scope
123
+ e = current
124
+ return unless e
125
+ return :unmarkable unless @scope.paths.any? { |p| Plan.inside?(e.path, p) }
126
+
127
+ toggle_exclude(e)
128
+ move(1)
129
+ nil
130
+ end
84
131
 
85
132
  def replace_tags(list)
86
133
  (@tags.to_a - list).each { |p| untag(p) }
87
134
  list.each { |p| tag(p) }
88
135
  end
89
136
 
90
- # Selections implied by the current state: explicit tags, else whatever
91
- # the cursor is sitting on.
92
137
  def selection
93
138
  return @tags.to_a.sort if @tags.any?
94
139
 
@@ -104,14 +149,10 @@ module Rakpak
104
149
  @tags.each { |p| @sizer.request(p) }
105
150
  end
106
151
 
107
- # ----------------------------------------------------------- navigation
108
-
109
152
  def move(delta)
110
153
  self.index = index + delta
111
154
  end
112
155
 
113
- # One step at a time wraps: up from the top lands on the last entry,
114
- # down from the bottom on the first. Page moves still stop at the ends.
115
156
  def step(delta)
116
157
  n = entries.size
117
158
  return if n.zero?
@@ -130,7 +171,7 @@ module Rakpak
130
171
  end
131
172
 
132
173
  def ascend
133
- return if @cwd == "/"
174
+ return if @cwd == "/" || @cwd == @scope&.root
134
175
 
135
176
  child = @cwd
136
177
  @cwd = File.dirname(@cwd)
@@ -142,13 +183,13 @@ module Rakpak
142
183
  def goto(dir)
143
184
  d = File.expand_path(dir)
144
185
  return false unless File.directory?(d) && File.readable?(d)
186
+ return false if @scope && d != @scope.root && !Plan.inside?(d, @scope.root)
145
187
 
146
188
  @cwd = d
147
189
  @filter = nil
148
190
  true
149
191
  end
150
192
 
151
- # Open the folder holding `path` with the cursor on it.
152
193
  def jump_to(path)
153
194
  path = File.expand_path(path)
154
195
  return false unless goto(File.dirname(path))
@@ -159,7 +200,6 @@ module Rakpak
159
200
  !idx.nil?
160
201
  end
161
202
 
162
- # Returns :quit, :archive, :tags, :help, :jobs or nil.
163
203
  def handle(key)
164
204
  return handle_filter(key) if @filtering
165
205
 
@@ -170,8 +210,15 @@ module Rakpak
170
210
  when "h" then return goto(Dir.home) && nil
171
211
  when "r" then return goto("/") && nil
172
212
  end
173
- # Anything else was not a chord: treat it as its own keystroke, so
174
- # a stray g never swallows q, p or space.
213
+ # Not a chord: handle it as its own key so a stray g never swallows q, p or space.
214
+ end
215
+
216
+ if @scope
217
+ case key
218
+ when :space, "x" then return mark_in_scope
219
+ when "p", :esc then return :resume
220
+ when "a", "d", "D", "u", "T", "b" then return nil
221
+ end
175
222
  end
176
223
 
177
224
  result = case key
@@ -188,6 +235,9 @@ module Rakpak
188
235
  when :space
189
236
  toggle_tag
190
237
  move(1)
238
+ when "x"
239
+ toggle_exclude
240
+ move(1)
191
241
  when "a" then tag_all
192
242
  when "d" then untag_all_here
193
243
  when "D" then clear_tags
@@ -197,12 +247,13 @@ module Rakpak
197
247
  when :ctrl_r then refresh!
198
248
  when "~" then goto(Dir.home)
199
249
  when "p" then :archive
250
+ when "u" then :unpack
200
251
  when "T" then :tags
201
252
  when "?" then :help
202
253
  when "b" then :jobs
203
254
  when "q", :ctrl_c then :quit
204
255
  end
205
- %i[archive tags help jobs quit].include?(result) ? result : nil
256
+ %i[archive unpack tags help jobs quit].include?(result) ? result : nil
206
257
  end
207
258
 
208
259
  def toggle_hidden
@@ -237,8 +288,6 @@ module Rakpak
237
288
 
238
289
  def page = @page_size || 10
239
290
 
240
- # -------------------------------------------------------------- drawing
241
-
242
291
  def draw(screen, status_line)
243
292
  @page_size = screen.h - 5
244
293
  draw_header(screen)
@@ -249,8 +298,6 @@ module Rakpak
249
298
  draw_footer(screen, screen.h - 1, status_line)
250
299
  end
251
300
 
252
- # Left to right: the queue (when shown), parent, current, preview. The
253
- # queue takes its slice first and the usual three share what is left.
254
301
  def layout(w)
255
302
  panes = []
256
303
  x = 0
@@ -280,7 +327,10 @@ module Rakpak
280
327
  def draw_header(screen)
281
328
  screen.fill(0, 0, screen.w, 1, " ", Theme::HEAD)
282
329
  screen.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
283
- right = tag_summary
330
+ right = if @scope then "marking exclusions"
331
+ elsif @excludes.any? then "#{tag_summary} · #{@excludes.size} excluded"
332
+ else tag_summary
333
+ end
284
334
  avail = screen.w - 10 - Text.width(right) - 3
285
335
  screen.put(9, 0, Text.fit_left(Text.tilde(@cwd), [avail, 1].max), Theme::HEAD + "\e[38;5;231m")
286
336
  screen.put(screen.w - Text.width(right) - 1, 0, right,
@@ -308,9 +358,6 @@ module Rakpak
308
358
  end
309
359
  end
310
360
 
311
- # Everything queued for the next pack, wherever on the disk it lives:
312
- # the name, then where it is, with the size at the edge and the running
313
- # total on top. Stays put while you keep browsing.
314
361
  def draw_queue(screen, pane, y, h)
315
362
  x = pane[:x]
316
363
  w = pane[:w]
@@ -362,12 +409,16 @@ module Rakpak
362
409
  row = y + i
363
410
  sel = (top + i) == cur
364
411
  tagged = @tags.include?(e.path)
412
+ excluded = @excludes.include?(e.path)
365
413
  bg = if sel && active then Theme::CUR_BG
366
414
  elsif sel then Theme::SEL_BG
367
415
  end
368
416
  screen.fill(x, row, w, 1, " ", bg) if bg
369
- mark_style = tagged ? (bg.to_s + Theme::TAG) : (bg.to_s + Theme::FAINT)
370
- screen.put(x, row, tagged ? " ▌" : " ", mark_style)
417
+ mark, mark_style = if tagged then [" ▌", Theme::TAG]
418
+ elsif excluded then [" ✗", Theme::ERR]
419
+ else [" ", Theme::FAINT]
420
+ end
421
+ screen.put(x, row, mark, bg.to_s + mark_style)
371
422
  name_style = bg ? bg + (sel && active ? "\e[1;38;5;231m" : e.style) : e.style
372
423
  avail = w - 3
373
424
  avail -= 5 unless pane[:kind] == :parent
@@ -422,8 +473,7 @@ module Rakpak
422
473
  end
423
474
 
424
475
  def file_preview(entry, w, h)
425
- # Reading a FIFO or a device blocks until someone writes to it, which
426
- # would freeze the UI with the cursor merely resting on the entry.
476
+ # Reading a FIFO or device blocks and would freeze the UI.
427
477
  kind = entry.target_stat
428
478
  return [["not a regular file", Theme::FAINT]] unless kind&.file?
429
479
 
@@ -443,7 +493,6 @@ module Rakpak
443
493
  [[e.class.name.split("::").last, Theme::ERR]]
444
494
  end
445
495
 
446
- # Bytes per row adapts to the pane so the ASCII column always fits.
447
496
  def hexdump(bytes, rows, width)
448
497
  return [] if rows <= 0
449
498
 
@@ -487,7 +536,9 @@ module Rakpak
487
536
  end
488
537
 
489
538
  HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
490
- [".", "hidden"], ["p", "pack"], ["?", "help"]].freeze
539
+ [".", "hidden"], ["p", "pack"], ["u", "unpack"], ["?", "help"]].freeze
540
+ SCOPE_HINTS = [["space", "exclude"], ["←→", "nav"], ["/", "find"], [".", "hidden"],
541
+ ["p", "back to pack"], ["?", "help"]].freeze
491
542
 
492
543
  def draw_footer(screen, y, status_line)
493
544
  screen.fill(0, y, screen.w, 1, " ", nil)
@@ -496,7 +547,7 @@ module Rakpak
496
547
  return
497
548
  end
498
549
  x = 1
499
- HINTS.each do |k, label|
550
+ (@scope ? SCOPE_HINTS : HINTS).each do |k, label|
500
551
  break if x + Text.width(k) + Text.width(label) + 3 > screen.w
501
552
 
502
553
  x = screen.put(x, y, k, Theme::KEY)
@@ -3,8 +3,6 @@
3
3
  require_relative "entry"
4
4
 
5
5
  module Rakpak
6
- # Directory listings, memoised per (path, show_hidden). Cheap enough that
7
- # the parent/preview panes can ask on every keystroke.
8
6
  class DirCache
9
7
  MAX_ENTRIES = 20_000
10
8
 
@@ -31,7 +29,7 @@ module Rakpak
31
29
  names.reject! { |n| n.start_with?(".") } unless hidden
32
30
  names = names.first(MAX_ENTRIES)
33
31
  names.map { |n| Entry.new(File.join(path, n), n) }.sort_by(&:sort_key)
34
- rescue StandardError # EACCES, ENOENT, ENOTDIR, ELOOP and the rest
32
+ rescue StandardError
35
33
  nil
36
34
  end
37
35
  end
data/lib/rakpak/entry.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # One filesystem row. Stats are taken once, lazily, and never raise.
5
4
  class Entry
6
5
  attr_reader :path, :name
7
6
 
@@ -64,13 +63,11 @@ module Rakpak
64
63
  Theme::NORMAL
65
64
  end
66
65
 
67
- # The name as comparable text: lowercased, with bytes that are not
68
- # valid UTF-8 replaced so downcase cannot raise on them.
66
+ # Scrub invalid UTF-8 first, or downcase raises.
69
67
  def fold
70
68
  @fold ||= (@name.valid_encoding? ? @name : @name.scrub("?")).downcase
71
69
  end
72
70
 
73
- # Dirs first, then case-insensitive natural order.
74
71
  def sort_key
75
72
  [dir? ? 0 : 1, fold, @name]
76
73
  end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
  module Tools
8
5
  module_function
9
6
 
@@ -27,8 +24,7 @@ module Rakpak
27
24
  @zip_bz2 = false
28
25
  end
29
26
 
30
- # GNU tar, bsdtar (macOS) and busybox tar disagree about flags. Detect
31
- # once so the UI only offers switches this tar actually understands.
27
+ # GNU tar, bsdtar (macOS) and busybox tar disagree about flags.
32
28
  def tar_flavor
33
29
  return @tar_flavor if defined?(@tar_flavor)
34
30
 
@@ -54,7 +50,6 @@ module Rakpak
54
50
 
55
51
  end
56
52
 
57
- # A tar compression backend.
58
53
  Codec = Struct.new(:id, :label, :ext, :bin, :levels, :default, :threads, :blurb,
59
54
  :level_opt, keyword_init: true) do
60
55
  def available? = bin.nil? || Tools.available?(bin)
@@ -62,10 +57,8 @@ module Rakpak
62
57
  def why_not = available? ? nil : "#{bin} not installed"
63
58
  def container? = false
64
59
 
65
- # ".tar.gz" for a tarball, ".gz" when compressing a lone file.
66
60
  def single_ext = ext.delete_prefix(".tar")
67
61
 
68
- # The compressor reading a named file and writing to stdout.
69
62
  def argv(level)
70
63
  return nil if bin.nil?
71
64
 
@@ -76,8 +69,7 @@ module Rakpak
76
69
  parts
77
70
  end
78
71
 
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.
72
+ # tar splits --use-compress-program on spaces itself; GNU tar and bsdtar both accept it.
81
73
  def filter(level) = argv(level)&.join(" ")
82
74
  end
83
75
 
@@ -102,7 +94,6 @@ module Rakpak
102
94
 
103
95
  def self.tar_codec(id) = TAR_CODECS.find { |c| c.id == id }
104
96
 
105
- # A zip container written by Info-ZIP, with one of its entry methods.
106
97
  ZipMethod = Struct.new(:id, :label, :flag, :levels, :default, :blurb, keyword_init: true) do
107
98
  def available?
108
99
  flag == "bzip2" ? Tools.zip_has_bzip2? : Tools.available?("zip")
@@ -127,15 +118,11 @@ module Rakpak
127
118
  blurb: ".zip with no compression")
128
119
  ].freeze
129
120
 
130
- # Everything the zip target can produce: a .zip of anything, or a lone
131
- # file run through one compressor (notes.txt.gz).
132
121
  COMPRESSORS = (ZIP_METHODS + TAR_CODECS.reject { |c| c.id == :none }).freeze
133
122
 
134
123
  def self.compressor(id) = COMPRESSORS.find { |c| c.id == id }
135
124
 
136
- # Toggleable switches, rendered as a checklist.
137
125
  Flag = Struct.new(:id, :label, :args, :on, :blurb, :needs, keyword_init: true) do
138
- # `needs` lists the tar flavours that understand this switch.
139
126
  def supported?(flavor) = needs.nil? || needs.include?(flavor)
140
127
  end
141
128
 
@@ -143,7 +130,6 @@ module Rakpak
143
130
  BOTH = %i[gnu bsd unknown].freeze
144
131
  ALL = %i[gnu bsd busybox unknown].freeze
145
132
 
146
- # Only flags this machine's tar understands are offered.
147
133
  def self.tar_flags(flavor = Tools.tar_flavor)
148
134
  [
149
135
  Flag.new(id: :verbose, label: "verbose", args: ["-v"], on: true, needs: ALL,
data/lib/rakpak/job.rb CHANGED
@@ -3,8 +3,6 @@
3
3
  require_relative "plan"
4
4
 
5
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
6
  class Job
9
7
  KEEP = 400
10
8
 
@@ -24,7 +22,7 @@ module Rakpak
24
22
  @verbose = true
25
23
  @cancel = false
26
24
  @pid = nil
27
- @spawned = nil # output path of the step currently being written
25
+ @spawned = nil
28
26
  @started_at = nil
29
27
  @finished_at = nil
30
28
  end
@@ -57,8 +55,6 @@ module Rakpak
57
55
 
58
56
  def total_files = @total_files
59
57
 
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
58
  def fraction
63
59
  return nil unless @total_files&.positive? && @verbose
64
60
 
@@ -67,16 +63,21 @@ module Rakpak
67
63
  [base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
68
64
  end
69
65
 
70
- # Bytes on disk for whatever this step is writing right now.
66
+ # A directory's own size means nothing mid-extraction.
71
67
  def output_size
72
- out = @plan.outputs[[@step_index, @plan.outputs.size - 1].min]
73
- return nil unless out
68
+ out = writing_now
69
+ return nil if out.nil? || File.directory?(out)
74
70
 
75
71
  File.size(out)
76
72
  rescue StandardError
77
73
  nil
78
74
  end
79
75
 
76
+ def writing_now
77
+ i = [@step_index, @steps.size - 1].min
78
+ @steps[i]&.[](3) || @plan.outputs[[i, @plan.outputs.size - 1].min]
79
+ end
80
+
80
81
  def summary
81
82
  @plan.outputs.map do |o|
82
83
  size = begin
@@ -93,7 +94,6 @@ module Rakpak
93
94
  kill_current
94
95
  end
95
96
 
96
- # Blocks until the worker is finished, for at most `secs`.
97
97
  def wait(secs = nil) = @thread&.join(secs)
98
98
 
99
99
  private
@@ -105,7 +105,7 @@ module Rakpak
105
105
  return unless pid
106
106
 
107
107
  begin
108
- Process.kill("TERM", -pid) # the whole group: tar and its compressor
108
+ Process.kill("TERM", -pid) # whole group: tar and its compressor
109
109
  rescue StandardError
110
110
  nil
111
111
  end
@@ -132,11 +132,12 @@ module Rakpak
132
132
  end
133
133
 
134
134
  def run_all
135
+ @plan.prepare
135
136
  @steps.each_with_index do |(label, argv, verbose, stdout), idx|
136
137
  @step_index = idx
137
138
  @file_count = 0
138
139
  @verbose = verbose
139
- # A cancel that lands between steps must not start the next one.
140
+ # A cancel between steps must not start the next one.
140
141
  return finish(:cancelled) if @cancel
141
142
 
142
143
  push("▸ #{label}: #{Plan.show_cmd(argv, stdout)}")
@@ -149,6 +150,7 @@ module Rakpak
149
150
  end
150
151
  @spawned = nil
151
152
  end
153
+ @plan.commit
152
154
  finish(:done)
153
155
  rescue StandardError => e
154
156
  @error = "#{e.class}: #{e.message}"
@@ -156,8 +158,7 @@ module Rakpak
156
158
  end
157
159
 
158
160
  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
+ # nil status: spawn failed and run_step already set @error.
161
162
  return @error || "#{label} could not be started" if status.nil?
162
163
 
163
164
  if status.signaled?
@@ -169,16 +170,19 @@ module Rakpak
169
170
  end
170
171
 
171
172
  def finish(state)
172
- cleanup_incomplete if %i[failed cancelled].include?(state)
173
+ if %i[failed cancelled].include?(state)
174
+ cleanup_incomplete
175
+ @plan.rollback
176
+ end
173
177
  @state = state
174
178
  @finished_at = now
175
179
  end
176
180
 
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
+ # Remove only the interrupted step's half-written archive; earlier ones are whole.
182
+ # Extraction output may hold pre-existing files, so it is never removed.
181
183
  def cleanup_incomplete
184
+ return unless @plan.clobbers_output?
185
+
182
186
  path = @spawned
183
187
  return unless path && File.exist?(path)
184
188
 
@@ -188,28 +192,28 @@ module Rakpak
188
192
  push("could not remove #{File.basename(path)}: #{e.message}")
189
193
  end
190
194
 
191
- # `stdout` names a file the command's output is the archive for (gzip -c);
192
- # otherwise stdout joins stderr in the log.
193
195
  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)
196
+ out = stdout || @plan.outputs[@step_index]
197
+ # zip would otherwise update an existing archive in place, keeping stale members.
198
+ clear_path(out) if @plan.clobbers_output?
199
199
  @spawned = out
200
200
 
201
201
  rd, wr = IO.pipe
202
+ sink = nil
202
203
  begin
203
- pid = Process.spawn(*argv, out: stdout ? [stdout, "wb"] : wr, err: wr, in: File::NULL,
204
+ sink = open_sink(stdout) if stdout
205
+ pid = Process.spawn(*argv, out: sink || wr, err: wr, in: File::NULL,
204
206
  pgroup: true, chdir: @plan.base)
205
207
  rescue StandardError => e
206
208
  # Nothing was spawned, so nothing will close these for us.
207
209
  rd.close
208
210
  wr.close
211
+ sink&.close
209
212
  @error = e.message
210
213
  return nil
211
214
  end
212
215
  wr.close
216
+ sink&.close
213
217
  @pid = pid
214
218
  # A cancel that raced the spawn saw no pid to signal; do it now.
215
219
  kill_current if @cancel
@@ -225,8 +229,7 @@ module Rakpak
225
229
  rescue EOFError
226
230
  break
227
231
  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.
232
+ # Filenames are arbitrary bytes; scrub before they meet the UTF-8 frame.
230
233
  buf << chunk.force_encoding(Encoding::UTF_8).scrub("·")
231
234
  # tar emits newlines, zip rewrites lines with \r.
232
235
  while (m = buf.match(/[\r\n]/))
@@ -244,6 +247,25 @@ module Rakpak
244
247
  status
245
248
  end
246
249
 
250
+ # lstat: a dangling symlink is not exist?, and writing through it would escape the folder.
251
+ def clear_path(path)
252
+ return unless path
253
+
254
+ st = begin
255
+ File.lstat(path)
256
+ rescue StandardError
257
+ return
258
+ end
259
+ File.unlink(path) unless st.directory?
260
+ end
261
+
262
+ # O_EXCL|O_NOFOLLOW: never write through a symlink planted after clear_path.
263
+ SINK_FLAGS = File::WRONLY | File::CREAT | File::EXCL |
264
+ (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
265
+
266
+ # 0666 so the result respects umask, like a shell redirect.
267
+ def open_sink(path) = File.open(path, SINK_FLAGS, 0o666)
268
+
247
269
  def record(line)
248
270
  return if line.empty?
249
271