// documentation

Every flag, and why it exists.

ScrubPony has four modes and a dozen options. This page is the whole surface; there is nothing configurable that is not on it.

Synopsis

scrubpony [options] <path>...

Paths may be files or directories, in any number and any mix. A bare directory covers the files directly inside it; -r descends. A failure on one file never stops the run — the exit code reflects the worst outcome, but only after every other file has been attempted.

At a glance

the common shapes
scrubpony photo.jpg                        writes photo.scrubbed.jpg
scrubpony a.jpg b.jpg c.jpg                many files at once
scrubpony -l photo.jpg                     list segment structure, write nothing
scrubpony -r ~/Pictures/upload              recurse a whole directory tree
scrubpony -r -d ~/clean ~/Pictures/upload  mirror the tree into ~/clean, same names
scrubpony -i -r ~/Pictures/upload           in place, atomically, per file
scrubpony -n -r ~/Pictures                 dry run: what would be removed
scrubpony --check -r ~/Pictures            exit 2 if anything carries metadata
scrubpony --strict photo.jpg               also drop ICC, APP14, APP0
scrubpony --no-keep-orientation photo.jpg  drop the orientation tag as well

Options

FlagMeaning
-nDry run: report what would be removed, write nothing. Prints the keep/drop decision and reason for every non-structural segment, the EXIF summary, and the total that would go.
--checkReport only; exit 2 if any file carries identifying metadata. Quiet about clean files — the useful output is the short list of the ones that are not.
-l, --listList the file's segment structure and say nothing about policy: offsets, markers, payload lengths, identifiers. Writes nothing.
-vVerbose: show every decision, including the structural segments the dry run hides, and mention skipped outputs and leftover temporaries.
-iEdit in place: replace the original. Still a write-then-rename. Cannot be combined with -d.
-fOverwrite an existing output. Without it, an occupied output path is an error rather than a silent clobber.
-rRecurse into directories.
-d DIRMirror the source tree under DIR, keeping filenames, instead of writing .scrubbed files beside the inputs.
--strictAlso drop JFIF, ICC and Adobe segments. Costs colour fidelity; see the policy table below.
--no-keep-orientationDo not re-emit the EXIF orientation. Rotated photos will display sideways.
-h, --helpUsage text.
-V, --versionPrint scrubpony 1.0.0.
--End of options. Everything after it is a path, even if it starts with a dash.

The four modes

Exactly one of these is in effect on any run. --check takes precedence over -l, which takes precedence over -n; with none of them set, the tool scrubs.

ModeReadsWritesFor
scrubyesyesThe job. Sibling output, mirrored tree, or in place.
-nyesnoUnderstanding what is in your own files before you touch them.
--checkyesnoScripts. The verdict is in the exit code.
-lyesnoContainer structure with no opinion attached — what the golden tests compare against exiftool.

Where the output goes

By default, beside the input: photo.jpg becomes photo.scrubbed.jpg. Fine for one photo and unpleasant across four thousand, where it leaves a tree of double the size with two versions of everything interleaved.

With -d DIR, the source tree is mirrored under the target, filenames unchanged, and the originals are never touched. This is the mode to reach for on a whole library. The two trees may not nest in either direction; that is refused rather than allowed to loop — an output directory inside the source means the second run reads what the first one wrote, and a source inside the output means scrubbing the scrubbed copies. Both are loops that look like progress.

With -i, the destination is the source. The temporary still lands beside it and is renamed over the top, so the original is replaced in one indivisible step or not at all. -i and -d ask for opposite things and are refused together.

Segment policy

This is the whole decision table. The marker code is never sufficient: APP1 is EXIF or XMP, APP2 is an ICC profile or an MPF block, and the identifier string at the front of the payload is what tells them apart.

MarkerIdentifierDefault--strictWhy
APP0 JFIF keep drop Density fields are weakly fingerprintable but harmless, and some decoders expect an APP0.
APP0 JFXX drop drop Not the same as JFIF despite the shared marker: the extension block exists to carry a thumbnail.
APP1 EXIF drop drop GPS, timestamps, device serial, thumbnail.
APP1 XMP drop drop Editing history, creator, sometimes GPS again.
APP1 XMP (extended) drop drop The continuation blocks of a large XMP packet.
APP2 ICC profile keep drop Dropping it visibly shifts colour, so it survives by default.
APP2 MPF drop drop Can embed a second, unscrubbed image.
APP2 FlashPix drop drop Camera-specific, can embed a thumbnail.
APP12 Ducky drop drop Camera and editor junk.
APP13 Photoshop/IPTC drop drop Captions, credits, location names.
APP14 Adobe keep drop Dropping it can break CMYK and YCCK colour interpretation.
COM comment drop drop Free text.
APPn unrecognised drop drop Default deny. An unrecognised vendor segment is exactly where a manufacturer hides a serial number.
structural keep keep SOI, SOF, DHT, DQT, DRI, SOS and the scan itself. The picture.

// the default is not maximum paranoia

ICC colour profiles, JFIF and Adobe segments are kept by default because dropping them changes how the picture looks. --strict drops all three, at the cost of colour fidelity and, for CMYK or YCCK images, correct colour interpretation. Choose it deliberately rather than by reflex.

The 36 bytes it writes back

Phones do not rotate pixels. They store the sensor's native orientation and record an EXIF tag telling viewers to turn the image before displaying it. Dropping EXIF drops that instruction, which is why most metadata strippers leave you with a folder of sideways photographs.

ScrubPony drops the original EXIF block entirely and writes back a minimal replacement carrying exactly one field:

FF E1 00 22 APP1, length 34 (counts itself) "Exif\0\0" EXIF identifier 4D 4D 00 2A big-endian TIFF header 00 00 00 08 IFD0 is 8 bytes in 00 01 one entry 01 12 00 03 00 00 00 01 00 0N 00 00 Orientation, SHORT, count 1, value N 00 00 00 00 no IFD1, so no thumbnail

That is the only place this tool writes metadata rather than removing it. The justification is that the block is 36 bytes, holds one field, and that field is not identifying — it says which way up the picture goes, which anyone can see by looking at it. --no-keep-orientation drops it too, for whom the angle the phone was held at is itself a signal.

Nothing is emitted when the orientation is 1 or absent. Spending 36 bytes to say "already upright" would be metadata for its own sake.

Editing in place

-i replaces the original. It is still a write-then-rename: the scrubbed copy is built beside the file, synced, and moved over the top in one indivisible step. Kill the process at any moment and the file on disk is either exactly what it was or exactly what it should become — never a partial of either.

Permissions and ownership are carried onto the replacement. A file that was 0600 does not come back world-readable.

A file that would not change is not touched at all — same inode, same mtime. That is decided by comparing the bytes, not by counting segments: a photo scrubbed once still carries our own orientation block, so a second pass drops one segment and writes an identical one back. Only the bytes settle it.

Three things -i costs you, all of them consequences of replacing the file rather than editing it:

  • Extended attributes are lost. Finder tags, Spotlight comments and quarantine flags live on the inode, and the rename installs a new one.
  • Hard links are broken. Other links to the old inode keep the unscrubbed content.
  • The mtime is reset, deliberately — it is metadata too.

If any of those matter, use the default sibling output or -d and move the results into place yourself.

Checking without changing

--check reports and changes nothing, putting the verdict in the exit code so it can go in a pre-commit hook or an upload script:

scrubpony --check
$ scrubpony --check -r ~/Pictures/to-upload
~/Pictures/to-upload/IMG_4471.jpg: 3 segments of identifying metadata, including GPS
~/Pictures/to-upload/scan.jpg: 1 segment of identifying metadata
scrubpony: 2 of 118 carry identifying metadata (0 not JPEG, 0 skipped, 0 failed)
$ echo $?
2

A photo scrubbed once still carries the 36-byte orientation block, and policy still drops it, so counting dropped segments would report every rotated photo as dirty forever and the hook would get switched off within a week. --check counts identifying segments: a block that matches the bytes this tool emits, exactly, is not one of them. Anything with a second tag, a GPS pointer or a thumbnail is.

Exit codes

CodeMeaning
0Success, or nothing to do.
1Operational error: unreadable, malformed, write failed.
2--check only: identifying metadata was found.
64Usage error.

An error outranks a dirty verdict even though 2 is the larger number. A script that sees 2 concludes "found metadata, scrub it"; if a file was also unreadable, that is the thing it needs to know first, and a plain maximum would have hidden it.

Directories, symlinks and ordering

  • Directories are first-class. Point it at a folder, not a file. A bare directory covers the files directly inside it; -r descends, to a maximum of 64 levels.
  • Symlinks are never followed — not files, not directories, not even one named directly on the command line. A symlink loop is an infinite walk, and a symlink out of the tree is a way to modify files you did not mean to touch.
  • Directory entries are sorted before use, so a dry run is diffable between runs.
  • A second pass skips its own output. Running twice over the same folder does not produce photo.scrubbed.scrubbed.jpg; leftover temporaries are skipped too, and both are only mentioned under -v.
  • Non-JPEG files are reported and skipped, decided by the SOI marker rather than the extension. A .jpg that is really a PNG is not a JPEG here.
  • Per-file error isolation. One unreadable or malformed file does not stop the run.

What happens when something goes wrong

Nothing is written at all if the input turns out to be malformed partway through. A partially scrubbed file is worse than no file, because it looks finished. On any failure the temporary is discarded and the original — or the pre-existing destination — is left exactly as it was.

A run whose output would be larger than its input — beyond the exact size of the orientation block it may add back — is treated as a bug and refused rather than published: scrubbing only removes bytes.

Where to go next