metaclean 4.0.1 → 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.
@@ -1,45 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The "policy" module: which tools to run for which file, and what counts as
4
- # privacy-relevant if it survives a clean.
5
- #
6
- # Keeping this logic in its own file means the runner doesn't need to know
7
- # about formats — it just asks Strategy.tools_for(path) and runs whatever
8
- # comes back.
9
-
10
3
  module Metaclean
11
4
  module Strategy
12
- # Group-name PREFIXES treated as privacy-bearing. Matching whole families by
13
- # prefix keeps the residual check fail-closed instead of an exact allowlist
14
- # that silently misses variants:
15
- # GPS* — GPS plus any sub-group
16
- # XMP-* — every XMP namespace (XMP-exif GPS, XMP-mwg-rs face/person
17
- # names, XMP-xmpMM DocumentID, XMP-iptcExt, …)
18
- # MakerNotes*, IPTC*
19
- # IFD1 — the embedded thumbnail IFD; a surviving thumbnail can carry
20
- # the original's full EXIF+GPS
21
- # Over-flagging here is deliberate: for a privacy tool a false "still
22
- # present" is far cheaper than a false "Cleaned". (ICC colour-profile groups
23
- # are intentionally NOT flagged — a colour profile isn't PII; any genuinely
24
- # identifying field such as Copyright is still caught by PRIVACY_TAGS below.)
25
5
  PRIVACY_GROUP_PREFIXES = %w[GPS XMP- MakerNotes IPTC IFD1].freeze
26
6
 
27
- # Formats ExifTool can't WRITE, so it leaves document-internal metadata only
28
- # mat2's rebuild removes (and can't re-read to verify). If mat2 won't run for
29
- # one of these, the runner warns coverage is reduced rather than reporting a
30
- # confident "Cleaned". (PDF is NOT here: ExifTool writes PDF metadata and qpdf
31
- # rebuilds the file, so PDF is fully handled and verifiable without mat2.)
32
- MAT2_ESSENTIAL = %w[docx xlsx pptx odt ods odp odg odf epub].freeze
33
-
34
- # Specific tag NAMES (regardless of group) we never want to leak.
35
- # If exiftool reports e.g. "EXIF:Artist" we still flag it because of the
36
- # tag-name match, not the group. exiftool's `-all=` normally strips these,
37
- # so this list is a fail-closed BACKSTOP: if any survive a strip we'd rather
38
- # over-warn than report a confident "Cleaned".
7
+ MAT2_ESSENTIAL = %w[
8
+ docx xlsx pptx odt ods odp odg odf odc odi epub
9
+ zip tar torrent html htm xhtml xht ncx
10
+ ].freeze
11
+
39
12
  PRIVACY_TAGS = %w[
40
13
  Artist Author Creator Copyright Rights
41
14
  By-line By-lineTitle Credit Source Contact OwnerName
42
15
  CameraOwnerName SerialNumber InternalSerialNumber LensSerialNumber
16
+ CameraSerialNumber UniqueCameraModel LocalizedCameraModel
17
+ RawDataUniqueID OriginalRawFileName OriginalRawFileData
18
+ OriginalRawFileDigest RawImageDigest NewRawImageDigest
19
+ CameraCalibrationSignature ProfileCalibrationSignature
20
+ ProfileName ProfileCopyright AsShotProfileName
43
21
  Software HostComputer ProcessingSoftware
44
22
  ImageDescription UserComment
45
23
  LastModifiedBy LastSavedBy LastAuthor
@@ -49,62 +27,49 @@ module Metaclean
49
27
  XPAuthor XPComment XPSubject XPKeywords XPTitle Comment
50
28
  ].freeze
51
29
 
52
- # File extensions where mat2 is meaningfully stricter than ExifTool and
53
- # should run first. For other formats, ExifTool is the broader expert.
54
- # (mkv/webm are NOT here — see FFMPEG_FORMATS; no mat2/ExifTool path writes
55
- # Matroska.)
56
30
  MAT2_PREFERRED = %w[
57
- docx xlsx pptx odt ods odp odg odf epub png svg
31
+ docx xlsx pptx odt ods odp odg odf odc odi epub png svg svgz
58
32
  mp4 avi
59
33
  ].freeze
60
34
 
61
- # Matroska containers. ExifTool is read-only for them and mat2 has no
62
- # Matroska parser, so neither can strip mkv/webm. ffmpeg is the only tool in
63
- # the set that can — it remuxes the container dropping all metadata while
64
- # copying every stream verbatim (lossless, no re-encode).
35
+ RESIDUAL_IGNORED_GROUPS = %w[System ExifTool Composite].freeze
36
+
37
+ ICC_PRIVACY_TAGS = %w[
38
+ ProfileDescription ProfileDescriptionML ProfileCopyright ProfileCreator
39
+ ProfileCMMType DeviceManufacturer DeviceModel DeviceMfgDesc DeviceModelDesc
40
+ ].freeze
41
+
42
+ ICC_STANDARD_DESCRIPTIONS = [
43
+ 'srgb iec61966-2.1', 'srgb', 'srgb iec61966-2-1 black scaled',
44
+ 'srgb iec61966-2-1 no black scaling', 'display p3', 'dci-p3', 'p3-d65 (display)',
45
+ 'adobe rgb (1998)', 'prophoto rgb', 'romm rgb: iso 22028-2:2013',
46
+ 'apple rgb', 'colormatch rgb', 'camera rgb profile',
47
+ 'apple wide color sharing profile', 'linear rgb profile',
48
+ 'generic rgb profile', 'generic gray profile', 'generic gray gamma 2.2 profile',
49
+ 'generic lab profile', 'generic cmyk profile', 'generic xyz profile',
50
+ 'gray gamma 2.2', 'dot gain 20%',
51
+ 'rec. itu-r bt.709-5', 'hdtv (rec. 709)', 'sdtv (rec. 601)'
52
+ ].freeze
53
+
65
54
  FFMPEG_FORMATS = %w[mkv webm].freeze
66
55
 
67
- # Raster formats mat2 cannot strip without DAMAGING the file: it rebuilds via
68
- # Pillow, which recompresses JPEG/WebP (visible quality loss — a clean
69
- # wallpaper drops ~65% in size with no metadata to remove) and downconverts
70
- # TIFF (16-bit → 8-bit). ExifTool strips all of these completely and IN PLACE
71
- # (pixels byte-identical), so ExifTool owns them and mat2 is skipped —
72
- # cleaning metadata must never silently damage the file.
73
- MAT2_DEGRADES = %w[jpg jpeg webp tif tiff].freeze
56
+ MAT2_DEGRADES = %w[jpg jpe jpeg webp tif tiff heic].freeze
74
57
 
75
58
  module_function
76
59
 
77
- # Returns an ordered list of tool symbols (e.g. `[:mat2, :exiftool, :qpdf]`)
78
- # to run on `path`. The runner executes them in order; if one fails or
79
- # is skipped, the next still runs. The three tools are always used together
80
- # for maximum coverage — there is no per-tool opt-out; a tool that isn't
81
- # installed is simply left out (the `.available?`/`.supports?` checks).
82
60
  def tools_for(path)
83
61
  ext = Metaclean.ext_of(path)
84
62
  tools = []
85
63
 
86
64
  if ext == 'pdf'
87
- # mat2 cleans PDFs by RASTERIZING every page (text → images): it destroys
88
- # the text layer and balloons the file (~35×). So PDFs skip mat2 and use:
89
- # exiftool → strips the Info dictionary + XMP (Author, Title, Producer…)
90
- # qpdf → rebuilds the file, dropping unreferenced objects / old revisions
91
- # Both are lossless and leave the text intact. (PDF JS/macros are out of
92
- # scope — see README.)
93
65
  tools << :exiftool
94
66
  tools << :qpdf if Qpdf.available?
95
67
  elsif FFMPEG_FORMATS.include?(ext)
96
- # Matroska (mkv/webm): ffmpeg is the ONLY tool that can clean these.
97
- # ExifTool still re-reads the result afterwards, so the residual check
98
- # (the false-clean backstop) is not blind.
99
68
  tools << :ffmpeg if Ffmpeg.available?
100
69
  elsif MAT2_PREFERRED.include?(ext) && Mat2.available?
101
- # Office docs, modern image/video containers — mat2 leads.
102
70
  tools << :mat2
103
71
  tools << :exiftool
104
72
  else
105
- # Everything else (JPEG, MP3, RAW, …) — ExifTool has the broadest coverage.
106
- # mat2 still adds coverage for many, but NOT for rasters it would damage
107
- # (MAT2_DEGRADES) — there ExifTool's in-place strip is complete and lossless.
108
73
  tools << :exiftool
109
74
  tools << :mat2 if Mat2.supports?(path) && !MAT2_DEGRADES.include?(ext)
110
75
  end
@@ -112,62 +77,72 @@ module Metaclean
112
77
  tools
113
78
  end
114
79
 
115
- # Looks at metadata read AFTER cleaning and returns the entries that
116
- # still look privacy-relevant. The runner uses this for the "still
117
- # present" warning at the end of each file.
118
- #
119
- # Why both group-match and tag-match? Tag names can appear under
120
- # different groups depending on the format (e.g. "Author" in PDF vs
121
- # "Artist" in EXIF). Combining the two keeps coverage broad without
122
- # having to enumerate every {group, tag} pair.
123
- def privacy_residual(meta)
80
+ def privacy_residual(meta, allow_icc_metadata: false)
81
+ standard_icc = standard_icc_profile?(meta)
124
82
  meta.select do |k, v|
125
- # Skip SourceFile and the System/File/etc. groups — not user metadata.
126
- next false unless Display.embedded_key?(k)
83
+ key = k.to_s
84
+ next false if key == 'SourceFile'
127
85
 
128
- # ExifTool keys look like "GPS:GPSLatitude". Split on the first ":";
129
- # no "Group:" prefix means the whole key is the tag name.
130
- group, tag = k.to_s.split(':', 2)
86
+ group, tag = key.split(':', 2)
131
87
  name = tag.nil? ? group.to_s : tag
88
+ next false if RESIDUAL_IGNORED_GROUPS.include?(group.to_s)
89
+
90
+ icc_privacy = group.to_s.start_with?('ICC') && ICC_PRIVACY_TAGS.include?(name)
91
+ if group.to_s.start_with?('ICC')
92
+ next false if allow_icc_metadata || standard_icc
93
+ next false unless icc_privacy
94
+ end
132
95
 
133
- # A zeroed/empty value is not a leak for un-removable container atoms like
134
- # QuickTime:CreateDate (deletable only by zeroing, "0000:00:00 …") — without
135
- # this every video would fail the gate on an already-zeroed date. GPS is the
136
- # exception: 0,0 is a REAL location (Null Island) and a coordinate ExifTool
137
- # reports as 0 (or null) must still be caught, so the blank exemption NEVER
138
- # applies to GPS-family entries — the whole point of the fail-closed backstop.
139
96
  gps = group.to_s.start_with?('GPS') || name.start_with?('GPS')
140
- next false if !gps && blank_value?(v)
97
+ next false if !gps && exempt_blank?(name, v)
141
98
 
142
- privacy_group?(group) || privacy_tag?(name)
99
+ icc_privacy || privacy_group?(group) || privacy_tag?(name)
143
100
  end
144
101
  end
145
102
 
146
- # True when a value carries no information: empty, or only zeros plus date/time
147
- # punctuation and the "Z" (UTC) marker — e.g. "0000:00:00 00:00:00", or the ASF
148
- # variant "0000:00:00 00:00:00Z" that mat2 writes into WMV's mandatory date
149
- # field. Only the digit 0 is stripped (never 1-9), so a real value like
150
- # "59.9139", "Jane Doe", or a real "2024:..." date keeps other characters and
151
- # is NOT blank. (GPS is exempt from this check entirely — see privacy_residual.)
103
+ def standard_icc_profile?(meta)
104
+ meta.any? do |k, v|
105
+ key = k.to_s
106
+ next false unless key.start_with?('ICC') && key.split(':', 2).last == 'ProfileDescription'
107
+
108
+ ICC_STANDARD_DESCRIPTIONS.include?(normalize_icc_value(v))
109
+ end
110
+ end
111
+
112
+ def normalize_icc_value(value)
113
+ value.to_s.strip.downcase.gsub(/\s+/, ' ')
114
+ end
115
+
116
+ def exempt_blank?(name, value)
117
+ return true if value.to_s.strip.empty?
118
+
119
+ date_like_tag?(name) && blank_value?(value)
120
+ end
121
+
122
+ def date_like_tag?(name)
123
+ n = name.to_s
124
+ n.end_with?('Date') || n.start_with?('DateTime')
125
+ end
126
+
152
127
  def blank_value?(value)
153
128
  s = value.to_s
154
129
  s.strip.empty? || s.gsub(/[Z0\s:.+-]/, '').empty?
155
130
  end
156
131
 
157
- # A group is privacy-bearing if it matches one of the family prefixes
158
- # (GPS, XMP-, MakerNotes, IPTC, IFD1).
159
132
  def privacy_group?(group)
160
133
  PRIVACY_GROUP_PREFIXES.any? { |p| group.to_s.start_with?(p) }
161
134
  end
162
135
 
163
- # A tag is privacy-bearing if it's in the exact list OR is any GPS* tag
164
- # (GPSLatitude/GPSLongitude/GPSPosition/… regardless of group).
165
136
  def privacy_tag?(tag)
166
137
  t = tag.to_s
167
- PRIVACY_TAGS.include?(t) || t.start_with?('GPS')
138
+ return true if PRIVACY_TAGS.include?(t) || t.start_with?('GPS')
139
+ return true if t.match?(/SerialNumber\z/i)
140
+ return true if t.match?(/\AOriginalRawFile(?:Name|Data|Digest)\z/i)
141
+ return true if t.match?(/\A(?:RawDataUniqueID|RawImageDigest|NewRawImageDigest)\z/i)
142
+
143
+ false
168
144
  end
169
145
 
170
- # Does this path need mat2 for adequate coverage? (See MAT2_ESSENTIAL.)
171
146
  def mat2_essential?(path)
172
147
  MAT2_ESSENTIAL.include?(Metaclean.ext_of(path))
173
148
  end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Single source of truth for the program's version.
4
- # Both the gemspec and `metaclean --version` read from here, so we only have
5
- # one place to bump.
6
-
7
3
  module Metaclean
8
- VERSION = '4.0.1'
4
+ VERSION = '4.2.0'
9
5
  end
data/lib/metaclean.rb CHANGED
@@ -1,71 +1,125 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Library entry point. require order matters: dependencies before dependents.
3
+ require 'open3'
4
4
 
5
5
  require 'metaclean/version'
6
6
  require 'metaclean/display'
7
+ require 'metaclean/file_ops'
7
8
  require 'metaclean/exiftool'
8
9
  require 'metaclean/mat2'
9
10
  require 'metaclean/qpdf'
10
11
  require 'metaclean/ffmpeg'
11
12
  require 'metaclean/strategy'
13
+ require 'metaclean/discovery'
14
+ require 'metaclean/committer'
12
15
  require 'metaclean/runner'
13
16
  require 'metaclean/cli'
14
17
 
15
18
  module Metaclean
16
19
  class Error < StandardError; end
17
20
 
18
- # Raised by ensure_tools! when any of the four required external tools is not
19
- # on PATH. metaclean runs ExifTool, mat2, qpdf and ffmpeg together and refuses
20
- # to run without all of them.
21
21
  class ToolsMissing < Error; end
22
22
 
23
- # A path beginning with "-" is misread as an *option* by the tools we shell
24
- # out to — e.g. exiftool's `-config FILE` loads and runs arbitrary Perl.
25
- # Open3 argument arrays bypass the shell, but NOT the invoked tool's own
26
- # option parser. Prefixing a leading-dash relative path with "./" makes it
27
- # unambiguously a filename to every tool. Absolute paths and normal names
28
- # pass through untouched. Used at every shell-out boundary.
29
23
  def self.safe_path(path)
30
24
  s = path.to_s
31
25
  s.start_with?('-') ? File.join('.', s) : s
32
26
  end
33
27
 
34
- # Lower-cased, dot-stripped extension used for FORMAT ROUTING decisions
35
- # (Strategy#tools_for, Strategy#mat2_essential?, Mat2.supports?). One
36
- # definition so every routing path normalizes the extension identically —
37
- # a future tweak (double extensions, locale-safe downcasing) lands once.
28
+ COMMAND_TIMEOUT = 120
29
+ MAX_OUTPUT_BYTES = 64 * 1024 * 1024
30
+ PROBE_TIMEOUT = 10
31
+ PROBE_MAX_OUTPUT_BYTES = 1024 * 1024
32
+ READ_CHUNK = 64 * 1024
33
+
34
+ def self.command_timeout
35
+ override = ENV['METACLEAN_TIMEOUT'].to_i
36
+ override.positive? ? override : COMMAND_TIMEOUT
37
+ end
38
+
39
+ def self.capture3(*cmd, timeout: command_timeout, max_output: MAX_OUTPUT_BYTES)
40
+ Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
41
+ out_t = err_t = deadline = nil
42
+ begin
43
+ stdin.close
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
45
+ out_t = read_capped(stdout, max_output, wait_thr)
46
+ err_t = read_capped(stderr, max_output, wait_thr)
47
+
48
+ if wait_thr.join(timeout).nil?
49
+ kill_group(wait_thr)
50
+ drain_readers(out_t, err_t, deadline)
51
+ raise Error, "#{cmd.first} timed out after #{timeout}s"
52
+ end
53
+
54
+ unless drain_readers(out_t, err_t, deadline)
55
+ kill_group(wait_thr)
56
+ drain_readers(out_t, err_t, deadline)
57
+ raise Error, "#{cmd.first} timed out after #{timeout}s"
58
+ end
59
+
60
+ out, out_over = out_t.value
61
+ err, err_over = err_t.value
62
+ raise Error, "#{cmd.first} exceeded the #{max_output}-byte output limit" if out_over || err_over
63
+
64
+ [out, err, wait_thr.value]
65
+ rescue Interrupt
66
+ kill_group(wait_thr)
67
+ drain_readers(out_t, err_t, deadline) if out_t && err_t && deadline
68
+ raise
69
+ end
70
+ end
71
+ end
72
+
73
+ def self.drain_readers(out_t, err_t, deadline)
74
+ [out_t, err_t].all? do |t|
75
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ remaining = 0.1 if remaining < 0.1
77
+ !t.join(remaining).nil?
78
+ end
79
+ end
80
+
81
+ def self.read_capped(io, limit, wait_thr)
82
+ Thread.new do
83
+ buf = +''
84
+ over = false
85
+ while (chunk = io.read(READ_CHUNK))
86
+ next if over
87
+
88
+ buf << chunk
89
+ next unless buf.bytesize > limit
90
+
91
+ over = true
92
+ buf = buf.byteslice(0, limit)
93
+ kill_group(wait_thr)
94
+ end
95
+ [buf, over]
96
+ end
97
+ end
98
+
99
+ def self.kill_group(wait_thr)
100
+ Process.kill('-TERM', wait_thr.pid)
101
+ Process.kill('-KILL', wait_thr.pid) unless wait_thr.join(2)
102
+ rescue Errno::ESRCH, Errno::EPERM
103
+ nil
104
+ end
105
+
38
106
  def self.ext_of(path)
39
107
  File.extname(path.to_s).downcase.delete('.')
40
108
  end
41
109
 
42
- # Marker embedded in every staging-temp filename (Runner, Ffmpeg, Qpdf) and
43
- # matched by Runner#skip?, so a leftover temp from an interrupted run is
44
- # ignored on a later directory scan. One literal keeps the producers and the
45
- # matcher from drifting (qpdf previously embedded a divergent
46
- # ".metaclean.qpdf.tmp." that didn't contain this marker).
47
110
  TMP_MARKER = '.metaclean.tmp.'
48
111
 
49
- # Suffix of the default "<name>_clean.<ext>" outputs. Runner#build_clean_path
50
- # writes it; CLEAN_OUTPUT_RE derives the loop-prevention match from it so the
51
- # producer and Runner#skip? can't disagree.
52
112
  CLEAN_SUFFIX = '_clean'
53
113
 
54
- # Matches our own "<name>_clean.<ext>" outputs (with optional "_N" collision
55
- # counter) so a recursive re-run doesn't re-clean them. Compiled once here,
56
- # in the module body that runs after the requires, so CLEAN_SUFFIX exists.
57
- CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(_\d+)?\.[^.]+\z/
114
+ CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(?:_\d+)?(?:\.[^.]+)?\z/
58
115
 
59
- # Preflight: all four tools must be installed. We run them together for full
60
- # coverage and to verify the strip, so a partial toolchain is not "good enough"
61
- # — bail with one clear message naming what's missing and how to install
62
- # everything. Called once by the CLI before any inspect/clean work.
63
- def self.ensure_tools!
116
+ def self.ensure_tools!(in_place: false)
64
117
  missing = []
65
118
  missing << 'exiftool' unless Exiftool.available?
66
119
  missing << 'mat2' unless Mat2.available?
67
120
  missing << 'qpdf' unless Qpdf.available?
68
121
  missing << 'ffmpeg' unless Ffmpeg.available?
122
+ missing << 'cp with metadata preservation' if in_place && !FileOps.metadata_copy_supported?
69
123
  return if missing.empty?
70
124
 
71
125
  raise ToolsMissing, <<~MSG
@@ -77,6 +131,8 @@ module Metaclean
77
131
  Fedora: sudo dnf install perl-Image-ExifTool mat2 qpdf ffmpeg
78
132
  Arch: sudo pacman -S perl-image-exiftool mat2 qpdf ffmpeg
79
133
  Windows: use WSL2 (https://learn.microsoft.com/windows/wsl/install) + the Debian/Ubuntu line
134
+
135
+ --in-place additionally requires the POSIX cp utility supplied by macOS or coreutils on Linux.
80
136
  MSG
81
137
  end
82
138
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metaclean
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.1
4
+ version: 4.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laurent Zogaj
@@ -21,12 +21,16 @@ extra_rdoc_files: []
21
21
  files:
22
22
  - LICENSE
23
23
  - README.md
24
+ - SECURITY.md
24
25
  - bin/metaclean
25
26
  - lib/metaclean.rb
26
27
  - lib/metaclean/cli.rb
28
+ - lib/metaclean/committer.rb
29
+ - lib/metaclean/discovery.rb
27
30
  - lib/metaclean/display.rb
28
31
  - lib/metaclean/exiftool.rb
29
32
  - lib/metaclean/ffmpeg.rb
33
+ - lib/metaclean/file_ops.rb
30
34
  - lib/metaclean/mat2.rb
31
35
  - lib/metaclean/qpdf.rb
32
36
  - lib/metaclean/runner.rb
@@ -38,6 +42,7 @@ licenses:
38
42
  metadata:
39
43
  allowed_push_host: https://rubygems.org
40
44
  bug_tracker_uri: https://github.com/26zl/metaclean/issues
45
+ documentation_uri: https://github.com/26zl/metaclean#readme
41
46
  source_code_uri: https://github.com/26zl/metaclean
42
47
  rubygems_mfa_required: 'true'
43
48
  rdoc_options: []
@@ -58,6 +63,7 @@ requirements:
58
63
  - mat2 (https://github.com/jvoisin/mat2) on PATH — required
59
64
  - qpdf (https://qpdf.sourceforge.io) on PATH — required
60
65
  - ffmpeg (https://ffmpeg.org) on PATH — required
66
+ - POSIX cp with metadata-preservation support — required for --in-place
61
67
  rubygems_version: 3.6.9
62
68
  specification_version: 4
63
69
  summary: CLI that strips file metadata with ExifTool, mat2, qpdf and ffmpeg.