diff options
| author | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
| commit | 13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch) | |
| tree | 684690c61b7b59758c2aac132c65b81c86de0608 /bin/sz-rm-meta.sh | |
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and
szecret, an age-based secret manager whose store lives elsewhere.
History restarts here. What came before was not written to be read by
anyone else, and carried machine names, addresses and an encrypted secret
store; it is kept privately instead. What is left is meant to be safe on
any machine, including one whose owner is not me: see CLAUDE.md for the
rule it is written to.
Diffstat (limited to 'bin/sz-rm-meta.sh')
| -rwxr-xr-x | bin/sz-rm-meta.sh | 477 |
1 files changed, 477 insertions, 0 deletions
diff --git a/bin/sz-rm-meta.sh b/bin/sz-rm-meta.sh new file mode 100755 index 0000000..5618dc2 --- /dev/null +++ b/bin/sz-rm-meta.sh @@ -0,0 +1,477 @@ +#!/usr/bin/env bash +set -euo pipefail + +# sz-rm-meta.sh — strip metadata from image files. +# +# sz-rm-meta.sh [options] FILE... +# +# -l, --list report what would be removed; change nothing +# -o, --out DIR write cleaned copies into DIR instead of editing in place +# --keep-icc keep the embedded ICC colour profile (stripped by default) +# --keep-xattr leave extended attributes alone (macOS: kMDItemWhereFroms, +# com.apple.quarantine — these record the download URL) +# -d, --destructive re-encode the pixels as well (see "Two modes" below) +# --quality N JPEG quality for --destructive (default 85) +# --scale PCT resize percentage for --destructive (default 97) +# --rename rename the file to a random name, keeping the extension +# -q, --quiet only report failures +# +# Two modes, because "no metadata" and "not traceable" are different jobs: +# +# default Lossless. Rewrites the container, copies the compressed image +# data byte for byte. Pixels stay bit-identical, so the file is +# still matchable against the source by perceptual hash, and the +# quantization/Huffman tables still fingerprint the encoder that +# produced it. Right for sanitising an archive you keep. +# +# --destructive Re-encodes: resize by --scale, re-compress at --quality, fresh +# quantization tables, then run the lossless pass over the +# result. Breaks bitstream-level linkage to the source copy. +# Does NOT defeat reverse-image search or perceptual hashing — +# nothing that preserves what the picture looks like can. Needs +# ImageMagick. +# +# What the lossless pass removes, per format: +# JPEG every APPn segment except JFIF (APP0) and Adobe (APP14), all COM +# comments, and any trailer appended after the EOI marker. That covers +# EXIF (camera, GPS, serial, timestamps), XMP, IPTC and the Photoshop +# 8BIM block (by-line, credit, document ancestry). +# PNG everything outside an allowlist of image and colour chunks. An +# allowlist rather than a denylist because private chunks are the worst +# offenders: Fireworks' mkBF/mkBS/mkBT embed the entire editable source +# document, and prVw carries a full preview image. +# +# Why APP14 "Adobe" survives: it is not metadata, it declares the colour +# transform (YCbCr vs RGB vs YCCK). Dropping it turns some CMYK and RGB JPEGs +# into colour-inverted garbage. It is a 12-byte codec record written by many +# encoders, not a Photoshop watermark. +# +# Symlinks are resolved and the real file is rewritten, so cleaning a link no +# longer leaves the target dirty. A file with more than one hard link is +# rewritten in place rather than replaced, so every name sees the clean data — +# that write is not atomic, which is the honest trade for not silently breaking +# the link. +# +# Files are otherwise rewritten via a temp file in the same directory and moved +# into place, so an interrupted run leaves the original intact. A file that +# fails to parse is skipped, never half-written. +# +# Deliberately not handled: GIF, TIFF, HEIC, WebP, video. They need a real +# metadata library; this script would rather refuse than corrupt them. Install +# exiftool for those. + +die() { >&2 echo "sz-rm-meta: $*"; exit 1; } +warn() { >&2 echo "sz-rm-meta: warning: $*"; } + +list_only=false +out_dir="" +keep_icc=false +keep_xattr=false +destructive=false +rename=false +quality=85 +scale=97 +quiet=false +files=() + +usage() { + cat <<'USAGE' +sz-rm-meta.sh [options] FILE... strip metadata from JPEG/PNG + + -l, --list report what would be removed; change nothing + -o, --out DIR write cleaned copies into DIR instead of editing in place + --keep-icc keep the embedded ICC colour profile (stripped by default) + --keep-xattr leave extended attributes alone (macOS keeps the download + URL in kMDItemWhereFroms) + -d, --destructive also re-encode the pixels: breaks the bitstream fingerprint + linking this copy to the source. Needs ImageMagick. + --quality N JPEG quality for --destructive (default 85) + --scale PCT resize percentage for --destructive (default 97) + --rename rename to a random name, keeping the extension + -q, --quiet only report failures + +Default mode is lossless: pixels stay bit-identical, which also means the file +still matches the source under reverse-image search or perceptual hashing. +Use --destructive when the goal is that a copy cannot be linked to its origin. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -l|--list) list_only=true; shift ;; + -o|--out) [ "$#" -ge 2 ] || die "--out needs a directory"; out_dir="$2"; shift 2 ;; + --keep-icc) keep_icc=true; shift ;; + --keep-xattr) keep_xattr=true; shift ;; + -d|--destructive) destructive=true; shift ;; + --quality) [ "$#" -ge 2 ] || die "--quality needs a number"; quality="$2"; shift 2 ;; + --scale) [ "$#" -ge 2 ] || die "--scale needs a number"; scale="$2"; shift 2 ;; + --rename) rename=true; shift ;; + -q|--quiet) quiet=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; files+=("$@"); break ;; + -*) die "unknown option '$1' (try --help)" ;; + *) files+=("$1"); shift ;; + esac +done + +[ "${#files[@]}" -gt 0 ] || die "no files given (try --help)" +command -v python3 >/dev/null || die "python3 not found" + +case "${quality}" in ''|*[!0-9]*) die "--quality must be a number" ;; esac +case "${scale}" in ''|*[!0-9]*) die "--scale must be a number" ;; esac +if [ "${quality}" -lt 1 ] || [ "${quality}" -gt 100 ]; then die "--quality must be 1-100"; fi +if [ "${scale}" -lt 1 ] || [ "${scale}" -gt 100 ]; then die "--scale must be 1-100"; fi + +if [ -n "${out_dir}" ]; then + ${list_only} && die "--list and --out are mutually exclusive" + mkdir -p "${out_dir}" +fi + +magick_cmd="" +if ${destructive}; then + ${list_only} && die "--list and --destructive are mutually exclusive" + if command -v magick >/dev/null 2>&1; then + magick_cmd="magick" + elif command -v convert >/dev/null 2>&1; then + magick_cmd="convert" + else + die "--destructive needs ImageMagick (brew install imagemagick / apt install imagemagick)" + fi +fi + +if ${rename} && ${list_only}; then + die "--list and --rename are mutually exclusive" +fi + +# Whether this host can inspect xattrs at all. Without a tool we must say so +# rather than report a reassuring zero: Linux browsers write the download URL to +# user.xdg.origin.url, the direct analogue of macOS kMDItemWhereFroms. +xattr_tool="" +if command -v xattr >/dev/null 2>&1; then + xattr_tool="macos" +elif command -v getfattr >/dev/null 2>&1 && command -v setfattr >/dev/null 2>&1; then + xattr_tool="linux" +fi +if [ -z "${xattr_tool}" ] && ! ${keep_xattr} && [ -z "${out_dir}" ]; then + warn "no xattr/setfattr on this host; extended attributes are NOT being checked or cleared" +fi + +# Clear the extended attributes that carry provenance. com.apple.provenance is +# system-managed and cannot be removed - it holds no information about the +# image, so it is excluded from the count and left alone. +clear_xattrs() { + local f="$1" n=0 a + case "${xattr_tool}" in + macos) + n=$(xattr "${f}" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true) + if [ "${n}" -gt 0 ]; then + xattr -c "${f}" 2>/dev/null || true + fi + ;; + linux) + while read -r a; do + [ -n "${a}" ] || continue + if setfattr -x "${a}" "${f}" 2>/dev/null; then + n=$((n + 1)) + fi + done < <(getfattr --absolute-names -m '^user\.' "${f}" 2>/dev/null | + grep '^user\.' || true) + ;; + esac + echo "${n}" +} + +count_xattrs() { + case "${xattr_tool}" in + macos) xattr "$1" 2>/dev/null | grep -cv '^com\.apple\.provenance$' || true ;; + linux) getfattr -m '^user\.' "$1" 2>/dev/null | grep -c '^user\.' || true ;; + *) echo 0 ;; + esac +} + +strip_one() { + python3 - "$1" "$2" "$3" "$4" <<'PY' +import os, shutil, struct, sys, tempfile + +src, dst, mode, keep_icc = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "true" +listing = mode == "list" + +def fail(msg): + sys.stderr.write("sz-rm-meta: %s: %s\n" % (os.path.basename(src), msg)) + sys.exit(2) + +# Follow symlinks: replacing the link node would leave the real file dirty while +# reporting success, and would silently turn the link into a regular file. +src = os.path.realpath(src) +in_place = os.path.realpath(dst) == src +if in_place: + dst = src + +d = open(src, "rb").read() +removed = {} # label -> [count, bytes], insertion-ordered + +def note(label, n): + slot = removed.setdefault(label, [0, 0]) + slot[0] += 1 + slot[1] += n + +def summary(): + parts = [] + for label, (count, size) in removed.items(): + parts.append("%s%s %dB" % (label, " x%d" % count if count > 1 else "", size)) + return ", ".join(parts) + +def jpeg_label(marker, body): + if marker == 0xFE: + return "comment" + if body.startswith(b"Exif\x00"): + return "EXIF" + if body.startswith(b"http://ns.adobe.com/xap"): + return "XMP" + if body.startswith(b"http://ns.adobe.com/xmp/extension"): + return "XMP-extended" + if body.startswith(b"Photoshop 3.0"): + return "IPTC/8BIM" + if body.startswith(b"ICC_PROFILE"): + return "ICC" + if body.startswith(b"Ducky"): + return "Ducky" + if body.startswith(b"http://ns.google.com"): + return "Google-XMP" + if body.startswith(b"MPF\x00"): + return "MPF" + return "APP%d" % (marker - 0xE0) + +def strip_jpeg(d): + out = bytearray(d[:2]) + i, n = 2, len(d) + saw_eoi = False + while i < n - 1: + if d[i] != 0xFF: + fail("malformed JPEG: expected a marker at offset %d" % i) + m = d[i + 1] + if m == 0xFF: # fill byte + out += d[i:i + 1]; i += 1; continue + if m == 0xD9: # EOI + out += d[i:i + 2]; i += 2; saw_eoi = True; break + if m == 0x01 or 0xD0 <= m <= 0xD7: # TEM / RSTn: no payload + out += d[i:i + 2]; i += 2; continue + if i + 4 > n: + fail("truncated JPEG segment header") + ln = struct.unpack(">H", d[i + 2:i + 4])[0] + if ln < 2 or i + 2 + ln > n: + fail("truncated JPEG segment (marker FF%02X)" % m) + body = d[i + 4:i + 2 + ln] + keep = True + if 0xE0 <= m <= 0xEF or m == 0xFE: + label = jpeg_label(m, body) + if m == 0xE0 and body.startswith(b"JFIF\x00"): + # Density header. Its last two fields are thumbnail dimensions; + # a non-zero pair means an embedded RGB preview of the image + # rides along, so drop the segment rather than keep the preview. + keep = not (len(body) >= 14 and body[12] and body[13]) + if not keep: + note("JFIF-thumbnail", ln + 2) + elif m == 0xE0 and body.startswith(b"JFXX\x00"): + keep = False # exists only to carry a thumbnail + note("JFXX-thumbnail", ln + 2) + elif m == 0xEE and body.startswith(b"Adobe"): + keep = True # colour transform: a codec parameter + elif label == "ICC" and keep_icc: + keep = True + else: + keep = False + note(label, ln + 2) + if keep: + out += d[i:i + 2 + ln] + i += 2 + ln + if m == 0xDA: # entropy-coded scan follows + j = i + while j < n - 1: + if d[j] != 0xFF: + j += 1; continue + nx = d[j + 1] + if nx == 0x00 or 0xD0 <= nx <= 0xD7: + j += 2; continue # byte stuffing / restart marker + if nx == 0xFF: + j += 1; continue # fill byte + break + out += d[i:j] + i = j + if not saw_eoi: + fail("no EOI marker found; refusing to write a truncated file") + if i < n: + note("trailer", n - i) # appended thumbnails, depth maps, junk + return bytes(out) + +# Allowlist, not denylist: anything not named here is dropped. Covers the image +# itself, transparency, colour rendering, and APNG animation. Private chunks +# (mkBF/prVw/cmOD and friends) fall through to the drop path by construction. +PNG_KEEP = { + b"IHDR", b"PLTE", b"IDAT", b"IEND", # critical + b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"sBIT", # colour / transparency + b"bKGD", b"pHYs", b"hIST", b"sPLT", # rendering hints + b"acTL", b"fcTL", b"fdAT", # APNG: dropping these +} # would flatten the animation + +def strip_png(d): + out = bytearray(d[:8]) + i, n = 8, len(d) + saw_end = False + while i + 8 <= n: + ln = struct.unpack(">I", d[i:i + 4])[0] + typ = d[i + 4:i + 8] + end = i + 12 + ln + if end > n: + fail("truncated PNG chunk '%s'" % typ.decode("ascii", "replace")) + keep = typ in PNG_KEEP or (typ == b"iCCP" and keep_icc) + if keep: + out += d[i:end] + else: + note(typ.decode("ascii", "replace"), ln + 12) + i = end + if typ == b"IEND": + saw_end = True + break + if not saw_end: + fail("no IEND chunk found; refusing to write a truncated file") + if i < n: + note("trailer", n - i) + return bytes(out) + +if d[:2] == b"\xff\xd8": + cleaned = strip_jpeg(d) +elif d[:8] == b"\x89PNG\r\n\x1a\n": + cleaned = strip_png(d) +else: + fail("unsupported format (only JPEG and PNG; use exiftool for others)") + +links = os.stat(src).st_nlink if in_place else 1 + +if not listing and (removed or not in_place): + if in_place and links > 1: + # Replacing the inode would clean this name and leave every other hard + # link holding the original metadata. Rewrite the inode instead so all + # names see the clean data, accepting a non-atomic write. + with open(dst, "r+b") as fh: + fh.write(cleaned) + fh.truncate() + else: + # Write beside the destination so the rename is atomic on the same volume. + dst_dir = os.path.dirname(os.path.abspath(dst)) or "." + fd, tmp = tempfile.mkstemp(dir=dst_dir, prefix=".sz-rm-meta.") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(cleaned) + shutil.copymode(src, tmp) + os.replace(tmp, dst) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + +print("%d\t%d\t%d\t%s" % (len(d), len(cleaned), links, summary())) +PY +} + +# Re-encode into a temp file: new quantization tables, new bitstream, slightly +# different dimensions. -strip drops what ImageMagick knows about; the lossless +# pass afterwards catches what it leaves behind (it writes its own tEXt/date +# chunks into PNG, for one). +reencode() { + local src="$1" tmp="$2" + "${magick_cmd}" "${src}" -strip -resize "${scale}%" -quality "${quality}" \ + -interlace none "${tmp}" 2>/dev/null +} + +random_name() { + local ext="${1##*.}" + local stem + stem=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 12) + if [ "${ext}" = "$1" ]; then + echo "${stem}" + else + echo "${stem}.${ext}" + fi +} + +status=0 +for f in "${files[@]}"; do + [ -f "${f}" ] || { >&2 echo "sz-rm-meta: not a file: ${f}"; status=1; continue; } + + orig_size=$(python3 -c 'import os,sys; print(os.path.getsize(sys.argv[1]))' "${f}") + + dst="${f}" + [ -n "${out_dir}" ] && dst="${out_dir%/}/$(basename "${f}")" + + # Count before rewriting: the rewrite replaces the inode, which drops the + # xattrs on its own, so counting afterwards would always report zero. In + # --out mode the source is never touched and the fresh copy starts with no + # xattrs, so there is nothing to clear or report. + xa=0 + if ! ${keep_xattr} && [ -z "${out_dir}" ]; then + xa=$(count_xattrs "${f}") + fi + + src="${f}" + tmp_reenc="" + if ${destructive}; then + tmp_reenc="$(dirname "${dst}")/.sz-rm-meta-reenc.$$.$(basename "${f}")" + if ! reencode "${f}" "${tmp_reenc}"; then + rm -f "${tmp_reenc}" + >&2 echo "sz-rm-meta: ${f}: re-encode failed" + status=1; continue + fi + src="${tmp_reenc}" + fi + + mode="strip"; ${list_only} && mode="list" + if ! result=$(strip_one "${src}" "${dst}" "${mode}" "${keep_icc}"); then + [ -n "${tmp_reenc}" ] && rm -f "${tmp_reenc}" + status=1; continue + fi + if [ -n "${tmp_reenc}" ]; then + rm -f "${tmp_reenc}" + fi + + # Still needed for a file that was already clean, and for the hard-link path + # where the inode survives the rewrite along with its xattrs. + if [ "${xa}" -gt 0 ] && ! ${list_only}; then + clear_xattrs "${dst}" >/dev/null + fi + + after=$(echo "${result}" | cut -f2) + links=$(echo "${result}" | cut -f3) + removed=$(echo "${result}" | cut -f4) + + final="${dst}" + if ${rename} && ! ${list_only}; then + final="$(dirname "${dst}")/$(random_name "$(basename "${dst}")")" + mv -n "${dst}" "${final}" || { >&2 echo "sz-rm-meta: rename failed: ${dst}"; status=1; final="${dst}"; } + fi + + ${quiet} && continue + + detail="${removed}" + if [ "${xa}" -gt 0 ]; then + plural=s; [ "${xa}" -eq 1 ] && plural= + detail="${detail:+${detail}, }${xa} xattr${plural}" + fi + if [ -z "${detail}" ] && ! ${destructive}; then + echo "${f}: already clean" + elif ${list_only}; then + echo "${f}: would remove ${detail} (${orig_size} -> ${after} bytes)" + else + if ${destructive}; then + action="re-encoded q${quality} @${scale}%${detail:+, removed ${detail}}" + else + action="removed ${detail}" + fi + line="${f}: ${action} (${orig_size} -> ${after} bytes)" + [ "${links}" -gt 1 ] && line="${line} [${links} hard links, rewritten in place]" + [ "${final}" != "${dst}" ] && line="${line} -> $(basename "${final}")" + echo "${line}" + fi +done + +exit "${status}" |
