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.
- checksums.yaml +4 -4
- data/README.md +91 -29
- data/SECURITY.md +59 -0
- data/bin/metaclean +0 -1
- data/lib/metaclean/cli.rb +46 -43
- data/lib/metaclean/committer.rb +130 -0
- data/lib/metaclean/discovery.rb +127 -0
- data/lib/metaclean/display.rb +73 -90
- data/lib/metaclean/exiftool.rb +34 -65
- data/lib/metaclean/ffmpeg.rb +96 -50
- data/lib/metaclean/file_ops.rb +214 -0
- data/lib/metaclean/mat2.rb +28 -77
- data/lib/metaclean/qpdf.rb +46 -46
- data/lib/metaclean/runner.rb +126 -432
- data/lib/metaclean/strategy.rb +75 -100
- data/lib/metaclean/version.rb +1 -5
- data/lib/metaclean.rb +53 -73
- metadata +7 -1
data/lib/metaclean/strategy.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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 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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
next false
|
|
83
|
+
key = k.to_s
|
|
84
|
+
next false if key == 'SourceFile'
|
|
127
85
|
|
|
128
|
-
|
|
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 &&
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
data/lib/metaclean/version.rb
CHANGED
data/lib/metaclean.rb
CHANGED
|
@@ -1,90 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Library entry point. require order matters: dependencies before dependents.
|
|
4
|
-
|
|
5
3
|
require 'open3'
|
|
6
4
|
|
|
7
5
|
require 'metaclean/version'
|
|
8
6
|
require 'metaclean/display'
|
|
7
|
+
require 'metaclean/file_ops'
|
|
9
8
|
require 'metaclean/exiftool'
|
|
10
9
|
require 'metaclean/mat2'
|
|
11
10
|
require 'metaclean/qpdf'
|
|
12
11
|
require 'metaclean/ffmpeg'
|
|
13
12
|
require 'metaclean/strategy'
|
|
13
|
+
require 'metaclean/discovery'
|
|
14
|
+
require 'metaclean/committer'
|
|
14
15
|
require 'metaclean/runner'
|
|
15
16
|
require 'metaclean/cli'
|
|
16
17
|
|
|
17
18
|
module Metaclean
|
|
18
19
|
class Error < StandardError; end
|
|
19
20
|
|
|
20
|
-
# Raised by ensure_tools! when any of the four required external tools is not
|
|
21
|
-
# on PATH. metaclean runs ExifTool, mat2, qpdf and ffmpeg together and refuses
|
|
22
|
-
# to run without all of them.
|
|
23
21
|
class ToolsMissing < Error; end
|
|
24
22
|
|
|
25
|
-
# A path beginning with "-" is misread as an *option* by the tools we shell
|
|
26
|
-
# out to — e.g. exiftool's `-config FILE` loads and runs arbitrary Perl.
|
|
27
|
-
# Open3 argument arrays bypass the shell, but NOT the invoked tool's own
|
|
28
|
-
# option parser. Prefixing a leading-dash relative path with "./" makes it
|
|
29
|
-
# unambiguously a filename to every tool. Absolute paths and normal names
|
|
30
|
-
# pass through untouched. Used at every shell-out boundary.
|
|
31
23
|
def self.safe_path(path)
|
|
32
24
|
s = path.to_s
|
|
33
25
|
s.start_with?('-') ? File.join('.', s) : s
|
|
34
26
|
end
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
# or hostile file. Every OPERATIONAL shell-out (read/strip/rebuild) goes through
|
|
38
|
-
# this instead of Open3.capture3 so one bad file is bounded on BOTH axes — by
|
|
39
|
-
# wall-clock (COMMAND_TIMEOUT) and by captured bytes (MAX_OUTPUT_BYTES) — rather
|
|
40
|
-
# than hanging or exhausting memory and taking the whole batch with it. The
|
|
41
|
-
# quick availability probes (`-ver`/`--version`) stay on plain capture3: fixed
|
|
42
|
-
# args, no file input, nothing to hang on.
|
|
43
|
-
COMMAND_TIMEOUT = 120 # seconds
|
|
44
|
-
# Per stream (stdout AND stderr). Far above any legitimate output from the
|
|
45
|
-
# tools' invocations here (metadata JSON / `-q` strips / `-v error` muxes), so
|
|
46
|
-
# tripping it means a runaway, not a real result.
|
|
28
|
+
COMMAND_TIMEOUT = 120
|
|
47
29
|
MAX_OUTPUT_BYTES = 64 * 1024 * 1024
|
|
30
|
+
PROBE_TIMEOUT = 10
|
|
31
|
+
PROBE_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
48
32
|
READ_CHUNK = 64 * 1024
|
|
49
33
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
54
40
|
Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
66
|
kill_group(wait_thr)
|
|
64
|
-
out_t
|
|
65
|
-
|
|
66
|
-
raise Error, "#{cmd.first} timed out after #{timeout}s"
|
|
67
|
+
drain_readers(out_t, err_t, deadline) if out_t && err_t && deadline
|
|
68
|
+
raise
|
|
67
69
|
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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?
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
|
|
77
|
-
# Read an IO into a String in a thread, but stop accumulating once it passes
|
|
78
|
-
# `limit` bytes — and kill the command then, so a flooding stream is cut off
|
|
79
|
-
# promptly instead of waiting out the full timeout. After the cap is hit it
|
|
80
|
-
# keeps draining (discarding) so the dying child isn't blocked on a full pipe.
|
|
81
|
-
# Returns [string, overflowed?].
|
|
82
81
|
def self.read_capped(io, limit, wait_thr)
|
|
83
82
|
Thread.new do
|
|
84
83
|
buf = +''
|
|
85
84
|
over = false
|
|
86
85
|
while (chunk = io.read(READ_CHUNK))
|
|
87
|
-
next if over
|
|
86
|
+
next if over
|
|
88
87
|
|
|
89
88
|
buf << chunk
|
|
90
89
|
next unless buf.bytesize > limit
|
|
@@ -97,51 +96,30 @@ module Metaclean
|
|
|
97
96
|
end
|
|
98
97
|
end
|
|
99
98
|
|
|
100
|
-
# SIGTERM the child's whole process group — pgroup:true made the child the
|
|
101
|
-
# group leader, so any helpers it forked are signalled too — escalating to
|
|
102
|
-
# SIGKILL if it ignores TERM. A negative pid targets the group.
|
|
103
99
|
def self.kill_group(wait_thr)
|
|
104
100
|
Process.kill('-TERM', wait_thr.pid)
|
|
105
101
|
Process.kill('-KILL', wait_thr.pid) unless wait_thr.join(2)
|
|
106
102
|
rescue Errno::ESRCH, Errno::EPERM
|
|
107
|
-
nil
|
|
103
|
+
nil
|
|
108
104
|
end
|
|
109
105
|
|
|
110
|
-
# Lower-cased, dot-stripped extension used for FORMAT ROUTING decisions
|
|
111
|
-
# (Strategy#tools_for, Strategy#mat2_essential?, Mat2.supports?). One
|
|
112
|
-
# definition so every routing path normalizes the extension identically —
|
|
113
|
-
# a future tweak (double extensions, locale-safe downcasing) lands once.
|
|
114
106
|
def self.ext_of(path)
|
|
115
107
|
File.extname(path.to_s).downcase.delete('.')
|
|
116
108
|
end
|
|
117
109
|
|
|
118
|
-
# Marker embedded in every staging-temp filename (Runner, Ffmpeg, Qpdf) and
|
|
119
|
-
# matched by Runner#skip?, so a leftover temp from an interrupted run is
|
|
120
|
-
# ignored on a later directory scan. One literal keeps the producers and the
|
|
121
|
-
# matcher from drifting (qpdf previously embedded a divergent
|
|
122
|
-
# ".metaclean.qpdf.tmp." that didn't contain this marker).
|
|
123
110
|
TMP_MARKER = '.metaclean.tmp.'
|
|
124
111
|
|
|
125
|
-
# Suffix of the default "<name>_clean.<ext>" outputs. Runner#build_clean_path
|
|
126
|
-
# writes it; CLEAN_OUTPUT_RE derives the loop-prevention match from it so the
|
|
127
|
-
# producer and Runner#skip? can't disagree.
|
|
128
112
|
CLEAN_SUFFIX = '_clean'
|
|
129
113
|
|
|
130
|
-
|
|
131
|
-
# counter) so a recursive re-run doesn't re-clean them. Compiled once here,
|
|
132
|
-
# in the module body that runs after the requires, so CLEAN_SUFFIX exists.
|
|
133
|
-
CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(_\d+)?\.[^.]+\z/
|
|
114
|
+
CLEAN_OUTPUT_RE = /#{Regexp.escape(CLEAN_SUFFIX)}(?:_\d+)?(?:\.[^.]+)?\z/
|
|
134
115
|
|
|
135
|
-
|
|
136
|
-
# coverage and to verify the strip, so a partial toolchain is not "good enough"
|
|
137
|
-
# — bail with one clear message naming what's missing and how to install
|
|
138
|
-
# everything. Called once by the CLI before any inspect/clean work.
|
|
139
|
-
def self.ensure_tools!
|
|
116
|
+
def self.ensure_tools!(in_place: false)
|
|
140
117
|
missing = []
|
|
141
118
|
missing << 'exiftool' unless Exiftool.available?
|
|
142
119
|
missing << 'mat2' unless Mat2.available?
|
|
143
120
|
missing << 'qpdf' unless Qpdf.available?
|
|
144
121
|
missing << 'ffmpeg' unless Ffmpeg.available?
|
|
122
|
+
missing << 'cp with metadata preservation' if in_place && !FileOps.metadata_copy_supported?
|
|
145
123
|
return if missing.empty?
|
|
146
124
|
|
|
147
125
|
raise ToolsMissing, <<~MSG
|
|
@@ -153,6 +131,8 @@ module Metaclean
|
|
|
153
131
|
Fedora: sudo dnf install perl-Image-ExifTool mat2 qpdf ffmpeg
|
|
154
132
|
Arch: sudo pacman -S perl-image-exiftool mat2 qpdf ffmpeg
|
|
155
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.
|
|
156
136
|
MSG
|
|
157
137
|
end
|
|
158
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.
|
|
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.
|