metaclean 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29c5b4aad77d530c77d0f4fbae6bbb1811dd6c801719e67999a1da802522a06a
4
- data.tar.gz: cbeb0bc1eaf21557b922641fe255b1542dfc776e8833d20ae3fc1c74c9d378a5
3
+ metadata.gz: 692205ff836f71791a36b778a68d0118971b6b96e7e0be2ba00502db47cefb75
4
+ data.tar.gz: 8166979b18262baff9ecda5a7a51c12a49831c2003f45f6182684e19f7fd64bc
5
5
  SHA512:
6
- metadata.gz: b33eebe165d0d484d8fd19c9ba8bcaa28a2767b5597c5a08df946fa329256fb23694b9df35507007d4301998b49ca2bf7599eae1f11b0979b1408ee46a71ee74
7
- data.tar.gz: 48a238f59f367c4bd905455cc44bfe12863b02a73bda64b22391d528ef8523d513e5d668992a8d337fe593448ee6552746ca41cf1766fcac6dabbd540e36ba85
6
+ metadata.gz: e631299143c75e723d62eb37fa2674a0ffa1d9a81bf96f8947c0880344744741c041eca28deaeaefaaeafc23961a5503731a62a859a7bbf544ea4fdd94ef623a
7
+ data.tar.gz: 2e90e6758bf445f6a3468f2c012a3c0d4f3724f5846ca87068ccdd4d5c5362079aa7682cdb68a0c216b489ad9b20b5db0a1df2d3a006bb3c9523d6f2ef787ac0
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # metaclean
2
2
 
3
- ```
3
+ ```text
4
4
  ███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗
5
5
  ████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║
6
6
  ██╔████╔██║█████╗ ██║ ███████║██║ ██║ █████╗ ███████║██╔██╗ ██║
@@ -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,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 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
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
- - **Lossless routing:** it avoids mat2 paths that recompress JPEG/WebP or
38
- downconvert TIFF, and uses ffmpeg stream-copy for Matroska.
39
- - **Batch-friendly:** failed or unverified files exit non-zero, so scripts and CI
40
- do not mistake uncertainty for success.
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 without writing anything
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 cleaning, show diff, write nothing |
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 builds a `.gem` for every `v*` tag, attaches it to a
164
- 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
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 v4.0.1
172
- git push origin v4.0.1
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 (`Open3.capture3(*args)`), so filenames
178
- with spaces, quotes, or shell metacharacters are safe.
179
- - `--in-place` writes atomically: the file is built in a temp file and
180
- renamed into place, so a crash mid-run cannot leave a half-written original.
181
- - Symlinks are always skipped metaclean never cleans through a link.
182
- - Folder and recursive (`-r`) scans deliberately skip **hidden files** (dot-prefixed,
183
- e.g. `.secret.jpg`) and metaclean's own outputs (`*_clean.*`, `*.bak`), so a
184
- directory run does not clean every file in the tree. To clean a hidden file,
185
- name it explicitly: `metaclean .secret.jpg`.
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. It is reported in the run, but it is yours to delete
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,23 @@ 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 (a missing path, or everything filtered out), failed files,
202
- and unverified cleans exit non-zero, so scripts do not mistake uncertainty for
203
- 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
+ - Warnings and errors are written to stderr, so privacy notes (surviving tags,
257
+ the `.bak` reminder) stay visible even when normal output is piped or sent
258
+ to a file.
259
+ - External tool versions are recorded in CI and shown by `metaclean --version`.
260
+ Keep OS packages updated; release tests exercise the supported Ruby range and
261
+ capability-check qpdf/ffmpeg output rather than trusting version strings.
204
262
 
205
263
  ## What it does *not* do
206
264
 
207
265
  - Steganography (data hidden inside the pixel/audio data itself).
208
- - Filesystem metadata (mtime, ownership) that's the OS, not the file.
266
+ - Renaming files or directories, or removing filesystem timestamps. In-place
267
+ mode preserves filesystem security metadata; a new `*_clean` copy naturally
268
+ has a new inode.
209
269
  - Office macros or PDF JavaScript — open untrusted files in a sandbox.
210
270
 
211
271
  The post-clean "still present" check is bounded by what ExifTool can re-read.
@@ -214,6 +274,11 @@ For container formats that mat2 cleans but ExifTool only partially parses (e.g.
214
274
  independently confirm — a clean report means "the tools ran", not "every byte
215
275
  was audited".
216
276
 
277
+ Nested files are a separate trust boundary. PDF attachments and Matroska
278
+ attachments/cover art currently fail closed instead of being preserved under a
279
+ false clean result. Archive/document formats are accepted only when their
280
+ cleaning tool completes; do not treat metaclean as a malware scanner.
281
+
217
282
  **SVG:** some mat2 builds (e.g. 0.14.0 on recent Python) crash on SVG, and
218
283
  ExifTool is read-only for it — so on those systems metaclean cannot clean
219
284
  `.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,51 +87,36 @@ 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:'
82
- o.on('-h', '--help') { Display.banner; puts o; exit }
83
- o.on('-v', '--version') do
107
+ o.on('-h', '--help', 'Show this help and exit') { Display.banner; puts o; exit }
108
+ o.on('-v', '--version', 'Show metaclean and tool versions, then exit') 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