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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e4ebafb7621f428c5a0d0040c2487340e2dd22b32b831bf47c957027e4d0ca9
|
|
4
|
+
data.tar.gz: e9420536e480b319833996372e9d679285289e5afdfe3555466ff8167ebf5d7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c9b8b897058f4b86d9691d53f80f1045d78f1783861048591f6910b92cfca25a1f0e7ee531a91d22144ebf294c1240a4dd46a465412cff7c1d3dcd85d421a4f
|
|
7
|
+
data.tar.gz: 5dcdfbd96dd879ddb8c6e86f2c48b29ac43672eb5636ea2fb5e2a3260cac0bf9bad7644bb594d05834e1267850d95034ff3b4780b5a54af9e60b6cc321ea47fd
|
data/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A small Ruby CLI that strips metadata from almost any file —
|
|
|
19
19
|
images, audio, video, PDFs, Office documents — and shows a colored before/after
|
|
20
20
|
diff of exactly what was removed.
|
|
21
21
|
|
|
22
|
-
It wraps four
|
|
22
|
+
It wraps four external tools and routes each file to the right one:
|
|
23
23
|
|
|
24
24
|
- **ExifTool** — the broadest format coverage (EXIF, IPTC, XMP, GPS, ID3, …)
|
|
25
25
|
- **mat2** — stricter on `.docx` / `.png` and Office/OpenDocument files (rebuilds the file)
|
|
@@ -28,16 +28,19 @@ It wraps four battle-tested tools and routes each file to the right one:
|
|
|
28
28
|
|
|
29
29
|
## Why metaclean?
|
|
30
30
|
|
|
31
|
-
- **Verification-first:** it re-reads the cleaned file and
|
|
32
|
-
result
|
|
31
|
+
- **Verification-first:** it re-reads the cleaned file and only commits a
|
|
32
|
+
result whose final status is `cleaned`; failed, unsupported and unverified
|
|
33
|
+
candidates are deleted.
|
|
33
34
|
- **Safer defaults:** it writes `*_clean` copies by default; `--in-place` keeps a
|
|
34
35
|
`.bak` and asks for confirmation unless `--force` is set. Note the `.bak` is the
|
|
35
36
|
**original, with all its metadata** — delete or move the `.bak` files before
|
|
36
37
|
sharing an in-place-cleaned folder.
|
|
37
|
-
- **
|
|
38
|
-
|
|
39
|
-
- **
|
|
40
|
-
|
|
38
|
+
- **Content-preserving routing:** it keeps image orientation, avoids lossy
|
|
39
|
+
raster paths, and preserves Matroska chapters and languages.
|
|
40
|
+
- **Nested-content guard:** PDFs and Matroska files with attachments or cover
|
|
41
|
+
art fail closed until their embedded payloads can be independently cleaned.
|
|
42
|
+
- **Batch-friendly:** failed, unsupported or unverified files exit non-zero, so
|
|
43
|
+
scripts and CI do not mistake uncertainty for success.
|
|
41
44
|
|
|
42
45
|
## What it looks like
|
|
43
46
|
|
|
@@ -87,7 +90,6 @@ From a source checkout:
|
|
|
87
90
|
|
|
88
91
|
```bash
|
|
89
92
|
git clone https://github.com/26zl/metaclean.git && cd metaclean
|
|
90
|
-
chmod +x bin/metaclean
|
|
91
93
|
bundle install
|
|
92
94
|
bundle exec rake install
|
|
93
95
|
metaclean --version
|
|
@@ -142,8 +144,11 @@ metaclean --in-place photo.jpg
|
|
|
142
144
|
# Clean a whole folder, recursively, no prompts
|
|
143
145
|
metaclean -r --in-place --force ./vacation
|
|
144
146
|
|
|
145
|
-
# See what would change
|
|
147
|
+
# See what would change; a private temporary copy is removed afterwards
|
|
146
148
|
metaclean --dry-run photo.jpg
|
|
149
|
+
|
|
150
|
+
# Avoid writing private metadata values to a log
|
|
151
|
+
metaclean --redact-values photo.jpg
|
|
147
152
|
```
|
|
148
153
|
|
|
149
154
|
## Flags
|
|
@@ -151,41 +156,87 @@ metaclean --dry-run photo.jpg
|
|
|
151
156
|
| Flag | What it does |
|
|
152
157
|
| --- | --- |
|
|
153
158
|
| `--inspect` | Read-only — print metadata, never write |
|
|
154
|
-
| `--dry-run` | Simulate
|
|
159
|
+
| `--dry-run` | Simulate on a private mode-0700 temporary copy; keep no output |
|
|
155
160
|
| `-i`, `--in-place` | Overwrite originals (keeps a `<file>.bak` — the **original, metadata intact**; remove it before sharing) |
|
|
156
161
|
| `-r`, `--recursive` | Recurse into directories |
|
|
157
162
|
| `-f`, `--force` | Skip the confirmation prompt |
|
|
163
|
+
| `-q`, `--quiet` | Suppress normal output; warnings and errors remain |
|
|
164
|
+
| `--redact-values` | Hide metadata values in tables and diffs |
|
|
165
|
+
| `--show-values` | Show values even when stdout is redirected |
|
|
166
|
+
| `--allow-icc-metadata` | Keep a non-standard ICC profile's identifying text; recognized standard color spaces (sRGB, Display P3, …) clean without it |
|
|
158
167
|
| `-h`, `--help` | Show usage and exit |
|
|
159
168
|
| `-v`, `--version` | Show metaclean's version **and** the detected versions of exiftool/mat2/qpdf/ffmpeg (prints `not found` for any missing) |
|
|
160
169
|
|
|
170
|
+
Exit codes are `0` for a fully successful batch, `1` for failed, unsupported,
|
|
171
|
+
unverified or incomplete work, `2` for a missing toolchain, and `130` for
|
|
172
|
+
Ctrl-C.
|
|
173
|
+
|
|
174
|
+
## Supported formats
|
|
175
|
+
|
|
176
|
+
Routing covers the formats supported by the installed toolchain, including:
|
|
177
|
+
|
|
178
|
+
- images: JPEG, PNG, GIF, BMP, TIFF, WebP, HEIC, PPM, SVG and DNG;
|
|
179
|
+
- audio/video: MP3, FLAC, Ogg/Opus, WAV, AIFF, MP4/M4A/MOV, AVI, WMV and Matroska;
|
|
180
|
+
- documents: PDF, OOXML, OpenDocument, EPUB, HTML/XHTML and CSS;
|
|
181
|
+
- archives/data: ZIP, TAR and BitTorrent files.
|
|
182
|
+
|
|
183
|
+
Support still depends on the installed ExifTool/mat2/qpdf/ffmpeg versions.
|
|
184
|
+
Unknown or unsupported formats are never treated as success. PDFs and Matroska
|
|
185
|
+
files containing attachments or cover-art streams are reported unverified or
|
|
186
|
+
failed and are not written.
|
|
187
|
+
|
|
188
|
+
ICC profiles are retained because removing them can change color rendering.
|
|
189
|
+
Recognized standard color spaces (sRGB, Display P3, Adobe RGB, the generic
|
|
190
|
+
profiles, …) are shared by countless files and are not identifying, so they
|
|
191
|
+
clean normally. A profile whose description is not a known standard can carry a
|
|
192
|
+
person's or organization's name in its description, creator, manufacturer or
|
|
193
|
+
copyright fields, so it blocks commit by default — review it and pass
|
|
194
|
+
`--allow-icc-metadata` to keep it.
|
|
195
|
+
|
|
161
196
|
## Publishing
|
|
162
197
|
|
|
163
|
-
The release workflow
|
|
164
|
-
|
|
198
|
+
The release workflow accepts a matching `v*` tag only when its commit belongs
|
|
199
|
+
to `main`. It tests every supported Ruby version, builds a `.gem`, attaches it
|
|
200
|
+
to a GitHub Release, and publishes it to RubyGems via a
|
|
165
201
|
[Trusted Publisher](https://guides.rubygems.org/trusted-publishing/) (OIDC) —
|
|
166
202
|
`rubygems/release-gem` with `id-token: write`. There is **no `RUBYGEMS_API_KEY`
|
|
167
203
|
secret**; the one-time prerequisite is registering this gem's Trusted Publisher
|
|
168
204
|
on rubygems.org.
|
|
169
205
|
|
|
206
|
+
Replace `X.Y.Z` with the version in `lib/metaclean/version.rb` — the release
|
|
207
|
+
workflow refuses to publish if the tag and that version disagree.
|
|
208
|
+
|
|
170
209
|
```bash
|
|
171
|
-
git tag
|
|
172
|
-
git push origin
|
|
210
|
+
git tag vX.Y.Z
|
|
211
|
+
git push origin vX.Y.Z
|
|
173
212
|
```
|
|
174
213
|
|
|
175
214
|
## Safety
|
|
176
215
|
|
|
177
|
-
- All shell-outs use argument arrays (
|
|
178
|
-
|
|
179
|
-
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
216
|
+
- All shell-outs use argument arrays (no shell is involved), so filenames with
|
|
217
|
+
spaces, quotes, or shell metacharacters are safe.
|
|
218
|
+
- Each external tool, including version probes, runs under a wall-clock timeout
|
|
219
|
+
and output cap. Cleaning defaults to 120 s; probes use a shorter limit. For
|
|
220
|
+
very large media on slow or network storage, raise the timeout with
|
|
221
|
+
`METACLEAN_TIMEOUT=<seconds>` (e.g. `METACLEAN_TIMEOUT=600`).
|
|
222
|
+
- Tool output is created inside a private mode-0700 workspace and is rejected
|
|
223
|
+
if it is a symlink or non-regular file. Normal commits stage on the destination
|
|
224
|
+
filesystem for atomic rename; dry-run uses the system temp area. metaclean
|
|
225
|
+
fails instead of proceeding when private permissions cannot be enforced.
|
|
226
|
+
- `--in-place` builds the clean file privately, verifies that the source has not
|
|
227
|
+
changed, preserves ownership/permissions/ACLs/extended attributes with the
|
|
228
|
+
host's native `cp`, creates the metadata-bearing `.bak`, and then renames into
|
|
229
|
+
place. Failure to preserve these attributes or create a hard-link backup
|
|
230
|
+
aborts the commit; use the default `*_clean` mode on filesystems without hard
|
|
231
|
+
links.
|
|
232
|
+
- Final symlinks and user-controlled symlinks in parent path components are
|
|
233
|
+
rejected. Parent-directory identities are checked again before commit.
|
|
234
|
+
- Folder and recursive (`-r`) scans deliberately skip hidden files and hidden
|
|
235
|
+
directories, plus metaclean's outputs (`*_clean`, `*_clean.*`, `*.bak`).
|
|
236
|
+
Name a hidden file explicitly if it should be cleaned.
|
|
186
237
|
- A `<file>.bak` left by `--in-place` is the **untouched original** and still
|
|
187
|
-
contains all its metadata.
|
|
188
|
-
or move before sharing the folder.
|
|
238
|
+
contains all its metadata. Its actual collision-safe path is reported even
|
|
239
|
+
with `--force`, but it is yours to delete or move before sharing the folder.
|
|
189
240
|
- Filename collisions (`photo_clean.jpg` already exists, `.bak` already
|
|
190
241
|
exists) are resolved with `_1`, `_2`, … suffixes, including late collisions
|
|
191
242
|
that appear while a file is being cleaned.
|
|
@@ -198,14 +249,20 @@ git push origin v4.0.1
|
|
|
198
249
|
- metaclean requires ExifTool, mat2, qpdf, and ffmpeg, and refuses to run (with
|
|
199
250
|
install instructions, exit code 2) if any is missing — so the post-clean
|
|
200
251
|
residual check always runs.
|
|
201
|
-
- Naming no files
|
|
202
|
-
|
|
203
|
-
|
|
252
|
+
- Naming no files, discovery errors, unsupported formats, failed files and
|
|
253
|
+
unverified candidates all exit non-zero.
|
|
254
|
+
- When stdout is redirected, metadata values are redacted by default. Use
|
|
255
|
+
`--show-values` only when the destination log is trusted.
|
|
256
|
+
- External tool versions are recorded in CI and shown by `metaclean --version`.
|
|
257
|
+
Keep OS packages updated; release tests exercise the supported Ruby range and
|
|
258
|
+
capability-check qpdf/ffmpeg output rather than trusting version strings.
|
|
204
259
|
|
|
205
260
|
## What it does *not* do
|
|
206
261
|
|
|
207
262
|
- Steganography (data hidden inside the pixel/audio data itself).
|
|
208
|
-
-
|
|
263
|
+
- Renaming files or directories, or removing filesystem timestamps. In-place
|
|
264
|
+
mode preserves filesystem security metadata; a new `*_clean` copy naturally
|
|
265
|
+
has a new inode.
|
|
209
266
|
- Office macros or PDF JavaScript — open untrusted files in a sandbox.
|
|
210
267
|
|
|
211
268
|
The post-clean "still present" check is bounded by what ExifTool can re-read.
|
|
@@ -214,6 +271,11 @@ For container formats that mat2 cleans but ExifTool only partially parses (e.g.
|
|
|
214
271
|
independently confirm — a clean report means "the tools ran", not "every byte
|
|
215
272
|
was audited".
|
|
216
273
|
|
|
274
|
+
Nested files are a separate trust boundary. PDF attachments and Matroska
|
|
275
|
+
attachments/cover art currently fail closed instead of being preserved under a
|
|
276
|
+
false clean result. Archive/document formats are accepted only when their
|
|
277
|
+
cleaning tool completes; do not treat metaclean as a malware scanner.
|
|
278
|
+
|
|
217
279
|
**SVG:** some mat2 builds (e.g. 0.14.0 on recent Python) crash on SVG, and
|
|
218
280
|
ExifTool is read-only for it — so on those systems metaclean cannot clean
|
|
219
281
|
`.svg`. It reports the file as **failed** (exit 1) and leaves the original
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
metaclean is a privacy tool: its worst-case failure is reporting a file as
|
|
4
|
+
"clean" when removable metadata actually survived, or mishandling a file it
|
|
5
|
+
overwrites. Reports of either — or of any vulnerability in metaclean itself —
|
|
6
|
+
are welcome.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
**Please do not open a public issue for security reports.**
|
|
11
|
+
|
|
12
|
+
Use GitHub's [private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
|
|
13
|
+
for this repository (the **Security** tab → **Report a vulnerability**). If that
|
|
14
|
+
is unavailable, contact the maintainer privately via their GitHub profile
|
|
15
|
+
([@26zl](https://github.com/26zl)).
|
|
16
|
+
|
|
17
|
+
Please include:
|
|
18
|
+
|
|
19
|
+
- the file type and a minimal way to reproduce (use `--dry-run` if you can't
|
|
20
|
+
share the file);
|
|
21
|
+
- the metaclean version and the detected exiftool/mat2/qpdf/ffmpeg versions
|
|
22
|
+
(`metaclean --version`);
|
|
23
|
+
- your OS and Ruby version.
|
|
24
|
+
|
|
25
|
+
We aim to acknowledge reports within a few days.
|
|
26
|
+
|
|
27
|
+
## Scope
|
|
28
|
+
|
|
29
|
+
metaclean shells out to **ExifTool**, **mat2**, **qpdf**, and **ffmpeg**, which
|
|
30
|
+
parse hostile binary formats and have had CVEs of their own. Vulnerabilities in
|
|
31
|
+
those tools should be reported to their respective projects — keep them updated. metaclean's
|
|
32
|
+
own scope is the wrapper logic: path handling and argument-injection guards,
|
|
33
|
+
private temporary workspaces, the strip/verify pipeline, and the guarantee that
|
|
34
|
+
failed, unsupported or unverified candidates are never committed as clean.
|
|
35
|
+
|
|
36
|
+
Tool output is created in a mode-0700 directory (on the destination filesystem
|
|
37
|
+
for real commits, in system temp for dry-run) and validated with `lstat` before commit. Reports that bypass this boundary,
|
|
38
|
+
cause a symlink to be committed, or overwrite an unrelated file are in scope.
|
|
39
|
+
User-controlled symlinks in parent path components are rejected and path
|
|
40
|
+
component identities are pinned from discovery through commit.
|
|
41
|
+
|
|
42
|
+
Embedded payloads are treated as a separate verification boundary. PDFs and
|
|
43
|
+
Matroska files containing attachments or cover-art streams are rejected because
|
|
44
|
+
their nested metadata cannot yet be independently verified. A file reported
|
|
45
|
+
clean while such metadata survives is a security issue.
|
|
46
|
+
|
|
47
|
+
A non-standard ICC profile (one whose description is not a recognized standard
|
|
48
|
+
color space) blocks commit unless the user explicitly accepts its identifying
|
|
49
|
+
fields with `--allow-icc-metadata`; standard color spaces clean without it.
|
|
50
|
+
|
|
51
|
+
## Supported versions
|
|
52
|
+
|
|
53
|
+
metaclean follows [Semantic Versioning](https://semver.org). Only the latest
|
|
54
|
+
released version receives fixes.
|
|
55
|
+
|
|
56
|
+
The external parsers are supplied by the operating system rather than the gem.
|
|
57
|
+
CI records their versions and capability-checks the features metaclean uses, but
|
|
58
|
+
users must keep ExifTool, mat2, qpdf and ffmpeg patched through their package
|
|
59
|
+
manager.
|
data/bin/metaclean
CHANGED
data/lib/metaclean/cli.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# CLI argument parser. Uses stdlib OptionParser (zero deps) over a gem like Thor.
|
|
4
|
-
|
|
5
3
|
require 'optparse'
|
|
6
4
|
|
|
7
5
|
module Metaclean
|
|
@@ -17,23 +15,18 @@ module Metaclean
|
|
|
17
15
|
in_place: false,
|
|
18
16
|
force: false,
|
|
19
17
|
inspect_only: false,
|
|
20
|
-
dry_run: false
|
|
18
|
+
dry_run: false,
|
|
19
|
+
quiet: false,
|
|
20
|
+
allow_icc_metadata: false,
|
|
21
|
+
redact_values: !$stdout.tty?
|
|
21
22
|
}
|
|
22
23
|
@paths = []
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# Top-level dispatcher. Catches our errors and exits with codes that
|
|
26
|
-
# shells/CI can act on:
|
|
27
|
-
# 0 → success
|
|
28
|
-
# 1 → general failure
|
|
29
|
-
# 2 → a required tool (exiftool/mat2/qpdf/ffmpeg) is missing (install hint shown)
|
|
30
|
-
# 130→ user pressed Ctrl-C (matches the standard SIGINT exit code)
|
|
31
26
|
def run
|
|
32
27
|
parse!
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# so this only gates an actual inspect/clean.
|
|
36
|
-
Metaclean.ensure_tools!
|
|
28
|
+
Display.configure(quiet: @options[:quiet], redact_values: @options[:redact_values])
|
|
29
|
+
Metaclean.ensure_tools!(in_place: @options[:in_place])
|
|
37
30
|
runner = Runner.new(@options)
|
|
38
31
|
if @options[:inspect_only]
|
|
39
32
|
runner.inspect_paths(@paths)
|
|
@@ -45,13 +38,9 @@ module Metaclean
|
|
|
45
38
|
warn e.message
|
|
46
39
|
exit 2
|
|
47
40
|
rescue Error, SystemCallError => e
|
|
48
|
-
# Errno::* (disk full, permission denied, read-only fs) is a SIBLING of
|
|
49
|
-
# our Error, not a subclass; naming it here gives filesystem failures a
|
|
50
|
-
# clean message + exit 1 instead of a raw backtrace.
|
|
51
41
|
warn Display.error(e.message)
|
|
52
42
|
exit 1
|
|
53
43
|
rescue Interrupt
|
|
54
|
-
# Print a clean message instead of a stack trace.
|
|
55
44
|
warn "\n#{Display.error('Interrupted.')}"
|
|
56
45
|
exit 130
|
|
57
46
|
end
|
|
@@ -59,7 +48,36 @@ module Metaclean
|
|
|
59
48
|
private
|
|
60
49
|
|
|
61
50
|
def parse!
|
|
62
|
-
parser =
|
|
51
|
+
parser = option_parser
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
parser.parse!(@argv)
|
|
55
|
+
rescue OptionParser::ParseError => e
|
|
56
|
+
warn Display.error(e.message)
|
|
57
|
+
warn parser
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if @argv.empty?
|
|
62
|
+
Display.banner
|
|
63
|
+
puts parser
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
incompatible = []
|
|
68
|
+
incompatible << '--dry-run' if @options[:dry_run]
|
|
69
|
+
incompatible << '--in-place' if @options[:in_place]
|
|
70
|
+
incompatible << '--force' if @options[:force]
|
|
71
|
+
if @options[:inspect_only] && incompatible.any?
|
|
72
|
+
warn Display.error("--inspect cannot be combined with #{incompatible.join(', ')}")
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@paths = @argv.dup
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def option_parser
|
|
80
|
+
OptionParser.new do |o|
|
|
63
81
|
o.banner = 'Usage: metaclean [options] <path> [<path>...]'
|
|
64
82
|
o.separator ''
|
|
65
83
|
o.separator 'Metadata cleaner. Strips EXIF, IPTC, XMP, GPS,'
|
|
@@ -69,13 +87,20 @@ module Metaclean
|
|
|
69
87
|
|
|
70
88
|
o.separator 'Modes:'
|
|
71
89
|
o.on('--inspect', 'Only show metadata, do not modify files') { @options[:inspect_only] = true }
|
|
72
|
-
o.on('--dry-run', 'Simulate
|
|
90
|
+
o.on('--dry-run', 'Simulate on a private temporary copy; keep no output') { @options[:dry_run] = true }
|
|
73
91
|
|
|
74
92
|
o.separator ''
|
|
75
93
|
o.separator 'Output:'
|
|
76
94
|
o.on('-i', '--in-place', 'Overwrite originals (keeps a .bak; default: *_clean.<ext>)') { @options[:in_place] = true }
|
|
77
95
|
o.on('-r', '--recursive', 'Recurse into directories') { @options[:recursive] = true }
|
|
78
96
|
o.on('-f', '--force', 'Skip confirmation prompt') { @options[:force] = true }
|
|
97
|
+
o.on('-q', '--quiet', 'Suppress normal output; errors and warnings remain') { @options[:quiet] = true }
|
|
98
|
+
o.on('--redact-values', 'Hide metadata values in tables and diffs') { @options[:redact_values] = true }
|
|
99
|
+
o.on('--show-values', 'Show metadata values even when output is redirected') { @options[:redact_values] = false }
|
|
100
|
+
o.on('--allow-icc-metadata',
|
|
101
|
+
'Keep non-standard ICC profile text (standard color spaces clean anyway)') do
|
|
102
|
+
@options[:allow_icc_metadata] = true
|
|
103
|
+
end
|
|
79
104
|
|
|
80
105
|
o.separator ''
|
|
81
106
|
o.separator 'Other:'
|
|
@@ -83,37 +108,15 @@ module Metaclean
|
|
|
83
108
|
o.on('-v', '--version') do
|
|
84
109
|
Display.banner
|
|
85
110
|
puts "metaclean #{Metaclean::VERSION}"
|
|
86
|
-
# Route tool versions (from the binaries' own stdout) through printable,
|
|
87
|
-
# like every other output path, so a tool emitting ANSI/OSC control
|
|
88
|
-
# bytes on its version line can't inject the terminal.
|
|
89
111
|
puts " exiftool: #{Display.printable(Exiftool.version || 'not found')}"
|
|
90
112
|
puts " mat2: #{Display.printable(Mat2.version || 'not found')}"
|
|
91
113
|
puts " qpdf: #{Display.printable(Qpdf.version || 'not found')}"
|
|
92
114
|
puts " ffmpeg: #{Display.printable(Ffmpeg.version || 'not found')}"
|
|
93
115
|
exit
|
|
94
116
|
end
|
|
117
|
+
o.separator ''
|
|
118
|
+
o.separator 'Use -- before a filename that begins with "-".'
|
|
95
119
|
end
|
|
96
|
-
|
|
97
|
-
begin
|
|
98
|
-
parser.parse!(@argv)
|
|
99
|
-
rescue OptionParser::ParseError => e
|
|
100
|
-
# Any malformed flag — unknown, missing argument, or an ambiguous
|
|
101
|
-
# abbreviation like `--i` (matches both --inspect and --in-place) —
|
|
102
|
-
# shows the message + help and exits non-zero, never a raw backtrace.
|
|
103
|
-
# ParseError is the base class of all of OptionParser's error types.
|
|
104
|
-
warn Display.error(e.message)
|
|
105
|
-
warn parser
|
|
106
|
-
exit 1
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# No paths: show help, exit non-zero so scripts notice.
|
|
110
|
-
if @argv.empty?
|
|
111
|
-
Display.banner
|
|
112
|
-
puts parser
|
|
113
|
-
exit 1
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
@paths = @argv.dup
|
|
117
120
|
end
|
|
118
121
|
end
|
|
119
122
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
module Metaclean
|
|
7
|
+
class Committer
|
|
8
|
+
def initialize(in_place: false, dry_run: false)
|
|
9
|
+
@in_place = in_place
|
|
10
|
+
@dry_run = dry_run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve_final_path(file)
|
|
14
|
+
return file if @in_place
|
|
15
|
+
|
|
16
|
+
collision_safe(build_clean_path(file))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def staging_path_for(final_path)
|
|
20
|
+
prefix = "#{Metaclean::TMP_MARKER}runner.#{Process.pid}."
|
|
21
|
+
workspace = if @dry_run
|
|
22
|
+
Dir.mktmpdir(prefix)
|
|
23
|
+
else
|
|
24
|
+
Dir.mktmpdir(prefix, File.dirname(final_path))
|
|
25
|
+
end
|
|
26
|
+
FileOps.secure_workspace!(workspace)
|
|
27
|
+
File.join(workspace, "staging#{File.extname(final_path)}")
|
|
28
|
+
rescue StandardError, Interrupt
|
|
29
|
+
FileUtils.remove_entry(workspace, true) if workspace && File.exist?(workspace)
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cleanup_staging(staging)
|
|
34
|
+
return unless staging
|
|
35
|
+
|
|
36
|
+
workspace = File.dirname(staging)
|
|
37
|
+
FileUtils.remove_entry(workspace, true) if File.exist?(workspace)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def commit!(staging, final_path, source_stat:)
|
|
41
|
+
backup = nil
|
|
42
|
+
committed = false
|
|
43
|
+
if @in_place
|
|
44
|
+
FileOps.ensure_same_file!(final_path, source_stat)
|
|
45
|
+
backup = backup_original(final_path, source_stat)
|
|
46
|
+
File.rename(staging, final_path)
|
|
47
|
+
committed = true
|
|
48
|
+
return { path: final_path, backup: backup }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{ path: link_with_collision_safe_name(staging, final_path), backup: nil }
|
|
52
|
+
rescue SystemCallError, Interrupt
|
|
53
|
+
if backup && !committed && File.exist?(staging) && FileOps.lexist?(backup)
|
|
54
|
+
File.delete(backup)
|
|
55
|
+
end
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def backup_original(final_path, source_stat)
|
|
60
|
+
backup = hardlink_with_collision_safe_name(final_path, "#{final_path}.bak")
|
|
61
|
+
unless FileOps.same_identity?(File.lstat(backup), source_stat)
|
|
62
|
+
File.delete(backup)
|
|
63
|
+
raise Error, "#{final_path} changed during cleaning — refusing to back it up"
|
|
64
|
+
end
|
|
65
|
+
backup
|
|
66
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::ENOTSUP, Errno::EMLINK, NotImplementedError => e
|
|
67
|
+
raise Error, "Cannot create a metadata-preserving hard-link backup: #{e.message}; use default copy mode"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def hardlink_with_collision_safe_name(source, preferred)
|
|
71
|
+
target = preferred
|
|
72
|
+
loop do
|
|
73
|
+
File.link(source, target)
|
|
74
|
+
return target
|
|
75
|
+
rescue Errno::EEXIST
|
|
76
|
+
target = collision_safe(preferred)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def link_with_collision_safe_name(staging, preferred)
|
|
81
|
+
target = preferred
|
|
82
|
+
loop do
|
|
83
|
+
File.link(staging, target)
|
|
84
|
+
File.delete(staging)
|
|
85
|
+
return target
|
|
86
|
+
rescue Errno::EEXIST
|
|
87
|
+
target = collision_safe(preferred)
|
|
88
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::ENOTSUP, Errno::EMLINK, NotImplementedError
|
|
89
|
+
target = copy_with_collision_safe_name(staging, target)
|
|
90
|
+
File.delete(staging)
|
|
91
|
+
return target
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def copy_with_collision_safe_name(source, preferred, expected: nil)
|
|
96
|
+
target = preferred
|
|
97
|
+
loop do
|
|
98
|
+
copy_file_exclusive(source, target, preserve: true, expected: expected)
|
|
99
|
+
return target
|
|
100
|
+
rescue Errno::EEXIST
|
|
101
|
+
target = collision_safe(preferred)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def copy_file_exclusive(source, destination, preserve: false, expected: nil)
|
|
106
|
+
FileOps.copy_exclusive(source, destination, preserve: preserve, expected: expected)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_clean_path(file)
|
|
110
|
+
extension = File.extname(file)
|
|
111
|
+
base = File.basename(file, extension)
|
|
112
|
+
File.join(File.dirname(file), "#{base}#{Metaclean::CLEAN_SUFFIX}#{extension}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def collision_safe(path)
|
|
116
|
+
return path unless FileOps.lexist?(path)
|
|
117
|
+
|
|
118
|
+
extension = File.extname(path)
|
|
119
|
+
base = File.basename(path, extension)
|
|
120
|
+
directory = File.dirname(path)
|
|
121
|
+
index = 1
|
|
122
|
+
loop do
|
|
123
|
+
candidate = File.join(directory, "#{base}_#{index}#{extension}")
|
|
124
|
+
return candidate unless FileOps.lexist?(candidate)
|
|
125
|
+
|
|
126
|
+
index += 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|