summaryrefslogtreecommitdiff
path: root/bin/sz-rm-meta.sh
diff options
context:
space:
mode:
Diffstat (limited to 'bin/sz-rm-meta.sh')
-rwxr-xr-xbin/sz-rm-meta.sh477
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}"