metaclean 4.1.0 → 4.3.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,31 @@ 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
- no_color = ENV['NO_COLOR'].to_s
48
- @color = $stdout.tty? && no_color.empty?
49
- @color = true if ENV['FORCE_COLOR']
50
- @color
46
+ @color = if !ENV['NO_COLOR'].to_s.empty?
47
+ false
48
+ elsif !ENV['FORCE_COLOR'].to_s.empty?
49
+ true
50
+ else
51
+ $stdout.tty?
52
+ end
51
53
  end
52
54
 
53
- # Wrap text in a color, or pass it through plain when colors are off.
54
55
  def c(text, color)
55
56
  text = printable(text)
56
57
  return text unless color?
@@ -58,35 +59,30 @@ module Metaclean
58
59
  "#{COLORS[color]}#{text}#{COLORS[:reset]}"
59
60
  end
60
61
 
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
62
  def banner
67
63
  LOGO.each_line { |line| puts c(line.chomp, :red) }
68
64
  puts c(' strip EXIF · IPTC · XMP · GPS · ID3 — leave the file clean', :gray)
69
65
  end
70
66
 
71
67
  def header(text)
68
+ return if quiet?
69
+
72
70
  puts
73
71
  puts c('━' * 64, :gray)
74
72
  puts c(text, :bold)
75
73
  puts c('━' * 64, :gray)
76
74
  end
77
75
 
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
81
- def warning(text); puts c("⚠ #{text}", :yellow);end
76
+ def section(text); puts c("▸ #{text}", :cyan) unless quiet?; end
77
+ def info(text); puts c(" #{text}", :gray) unless quiet?; end
78
+ def success(text); puts c("✓ #{text}", :green) unless quiet?; end
79
+ def warning(text); warn c("⚠ #{text}", :yellow);end
82
80
 
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
81
  def error(text); c("✗ #{text}", :red); end
86
82
 
87
- # Prints a metadata Hash as a grouped, indented table.
88
- # `only_embedded:` filters out the System/File/etc. noise.
89
83
  def metadata_table(meta, only_embedded: false)
84
+ return if quiet?
85
+
90
86
  rows = meta.reject { |k, _| k == 'SourceFile' }
91
87
  rows = rows.select { |k, _| embedded_key?(k) } if only_embedded
92
88
 
@@ -95,120 +91,110 @@ module Metaclean
95
91
  return
96
92
  end
97
93
 
98
- # Group "GPS:*", "EXIF:*", … each into its own labeled sub-table.
99
94
  grouped = rows.group_by { |k, _| group_of(k) }
100
95
  grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
101
96
  puts c(" [#{group}]", :magenta)
102
97
  pairs.sort_by { |k, _| k.to_s }.each do |k, v|
103
98
  tag = k.to_s.split(':', 2).last
104
- line = format(' %-38s %s', truncate(tag, 38), truncate(format_value(v), 60))
99
+ line = format(' %-38s %s', truncate(tag, 38), truncate(visible_value(v), 60))
105
100
  puts c(line, :dim)
106
101
  end
107
102
  end
108
103
  end
109
104
 
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
105
  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
106
+ return if quiet?
107
+
108
+ removed, changed, kept = classify_diff(before, after)
109
+ render_removed(removed)
110
+ render_changed(changed)
111
+ render_kept(kept)
112
+
113
+ if [removed, changed, kept].all?(&:empty?)
114
+ info 'Nothing to strip — file already clean.'
115
+ elsif removed.empty? && changed.empty?
116
+ info 'No tags were removed see "Still present" above.'
130
117
  end
118
+ end
131
119
 
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)}"
120
+ def classify_diff(before, after)
121
+ rows = [[], [], []]
122
+ keys = (before.keys + after.keys).uniq.select { |key| embedded_key?(key) }
123
+ keys.sort.each do |key|
124
+ old_value = before[key]
125
+ new_value = after[key]
126
+ if new_value.nil? && !old_value.nil?
127
+ rows[0] << [key, old_value]
128
+ elsif !old_value.nil? && new_value != old_value
129
+ rows[1] << [key, old_value, new_value]
130
+ elsif !old_value.nil?
131
+ rows[2] << [key, old_value]
136
132
  end
137
133
  end
134
+ rows
135
+ end
138
136
 
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
137
+ def render_removed(rows)
138
+ return if rows.empty?
139
+
140
+ section "Removed (#{rows.size})"
141
+ rows.each do |key, value|
142
+ puts " #{c('-', :red)} #{c(key, :red)} #{c(truncate(visible_value(value), 60), :gray)}"
146
143
  end
144
+ end
147
145
 
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
146
+ def render_changed(rows)
147
+ return if rows.empty?
148
+
149
+ section "Changed (#{rows.size})"
150
+ rows.each do |key, old_value, new_value|
151
+ puts " #{c('~', :yellow)} #{c(key, :yellow)}"
152
+ puts " #{c('-', :red)} #{truncate(visible_value(old_value), 60)}"
153
+ puts " #{c('+', :green)} #{truncate(visible_value(new_value), 60)}"
153
154
  end
155
+ end
154
156
 
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.'
157
+ def render_kept(rows)
158
+ return if rows.empty?
159
+
160
+ section "Still present (#{rows.size})"
161
+ rows.each do |key, value|
162
+ puts " #{c('=', :gray)} #{c(key, :gray)} #{c(truncate(visible_value(value), 60), :gray)}"
159
163
  end
160
164
  end
161
165
 
162
- # Group name out of "Group:Tag" (split caps at 2 so a ":" in the value is safe).
163
166
  def group_of(key)
164
167
  key.to_s.split(':', 2).first.to_s
165
168
  end
166
169
 
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
170
  def embedded_key?(key)
173
171
  key != 'SourceFile' && !NON_METADATA_GROUPS.include?(group_of(key))
174
172
  end
175
173
 
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
174
  def format_value(v)
180
175
  case v
181
176
  when Hash, Array then printable(v.inspect)
182
177
  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
178
  s = printable(v)
187
179
  s.gsub(/\s+/, ' ')
188
180
  end
189
181
  end
190
182
 
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).
183
+ def visible_value(value)
184
+ redact_values? ? '[redacted]' : format_value(value)
185
+ end
186
+
196
187
  def printable(text)
197
188
  s = text.to_s
198
189
  s = s.scrub unless s.valid_encoding?
199
190
  s.gsub(/[[:cntrl:]]/, ' ')
200
191
  end
201
192
 
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
193
  def truncate(s, n)
206
194
  s = s.to_s
207
195
  s.length > n ? "#{s[0, n - 1]}…" : s
208
196
  end
209
197
 
210
- # How many "real" embedded tags are there? Used for the
211
- # "Before (24 embedded tags) → After (0)" summary line.
212
198
  def count_embedded(meta)
213
199
  meta.keys.count { |k| embedded_key?(k) }
214
200
  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}"