metaclean 4.1.0 → 4.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.
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metaclean
4
+ class Discovery
5
+ attr_reader :scan_errors, :guards
6
+
7
+ def initialize(recursive: false)
8
+ @recursive = recursive
9
+ @scan_errors = 0
10
+ @guards = {}
11
+ end
12
+
13
+ def expand(paths)
14
+ @scan_errors = 0
15
+ @guards = {}
16
+ explicit = []
17
+ discovered = []
18
+ paths.each do |path|
19
+ collect_path(path, explicit, discovered)
20
+ end
21
+ discovered.reject! { |file| skip?(file) }
22
+ result = dedupe_by_realpath(explicit + discovered)
23
+ result.select do |file|
24
+ @guards[file] = FileOps.path_guard!(file)
25
+ true
26
+ rescue Error, SystemCallError => e
27
+ scan_failed(file, e)
28
+ false
29
+ end
30
+ end
31
+
32
+ def skip?(file)
33
+ base = File.basename(file)
34
+ base.start_with?('.') ||
35
+ base.end_with?('.bak') ||
36
+ base.match?(Metaclean::CLEAN_OUTPUT_RE) ||
37
+ base.include?(Metaclean::TMP_MARKER)
38
+ end
39
+
40
+ def dedupe_by_realpath(paths)
41
+ seen = {}
42
+ paths.each_with_object([]) do |path, result|
43
+ key = safe_realpath(path)
44
+ next if seen[key]
45
+
46
+ seen[key] = true
47
+ result << path
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def collect_path(path, explicit, discovered)
54
+ unless File.exist?(path) || File.symlink?(path)
55
+ Display.warning "Not found: #{path}"
56
+ @scan_errors += 1
57
+ return
58
+ end
59
+
60
+ FileOps.path_guard!(path)
61
+ if File.directory?(path)
62
+ collect_dir(path, discovered)
63
+ elsif File.file?(path)
64
+ explicit << path
65
+ else
66
+ scan_failed(path, Error.new('not a regular file or directory'))
67
+ end
68
+ rescue Error, SystemCallError => e
69
+ scan_failed(path, e)
70
+ end
71
+
72
+ def safe_realpath(path)
73
+ File.realpath(path)
74
+ rescue SystemCallError
75
+ path
76
+ end
77
+
78
+ def collect_dir(dir, files)
79
+ if @recursive
80
+ walk_recursive(dir, files)
81
+ else
82
+ Dir.children(dir).each do |entry|
83
+ next if entry.start_with?('.')
84
+
85
+ path = File.join(dir, entry)
86
+ next if File.symlink?(path)
87
+
88
+ begin
89
+ FileOps.path_guard!(path)
90
+ files << path if File.file?(path)
91
+ rescue Error, SystemCallError => e
92
+ scan_failed(path, e)
93
+ end
94
+ end
95
+ end
96
+ rescue SystemCallError => e
97
+ scan_failed(dir, e)
98
+ end
99
+
100
+ def walk_recursive(dir, files)
101
+ Dir.each_child(dir) do |entry|
102
+ next if entry.start_with?('.')
103
+
104
+ path = File.join(dir, entry)
105
+ next if File.symlink?(path)
106
+
107
+ begin
108
+ FileOps.path_guard!(path)
109
+ if File.directory?(path)
110
+ walk_recursive(path, files)
111
+ elsif File.file?(path)
112
+ files << path
113
+ end
114
+ rescue Error, SystemCallError => e
115
+ scan_failed(path, e)
116
+ end
117
+ end
118
+ rescue SystemCallError => e
119
+ scan_failed(dir, e)
120
+ end
121
+
122
+ def scan_failed(dir, error)
123
+ Display.warning "Skipping #{dir}: #{error.message}"
124
+ @scan_errors += 1
125
+ end
126
+ end
127
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # All terminal output lives here: ANSI colors, headers, tables, the
4
- # before/after diff. Colors are gated on `tty?` (see color?).
5
-
6
3
  module Metaclean
7
4
  module Display
8
5
  COLORS = {
@@ -17,14 +14,8 @@ module Metaclean
17
14
  gray: "\e[90m"
18
15
  }.freeze
19
16
 
20
- # ExifTool reports four "groups" that are descriptions of the file
21
- # itself, not embedded metadata: System (filesystem stat), File (header
22
- # info), ExifTool (its own version), Composite (computed values).
23
- # Excluding these makes the diff focus on what actually got stripped.
24
17
  NON_METADATA_GROUPS = %w[System File ExifTool Composite].freeze
25
18
 
26
- # ASCII wordmark shown at the top of --help / --version. Printed by `banner`
27
- # (see there for why it's colored line-by-line).
28
19
  LOGO = <<~ART
29
20
  ███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗
30
21
  ████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║
@@ -36,21 +27,28 @@ module Metaclean
36
27
 
37
28
  module_function
38
29
 
39
- # Decides whether to emit ANSI color codes. Colors are wrong when:
40
- # * stdout is a pipe/file (not a terminal) — `tty?` is false there
41
- # * NO_COLOR env var is set (de-facto convention, see no-color.org)
30
+ def configure(quiet: false, redact_values: false)
31
+ @quiet = quiet
32
+ @redact_values = redact_values
33
+ end
34
+
35
+ def quiet?
36
+ @quiet == true
37
+ end
38
+
39
+ def redact_values?
40
+ @redact_values == true
41
+ end
42
+
42
43
  def color?
43
44
  return @color if defined?(@color)
44
45
 
45
- # Per https://no-color.org: disable only when NO_COLOR is set to a
46
- # non-empty value. An unset or empty NO_COLOR leaves colors on.
47
46
  no_color = ENV['NO_COLOR'].to_s
48
47
  @color = $stdout.tty? && no_color.empty?
49
48
  @color = true if ENV['FORCE_COLOR']
50
49
  @color
51
50
  end
52
51
 
53
- # Wrap text in a color, or pass it through plain when colors are off.
54
52
  def c(text, color)
55
53
  text = printable(text)
56
54
  return text unless color?
@@ -58,35 +56,30 @@ module Metaclean
58
56
  "#{COLORS[color]}#{text}#{COLORS[:reset]}"
59
57
  end
60
58
 
61
- # Red ASCII wordmark (matches Ruby's brand color) + one-line tagline for
62
- # --help / --version. Colored line-by-line on purpose: `c` runs text through
63
- # `printable`, which turns control chars (including the heredoc's newlines)
64
- # into spaces — so coloring the whole block at once would collapse the logo
65
- # onto one line.
66
59
  def banner
67
60
  LOGO.each_line { |line| puts c(line.chomp, :red) }
68
61
  puts c(' strip EXIF · IPTC · XMP · GPS · ID3 — leave the file clean', :gray)
69
62
  end
70
63
 
71
64
  def header(text)
65
+ return if quiet?
66
+
72
67
  puts
73
68
  puts c('━' * 64, :gray)
74
69
  puts c(text, :bold)
75
70
  puts c('━' * 64, :gray)
76
71
  end
77
72
 
78
- def section(text); puts c("▸ #{text}", :cyan); end
79
- def info(text); puts c(" #{text}", :gray); end
80
- def success(text); puts c("✓ #{text}", :green); end
73
+ def section(text); puts c("▸ #{text}", :cyan) unless quiet?; end
74
+ def info(text); puts c(" #{text}", :gray) unless quiet?; end
75
+ def success(text); puts c("✓ #{text}", :green) unless quiet?; end
81
76
  def warning(text); puts c("⚠ #{text}", :yellow);end
82
77
 
83
- # `error` returns a string instead of printing it — callers usually want
84
- # to send it to STDERR via `warn`, not stdout via `puts`.
85
78
  def error(text); c("✗ #{text}", :red); end
86
79
 
87
- # Prints a metadata Hash as a grouped, indented table.
88
- # `only_embedded:` filters out the System/File/etc. noise.
89
80
  def metadata_table(meta, only_embedded: false)
81
+ return if quiet?
82
+
90
83
  rows = meta.reject { |k, _| k == 'SourceFile' }
91
84
  rows = rows.select { |k, _| embedded_key?(k) } if only_embedded
92
85
 
@@ -95,120 +88,110 @@ module Metaclean
95
88
  return
96
89
  end
97
90
 
98
- # Group "GPS:*", "EXIF:*", … each into its own labeled sub-table.
99
91
  grouped = rows.group_by { |k, _| group_of(k) }
100
92
  grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
101
93
  puts c(" [#{group}]", :magenta)
102
94
  pairs.sort_by { |k, _| k.to_s }.each do |k, v|
103
95
  tag = k.to_s.split(':', 2).last
104
- line = format(' %-38s %s', truncate(tag, 38), truncate(format_value(v), 60))
96
+ line = format(' %-38s %s', truncate(tag, 38), truncate(visible_value(v), 60))
105
97
  puts c(line, :dim)
106
98
  end
107
99
  end
108
100
  end
109
101
 
110
- # Compares two metadata hashes (before vs after) and prints three
111
- # sections: removed, changed, still-present. This is the "before/after"
112
- # the user asked for.
113
102
  def diff(before, after)
114
- keys = (before.keys + after.keys).uniq.select { |k| embedded_key?(k) }
115
-
116
- removed = []
117
- changed = []
118
- kept = []
119
-
120
- keys.sort.each do |k|
121
- b = before[k]
122
- a = after[k]
123
- if a.nil? && !b.nil?
124
- removed << [k, b]
125
- elsif !b.nil? && a != b
126
- changed << [k, b, a]
127
- elsif !b.nil?
128
- kept << [k, b]
129
- end
103
+ return if quiet?
104
+
105
+ removed, changed, kept = classify_diff(before, after)
106
+ render_removed(removed)
107
+ render_changed(changed)
108
+ render_kept(kept)
109
+
110
+ if [removed, changed, kept].all?(&:empty?)
111
+ info 'Nothing to strip — file already clean.'
112
+ elsif removed.empty? && changed.empty?
113
+ info 'No tags were removed see "Still present" above.'
130
114
  end
115
+ end
131
116
 
132
- if removed.any?
133
- section "Removed (#{removed.size})"
134
- removed.each do |k, b|
135
- puts " #{c('-', :red)} #{c(k, :red)} #{c(truncate(format_value(b), 60), :gray)}"
117
+ def classify_diff(before, after)
118
+ rows = [[], [], []]
119
+ keys = (before.keys + after.keys).uniq.select { |key| embedded_key?(key) }
120
+ keys.sort.each do |key|
121
+ old_value = before[key]
122
+ new_value = after[key]
123
+ if new_value.nil? && !old_value.nil?
124
+ rows[0] << [key, old_value]
125
+ elsif !old_value.nil? && new_value != old_value
126
+ rows[1] << [key, old_value, new_value]
127
+ elsif !old_value.nil?
128
+ rows[2] << [key, old_value]
136
129
  end
137
130
  end
131
+ rows
132
+ end
138
133
 
139
- if changed.any?
140
- section "Changed (#{changed.size})"
141
- changed.each do |k, b, a|
142
- puts " #{c('~', :yellow)} #{c(k, :yellow)}"
143
- puts " #{c('-', :red)} #{truncate(format_value(b), 60)}"
144
- puts " #{c('+', :green)} #{truncate(format_value(a), 60)}"
145
- end
134
+ def render_removed(rows)
135
+ return if rows.empty?
136
+
137
+ section "Removed (#{rows.size})"
138
+ rows.each do |key, value|
139
+ puts " #{c('-', :red)} #{c(key, :red)} #{c(truncate(visible_value(value), 60), :gray)}"
146
140
  end
141
+ end
147
142
 
148
- if kept.any?
149
- section "Still present (#{kept.size})"
150
- kept.each do |k, b|
151
- puts " #{c('=', :gray)} #{c(k, :gray)} #{c(truncate(format_value(b), 60), :gray)}"
152
- end
143
+ def render_changed(rows)
144
+ return if rows.empty?
145
+
146
+ section "Changed (#{rows.size})"
147
+ rows.each do |key, old_value, new_value|
148
+ puts " #{c('~', :yellow)} #{c(key, :yellow)}"
149
+ puts " #{c('-', :red)} #{truncate(visible_value(old_value), 60)}"
150
+ puts " #{c('+', :green)} #{truncate(visible_value(new_value), 60)}"
153
151
  end
152
+ end
154
153
 
155
- if removed.empty? && changed.empty? && kept.empty?
156
- info 'Nothing to strip — file already clean.'
157
- elsif removed.empty? && changed.empty?
158
- info 'No tags were removed — see "Still present" above.'
154
+ def render_kept(rows)
155
+ return if rows.empty?
156
+
157
+ section "Still present (#{rows.size})"
158
+ rows.each do |key, value|
159
+ puts " #{c('=', :gray)} #{c(key, :gray)} #{c(truncate(visible_value(value), 60), :gray)}"
159
160
  end
160
161
  end
161
162
 
162
- # Group name out of "Group:Tag" (split caps at 2 so a ":" in the value is safe).
163
163
  def group_of(key)
164
164
  key.to_s.split(':', 2).first.to_s
165
165
  end
166
166
 
167
- # True when `key` names real embedded metadata: not the SourceFile
168
- # bookkeeping key, and not one of the System/File/ExifTool/Composite
169
- # groups that describe the file rather than its embedded tags. Single
170
- # source of truth for "is this a tag we actually stripped?" — shared by
171
- # the table, diff, count, removed-count and privacy-residual checks.
172
167
  def embedded_key?(key)
173
168
  key != 'SourceFile' && !NON_METADATA_GROUPS.include?(group_of(key))
174
169
  end
175
170
 
176
- # Make any value safe to print on a single line. Hashes/Arrays get
177
- # `inspect` (shows their structure); strings are collapsed to single
178
- # spaces so a multiline tag value doesn't wreck the table.
179
171
  def format_value(v)
180
172
  case v
181
173
  when Hash, Array then printable(v.inspect)
182
174
  else
183
- # Guard the regexp gsub against invalid-encoding tag values — gsub raises
184
- # ArgumentError on them. Exiftool.read already scrubs; this is belt-and-
185
- # suspenders so the display layer can never crash the run on hostile bytes.
186
175
  s = printable(v)
187
176
  s.gsub(/\s+/, ' ')
188
177
  end
189
178
  end
190
179
 
191
- # Render untrusted filenames/metadata as terminal text, not terminal control.
192
- # Exif/Office/PDF metadata can contain ANSI/OSC escape bytes; printing those
193
- # raw can recolor output, rewrite a terminal title, or worse. We keep the
194
- # content readable by replacing C0/DEL and C1 control chars with spaces
195
- # (C1, U+0080–U+009F, holds the 8-bit CSI/OSC introducers some terminals honor).
180
+ def visible_value(value)
181
+ redact_values? ? '[redacted]' : format_value(value)
182
+ end
183
+
196
184
  def printable(text)
197
185
  s = text.to_s
198
186
  s = s.scrub unless s.valid_encoding?
199
187
  s.gsub(/[[:cntrl:]]/, ' ')
200
188
  end
201
189
 
202
- # Truncate to N chars with a single-character ellipsis. We use "…"
203
- # (one Unicode char) instead of "..." so the truncation doesn't itself
204
- # spill over the budget.
205
190
  def truncate(s, n)
206
191
  s = s.to_s
207
192
  s.length > n ? "#{s[0, n - 1]}…" : s
208
193
  end
209
194
 
210
- # How many "real" embedded tags are there? Used for the
211
- # "Before (24 embedded tags) → After (0)" summary line.
212
195
  def count_embedded(meta)
213
196
  meta.keys.count { |k| embedded_key?(k) }
214
197
  end
@@ -1,66 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Thin wrapper around the external `exiftool` binary.
4
- #
5
- # Open3.capture3 with multiple args bypasses the shell, so a filename like
6
- # `cat; rm -rf /` is one argument, not a command. That's not the whole story:
7
- # exiftool still parses its own arguments, so a filename beginning with "-"
8
- # (e.g. `-config`) would be read as an option. Every path goes through
9
- # `Metaclean.safe_path`, which prefixes a leading dash with "./" so it's
10
- # always seen as a filename.
11
-
12
- require 'open3'
13
3
  require 'json'
14
4
 
15
5
  module Metaclean
16
6
  module Exiftool
17
7
  module_function
18
8
 
19
- # True if `exiftool` is on PATH. Memoized so repeated checks don't re-spawn
20
- # it (defined? not nil? — the cached value can legitimately be false).
21
9
  def available?
22
10
  return @available if defined?(@available)
23
11
 
24
- out, _err, status = Open3.capture3('exiftool', '-ver')
12
+ out, _err, status = Metaclean.capture3(
13
+ 'exiftool', '-ver',
14
+ timeout: Metaclean::PROBE_TIMEOUT,
15
+ max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
16
+ )
25
17
  @available = status.success?
26
- # Stash the version off the same call so `version` need not re-spawn.
27
18
  @version = @available ? out.strip : nil
28
19
  @available
29
- rescue Errno::ENOENT
20
+ rescue Errno::ENOENT, Error
30
21
  @version = nil
31
- @available = false # exiftool not on PATH
22
+ @available = false
32
23
  end
33
24
 
34
- # Returns the version string, or nil if exiftool is missing/broken.
35
- # Captured by `available?`, so this never re-runs the binary.
36
25
  def version
37
26
  available? ? @version : nil
38
27
  end
39
28
 
40
- # Reads metadata from a file and returns a flat Hash of "Group:Tag" => value.
41
- #
42
- # ExifTool flag glossary:
43
- # -j JSON output (machine-parseable)
44
- # -G1 Include the family-1 group name. NB: with -G1 mainstream EXIF
45
- # tags appear under "IFD0"/"ExifIFD"/"IFD1", not "EXIF" (that's
46
- # the family-0 name); GPS/IPTC/XMP-dc keep those group names.
47
- # -a Allow duplicate tags (some formats have several with same name)
48
- # -u Include unknown/unidentified tags
49
- # -s Short tag names (no descriptions)
50
- # -n Numeric values (no human formatting like "1/100 sec")
51
- # -api largefilesupport=1 Allow files >4 GB
52
29
  def read(path)
53
30
  out, err, status = Metaclean.capture3(
54
31
  'exiftool', '-j', '-G1', '-a', '-u', '-s', '-n', '-api', 'largefilesupport=1',
55
32
  Metaclean.safe_path(path)
56
33
  )
57
- raise Error, "ExifTool read failed: #{err.strip}" unless status.success?
34
+ raise Error, "ExifTool read failed: #{read_error_detail(out, err)}" unless status.success?
58
35
 
59
- # ExifTool's JSON output is an array (one entry per file). We always
60
- # pass one file, so we take the first element. `|| {}` handles the
61
- # edge case where exiftool returns an empty array. A non-array shape is
62
- # unexpected — bail with a clear error instead of crashing later on
63
- # `.first` returning a Hash/scalar.
64
36
  data = JSON.parse(out)
65
37
  raise Error, 'Unexpected ExifTool output (expected a JSON array)' unless data.is_a?(Array)
66
38
 
@@ -69,12 +41,17 @@ module Metaclean
69
41
  raise Error, "Could not parse ExifTool output: #{e.message}"
70
42
  end
71
43
 
72
- # ExifTool labels its -j output UTF-8, but binary/odd tag values (UserComment,
73
- # MakerNotes fragments, corrupt or hostile files) can carry invalid bytes. A
74
- # later gsub (Display.format_value) raises on an invalid-encoding String and
75
- # would crash the whole run, so replace bad bytes up front. This hash is only
76
- # used for display/diff/residual checks the actual strip operates on the
77
- # file via the tools so scrubbing is safe.
44
+ def read_error_detail(out, err)
45
+ return err.strip unless err.strip.empty?
46
+
47
+ data = JSON.parse(out)
48
+ entry = data.is_a?(Array) ? data.first : nil
49
+ detail = entry && (entry['ExifTool:Error'] || entry['Error'])
50
+ detail ? detail.to_s.strip : 'unknown error'
51
+ rescue JSON::ParserError
52
+ 'unknown error'
53
+ end
54
+
78
55
  def scrub_encoding(obj)
79
56
  case obj
80
57
  when String then obj.valid_encoding? ? obj : obj.scrub
@@ -84,35 +61,27 @@ module Metaclean
84
61
  end
85
62
  end
86
63
 
87
- # ExifTool can READ many formats it cannot WRITE, and mat2 owns the strip for
88
- # them: the ZIP-based documents (docx/xlsx/pptx/odt/ods/odp/odg/odf/epub) and
89
- # the RIFF containers (avi/wav). ExifTool announces the inability with one of
90
- # a few phrasings — "writing of X files is not yet supported", "does not yet
91
- # support writing of …", or "Can't currently write RIFF … files" — so we
92
- # match all of them. strip! returns :unsupported in these cases so the runner
93
- # treats it as a soft skip (mat2 does the actual strip), NOT a pipeline
94
- # failure that would wrongly pin an already-clean file at :unverified. This is
95
- # safe because the post-strip residual re-read still gates the :cleaned status.
96
- WRITE_UNSUPPORTED_RE = /not yet support|can't currently write|writing of .* files/i
97
-
98
- # Removes every removable tag, in place. Returns true on success,
99
- # :unsupported when ExifTool cannot write the format, and raises on failure.
100
- #
101
- # `-all=` sets every tag to empty, which deletes them. `-overwrite_original`
102
- # makes ExifTool replace the file directly instead of writing `file_original`
103
- # next to it. `-api largefilesupport=1` lets files larger than 4 GB through.
64
+ WRITE_UNSUPPORTED_RE = /
65
+ writing\sof\s(?:this\stype\sof\s|[^\s]+\s)?files?\sis\snot\s(?:yet\s)?supported |
66
+ can't\scurrently\swrite |
67
+ does\snot\syet\ssupport\swriting
68
+ /ix
69
+
70
+ PRESERVE_TAGS = %w[Orientation ICC_Profile].freeze
71
+
104
72
  def strip!(path, also_delete: [])
105
- # `-all=` clears metadata, but for TIFF/DNG ExifTool refuses to delete the
106
- # IFD0 directory and leaves its tags (Artist, Software, …) behind. So we
107
- # ALSO delete the known privacy tags by name and clear the GPS group: both
108
- # are no-ops where `-all=` already removed them (e.g. JPEG), but they make
109
- # the strip complete AND lossless (no re-encode) for IFD0-preserving formats.
73
+ FileOps.regular_stat!(path)
110
74
  args = ['exiftool', '-all=', '-gps:all=']
111
75
  also_delete.each { |tag| args << "-#{tag}=" }
76
+ args.concat(['-tagsfromfile', '@'])
77
+ PRESERVE_TAGS.each { |tag| args << "-#{tag}" }
112
78
  args.concat(['-overwrite_original', '-q', '-q', '-api', 'largefilesupport=1', Metaclean.safe_path(path)])
113
79
 
114
80
  _out, err, status = Metaclean.capture3(*args)
115
- return true if status.success?
81
+ if status.success?
82
+ FileOps.validate_output!(path)
83
+ return true
84
+ end
116
85
  return :unsupported if err.match?(WRITE_UNSUPPORTED_RE)
117
86
 
118
87
  raise Error, "ExifTool strip failed: #{err.strip}"