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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b514075fb238c6274263922e87d7218b6c27b86f46c07dfa6fe36e8a1387b382
4
- data.tar.gz: 7eff78f7bd882717bc03cc6f98cb140faa4c9123d5b6a0806ccdb0e342cd3935
3
+ metadata.gz: 8e4ebafb7621f428c5a0d0040c2487340e2dd22b32b831bf47c957027e4d0ca9
4
+ data.tar.gz: e9420536e480b319833996372e9d679285289e5afdfe3555466ff8167ebf5d7f
5
5
  SHA512:
6
- metadata.gz: b674107a22b4965a30bd5c990c90fec5d4944828a8ca755f27124029383de2a543fdf73fad42934b7fa6c4600d0670ffc79ff1602a9b23caf4da9bec7e239ccf
7
- data.tar.gz: d265c366f6b7f0c76acd866e883dc8459ab4e35c27ef092abb4488262e4a5726f7b812d00bba5419282a47d05c80ac3e5e4b97254d2849f3bb4320d7cb19e5b8
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 battle-tested tools and routes each file to the right one:
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,14 +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 refuses to write a
32
- result when known privacy metadata survives.
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
- `.bak` and asks for confirmation unless `--force` is set.
35
- - **Lossless routing:** it avoids mat2 paths that recompress JPEG/WebP or
36
- downconvert TIFF, and uses ffmpeg stream-copy for Matroska.
37
- - **Batch-friendly:** failed or unverified files exit non-zero, so scripts and CI
38
- do not mistake uncertainty for success.
35
+ `.bak` and asks for confirmation unless `--force` is set. Note the `.bak` is the
36
+ **original, with all its metadata** delete or move the `.bak` files before
37
+ sharing an in-place-cleaned folder.
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.
39
44
 
40
45
  ## What it looks like
41
46
 
@@ -85,7 +90,6 @@ From a source checkout:
85
90
 
86
91
  ```bash
87
92
  git clone https://github.com/26zl/metaclean.git && cd metaclean
88
- chmod +x bin/metaclean
89
93
  bundle install
90
94
  bundle exec rake install
91
95
  metaclean --version
@@ -140,8 +144,11 @@ metaclean --in-place photo.jpg
140
144
  # Clean a whole folder, recursively, no prompts
141
145
  metaclean -r --in-place --force ./vacation
142
146
 
143
- # See what would change without writing anything
147
+ # See what would change; a private temporary copy is removed afterwards
144
148
  metaclean --dry-run photo.jpg
149
+
150
+ # Avoid writing private metadata values to a log
151
+ metaclean --redact-values photo.jpg
145
152
  ```
146
153
 
147
154
  ## Flags
@@ -149,34 +156,87 @@ metaclean --dry-run photo.jpg
149
156
  | Flag | What it does |
150
157
  | --- | --- |
151
158
  | `--inspect` | Read-only — print metadata, never write |
152
- | `--dry-run` | Simulate cleaning, show diff, write nothing |
153
- | `-i`, `--in-place` | Overwrite originals (keeps a `<file>.bak`) |
159
+ | `--dry-run` | Simulate on a private mode-0700 temporary copy; keep no output |
160
+ | `-i`, `--in-place` | Overwrite originals (keeps a `<file>.bak` — the **original, metadata intact**; remove it before sharing) |
154
161
  | `-r`, `--recursive` | Recurse into directories |
155
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 |
156
167
  | `-h`, `--help` | Show usage and exit |
157
168
  | `-v`, `--version` | Show metaclean's version **and** the detected versions of exiftool/mat2/qpdf/ffmpeg (prints `not found` for any missing) |
158
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
+
159
196
  ## Publishing
160
197
 
161
- The release workflow builds a `.gem` for every `v*` tag, attaches it to a
162
- GitHub Release, and publishes it to RubyGems via a
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
163
201
  [Trusted Publisher](https://guides.rubygems.org/trusted-publishing/) (OIDC) —
164
202
  `rubygems/release-gem` with `id-token: write`. There is **no `RUBYGEMS_API_KEY`
165
203
  secret**; the one-time prerequisite is registering this gem's Trusted Publisher
166
204
  on rubygems.org.
167
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
+
168
209
  ```bash
169
- git tag v3.0.0
170
- git push origin v3.0.0
210
+ git tag vX.Y.Z
211
+ git push origin vX.Y.Z
171
212
  ```
172
213
 
173
214
  ## Safety
174
215
 
175
- - All shell-outs use argument arrays (`Open3.capture3(*args)`), so filenames
176
- with spaces, quotes, or shell metacharacters are safe.
177
- - `--in-place` writes atomically: the file is built in a temp file and
178
- renamed into place, so a crash mid-run cannot leave a half-written original.
179
- - Symlinks are always skipped metaclean never cleans through a link.
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.
237
+ - A `<file>.bak` left by `--in-place` is the **untouched original** and still
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.
180
240
  - Filename collisions (`photo_clean.jpg` already exists, `.bak` already
181
241
  exists) are resolved with `_1`, `_2`, … suffixes, including late collisions
182
242
  that appear while a file is being cleaned.
@@ -189,14 +249,20 @@ git push origin v3.0.0
189
249
  - metaclean requires ExifTool, mat2, qpdf, and ffmpeg, and refuses to run (with
190
250
  install instructions, exit code 2) if any is missing — so the post-clean
191
251
  residual check always runs.
192
- - Naming no files (a missing path, or everything filtered out), failed files,
193
- and unverified cleans exit non-zero, so scripts do not mistake uncertainty for
194
- success.
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.
195
259
 
196
260
  ## What it does *not* do
197
261
 
198
262
  - Steganography (data hidden inside the pixel/audio data itself).
199
- - Filesystem metadata (mtime, ownership) that's the OS, not the file.
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.
200
266
  - Office macros or PDF JavaScript — open untrusted files in a sandbox.
201
267
 
202
268
  The post-clean "still present" check is bounded by what ExifTool can re-read.
@@ -205,6 +271,11 @@ For container formats that mat2 cleans but ExifTool only partially parses (e.g.
205
271
  independently confirm — a clean report means "the tools ran", not "every byte
206
272
  was audited".
207
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
+
208
279
  **SVG:** some mat2 builds (e.g. 0.14.0 on recent Python) crash on SVG, and
209
280
  ExifTool is read-only for it — so on those systems metaclean cannot clean
210
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
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Add <repo>/lib to the load path so this runs from a checkout, gem or not.
5
4
  $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
6
5
  require 'metaclean'
7
6
 
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
- # Refuse to run unless all four external tools are present (see
34
- # Metaclean.ensure_tools!). --help/--version already exited inside parse!,
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 = OptionParser.new do |o|
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 cleaning, show diff, write nothing') { @options[:dry_run] = true }
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
@@ -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