Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Created August 4, 2026 14:59
Show Gist options
  • Select an option

  • Save dzogrim/e14f5a4e77906758beb466731ea30e7a to your computer and use it in GitHub Desktop.

Select an option

Save dzogrim/e14f5a4e77906758beb466731ea30e7a to your computer and use it in GitHub Desktop.
Safely converts macOS Finder alias files below ROOT into POSIX symlinks
#!/usr/bin/env bash
#
# convert-finder-aliases.sh
# ---------------------------------------------------------------------------
# Safely converts macOS Finder alias files below ROOT into POSIX symlinks.
#
# Internal aliases, whose targets are inside ROOT, are converted to relative
# symlinks so the complete tree can be moved or mounted elsewhere. External
# aliases are reported by default and can be converted to absolute symlinks only
# with --apply --include-external.
#
# The original Finder alias is retained as "<name>.finder-alias" before any
# symlink is created. Broken aliases, missing targets, self-targets, and
# conflicts are never modified.
#
# Required configuration:
# ADM_CLASSEUR_PATH_PERSO="/path/to/search/root" in ~/.config/AdminHelpers/env
#
# Usage:
# ./convert-finder-aliases.sh [--dry-run] [--log FILE]
# ./convert-finder-aliases.sh --apply [--include-external] [--log FILE]
# ./convert-finder-aliases.sh --restore [--log FILE]
# ./convert-finder-aliases.sh --cleanup --confirm-delete-backups [--log FILE]
# ./convert-finder-aliases.sh --root PATH [options]
# ./convert-finder-aliases.sh --help
# ---------------------------------------------------------------------------
set -Eeuo pipefail
# shellcheck disable=SC2034
readonly VERSION="0.1.1 (2026-08-04)"
readonly SCRIPT_NAME="${0##*/}"
readonly customScriptsEnv="${HOME}/.config/AdminHelpers/env"
readonly BACKUP_SUFFIX=".finder-alias"
MODE="dry-run"
ROOT=""
ROOT_REAL=""
LOG_FILE=""
INCLUDE_EXTERNAL=false
CONFIRM_DELETE_BACKUPS=false
TMP_DIR=""
RESOLVER=""
aliases_found=0
internal_count=0
external_count=0
broken_count=0
self_target_count=0
missing_target_count=0
already_converted_count=0
converted_count=0
restored_count=0
cleaned_count=0
skipped_count=0
error_count=0
FILES_INSPECTED=0
METADATA_FAILURES=0
usage() {
cat <<EOF
${SCRIPT_NAME} ${VERSION}
Usage:
${SCRIPT_NAME} [--dry-run] [--log FILE]
${SCRIPT_NAME} --apply [--include-external] [--log FILE]
${SCRIPT_NAME} --restore [--log FILE]
${SCRIPT_NAME} --cleanup --confirm-delete-backups [--log FILE]
${SCRIPT_NAME} --root PATH [options]
${SCRIPT_NAME} --help
Modes:
--dry-run Analyze only. This is the default.
--apply Convert safe aliases.
--restore Restore retained Finder aliases and remove generated links.
--cleanup Delete retained backups after validating matching links.
Options:
--include-external
With --apply, also convert external aliases to absolute links.
--confirm-delete-backups
Required with --cleanup before deleting .finder-alias files.
--root PATH Override ROOT after loading ADM_CLASSEUR_PATH_PERSO.
--log FILE Write the complete report to FILE and category reports beside it.
-v, --version Print version.
-h, --help Show this help.
Configuration:
ADM_CLASSEUR_PATH_PERSO must be defined in:
${customScriptsEnv}
EOF
}
die_args() {
printf 'ERROR: %s\n' "$1" >&2
printf 'Usage: %s --help\n' "${SCRIPT_NAME}" >&2
exit 2
}
load_config() {
if [[ ! -r "${customScriptsEnv}" ]]; then
printf 'ERROR: AdminHelpers environment is missing or unreadable: %s\n' "${customScriptsEnv}" >&2
exit 3
fi
# shellcheck source=/dev/null
source "${customScriptsEnv}"
: "${ADM_CLASSEUR_PATH_PERSO:?Missing ADM_CLASSEUR_PATH_PERSO in ${customScriptsEnv}}"
ROOT="${ADM_CLASSEUR_PATH_PERSO}"
}
parse_args() {
local selected_modes=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
MODE="dry-run"
selected_modes=$((selected_modes + 1))
shift
;;
--apply)
MODE="apply"
selected_modes=$((selected_modes + 1))
shift
;;
--restore)
MODE="restore"
selected_modes=$((selected_modes + 1))
shift
;;
--cleanup)
MODE="cleanup"
selected_modes=$((selected_modes + 1))
shift
;;
--include-external)
INCLUDE_EXTERNAL=true
shift
;;
--confirm-delete-backups)
CONFIRM_DELETE_BACKUPS=true
shift
;;
--root)
[[ $# -ge 2 ]] || die_args "--root requires a path"
ROOT="$2"
shift 2
;;
--log)
[[ $# -ge 2 ]] || die_args "--log requires a file"
LOG_FILE="$2"
shift 2
;;
-v|--version)
printf '%s %s\n' "${SCRIPT_NAME}" "${VERSION}"
exit 0
;;
-h|--help)
usage
exit 0
;;
*)
die_args "unknown argument: $1"
;;
esac
done
[[ "${selected_modes}" -le 1 ]] || die_args "--apply, --restore, and --cleanup are mutually exclusive"
if [[ "${INCLUDE_EXTERNAL}" == true && "${MODE}" != "apply" ]]; then
die_args "--include-external is only valid with --apply"
fi
if [[ "${CONFIRM_DELETE_BACKUPS}" == true && "${MODE}" != "cleanup" ]]; then
die_args "--confirm-delete-backups is only valid with --cleanup"
fi
if [[ "${MODE}" == "cleanup" && "${CONFIRM_DELETE_BACKUPS}" != true ]]; then
die_args "--cleanup requires --confirm-delete-backups"
fi
}
require_root() {
if [[ -z "${ROOT}" || ! -d "${ROOT}" ]]; then
printf 'ERROR: ROOT missing or inaccessible: %s\n' "${ROOT:-<unset>}" >&2
exit 3
fi
}
require_tools() {
local missing=()
local tool
for tool in /usr/bin/find /usr/bin/mdls /usr/bin/mktemp /usr/bin/xcrun /usr/bin/perl /bin/mkdir /bin/mv /bin/ln /bin/rm; do
[[ -x "${tool}" ]] || missing+=("${tool}")
done
if [[ "${#missing[@]}" -gt 0 ]]; then
printf 'ERROR: required macOS tool unavailable:\n' >&2
printf ' %s\n' "${missing[@]}" >&2
exit 4
fi
}
setup_logging() {
local log_dir
[[ -z "${LOG_FILE}" ]] && return 0
log_dir="${LOG_FILE%/*}"
[[ "${log_dir}" == "${LOG_FILE}" ]] && log_dir="."
[[ -d "${log_dir}" ]] || {
printf 'ERROR: log directory missing: %s\n' "${log_dir}" >&2
exit 1
}
: >"${LOG_FILE}" || {
printf 'ERROR: cannot write log: %s\n' "${LOG_FILE}" >&2
exit 1
}
}
log() {
printf '%s\n' "$*"
[[ -z "${LOG_FILE}" ]] || printf '%s\n' "$*" >>"${LOG_FILE}"
}
escape_field() {
local value="$1"
value=${value//\\/\\\\}
value=${value//$'\t'/\\t}
value=${value//$'\n'/\\n}
value=${value//$'\r'/\\r}
printf '%s' "${value}"
}
report_path() {
local category="$1"
local base dir stem
[[ -n "${LOG_FILE}" ]] || return 1
base="${LOG_FILE##*/}"
dir="${LOG_FILE%/*}"
[[ "${dir}" == "${LOG_FILE}" ]] && dir="."
stem="${base%.*}"
printf '%s/%s-%s.tsv' "${dir}" "${stem}" "${category}"
}
write_report() {
local category="$1"
local status="$2"
local alias_path="$3"
local target_path="$4"
local message="$5"
local file
file="$(report_path "${category}")" || return 0
if [[ ! -e "${file}" ]]; then
printf 'status\talias_path\tresolved_target\tmessage\n' >"${file}"
fi
printf '%s\t%s\t%s\t%s\n' \
"$(escape_field "${status}")" \
"$(escape_field "${alias_path}")" \
"$(escape_field "${target_path}")" \
"$(escape_field "${message}")" >>"${file}"
}
cleanup_tmp() {
[[ -z "${TMP_DIR}" ]] || /bin/rm -rf -- "${TMP_DIR}"
}
build_resolver() {
TMP_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/convert-finder-aliases.XXXXXX")"
RESOLVER="${TMP_DIR}/resolve-finder-alias"
/bin/mkdir -p "${TMP_DIR}/module-cache"
cat >"${TMP_DIR}/resolve-finder-alias.swift" <<'SWIFT'
import Foundation
enum ResolveError: Error, CustomStringConvertible {
case missingArgument
case notAlias
case unreadable(URLResourceKey)
var description: String {
switch self {
case .missingArgument:
return "missing alias path"
case .notAlias:
return "not a Finder alias file"
case .unreadable(let key):
return "unable to read resource key \(key.rawValue)"
}
}
}
func fail(_ message: String, _ code: Int32) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(code)
}
do {
guard CommandLine.arguments.count == 2 else {
throw ResolveError.missingArgument
}
let aliasURL = URL(fileURLWithPath: CommandLine.arguments[1])
let values = try aliasURL.resourceValues(forKeys: [.isAliasFileKey])
guard let isAlias = values.isAliasFile else {
throw ResolveError.unreadable(.isAliasFileKey)
}
guard isAlias else {
throw ResolveError.notAlias
}
let resolvedURL = try URL(
resolvingAliasFileAt: aliasURL,
options: [.withoutUI]
)
print(resolvedURL.path)
} catch let error as ResolveError {
fail(error.description, 10)
} catch {
fail(error.localizedDescription, 11)
}
SWIFT
CLANG_MODULE_CACHE_PATH="${TMP_DIR}/module-cache" \
/usr/bin/xcrun swiftc "${TMP_DIR}/resolve-finder-alias.swift" -o "${RESOLVER}" || {
printf 'ERROR: failed to build Swift alias resolver\n' >&2
exit 4
}
}
is_finder_alias() {
local item="$1"
[[ ! -L "${item}" ]] || return 1
[[ "${item}" != *"${BACKUP_SUFFIX}" ]] || return 1
[[ "$(/usr/bin/mdls -raw -name kMDItemContentType "${item}" 2>/dev/null)" == "com.apple.alias-file" ]]
}
is_retained_finder_alias() {
local item="$1"
[[ ! -L "${item}" ]] || return 1
[[ "$(/usr/bin/mdls -raw -name kMDItemContentType "${item}" 2>/dev/null)" == "com.apple.alias-file" ]]
}
resolve_alias() {
"${RESOLVER}" "$1"
}
canonical_path() {
/usr/bin/perl -MCwd=abs_path -e '
my $path = abs_path($ARGV[0]);
exit 1 unless defined $path;
print $path;
' "$1"
}
relative_path() {
/usr/bin/perl -MFile::Spec -e 'print File::Spec->abs2rel($ARGV[1], $ARGV[0])' "$1" "$2"
}
link_target_canonical() {
/usr/bin/perl -MCwd=abs_path -e '
my $path = abs_path($ARGV[0]);
exit 1 unless defined $path;
print $path;
' "$1"
}
target_is_inside_root() {
local root_real="$1"
local target_real="$2"
[[ "${target_real}" == "${root_real}" || "${target_real}" == "${root_real}/"* ]]
}
backup_for() {
printf '%s%s' "$1" "${BACKUP_SUFFIX}"
}
classify_existing_conversion() {
local alias_file="$1"
local target="$2"
local backup="$3"
local backup_target=""
[[ -L "${alias_file}" || -e "${backup}" || -L "${backup}" ]] || return 1
if [[ -e "${backup}" && ! -L "${backup}" ]] && is_retained_finder_alias "${backup}"; then
backup_target="$(resolve_alias "${backup}" 2>/dev/null || true)"
fi
if [[ -L "${alias_file}" && -n "${backup_target}" && "${backup_target}" == "${target}" ]]; then
return 0
fi
if [[ -e "${backup}" || -L "${backup}" ]]; then
return 0
fi
return 1
}
convert_alias() {
local alias_file="$1"
local target="$2"
local link_target="$3"
local classification="$4"
local backup
local link_real
local target_real
backup="$(backup_for "${alias_file}")"
if [[ -e "${backup}" || -L "${backup}" ]]; then
log "[SKIP] [ALREADY_CONVERTED] backup exists: ${backup}"
already_converted_count=$((already_converted_count + 1))
skipped_count=$((skipped_count + 1))
write_report "errors" "ALREADY_CONVERTED" "${alias_file}" "${target}" "backup already exists"
return 0
fi
log "[OK] [${classification}] converting: ${alias_file} -> ${link_target}"
if [[ "${MODE}" == "dry-run" ]]; then
log "[DRY-RUN] mv -- ${alias_file} ${backup}"
log "[DRY-RUN] ln -s -- ${link_target} ${alias_file}"
return 0
fi
/bin/mv -- "${alias_file}" "${backup}" || {
log "[ERROR] cannot move Finder alias to backup: ${alias_file}"
error_count=$((error_count + 1))
write_report "errors" "ERROR" "${alias_file}" "${target}" "failed to move alias to backup"
return 1
}
if ! /bin/ln -s -- "${link_target}" "${alias_file}"; then
/bin/rm -f -- "${alias_file}"
/bin/mv -- "${backup}" "${alias_file}"
log "[ERROR] failed to create symlink, original restored: ${alias_file}"
error_count=$((error_count + 1))
write_report "errors" "ERROR" "${alias_file}" "${target}" "failed to create symlink"
return 1
fi
if [[ ! -L "${alias_file}" || ! -e "${alias_file}" ]]; then
/bin/rm -f -- "${alias_file}"
/bin/mv -- "${backup}" "${alias_file}"
log "[ERROR] invalid symlink, original restored: ${alias_file}"
error_count=$((error_count + 1))
write_report "errors" "ERROR" "${alias_file}" "${target}" "symlink validation failed"
return 1
fi
link_real="$(link_target_canonical "${alias_file}" 2>/dev/null || true)"
target_real="$(canonical_path "${target}" 2>/dev/null || true)"
if [[ -z "${link_real}" || -z "${target_real}" || "${link_real}" != "${target_real}" ]]; then
/bin/rm -f -- "${alias_file}"
/bin/mv -- "${backup}" "${alias_file}"
log "[ERROR] symlink resolves to unexpected target, original restored: ${alias_file}"
error_count=$((error_count + 1))
write_report "errors" "ERROR" "${alias_file}" "${target}" "symlink target mismatch"
return 1
fi
converted_count=$((converted_count + 1))
log "[OK ${classification}] ${alias_file} -> ${link_target}"
}
process_alias() {
local alias_file="$1"
local target=""
local target_real=""
local alias_parent=""
local link_target=""
local backup=""
aliases_found=$((aliases_found + 1))
backup="$(backup_for "${alias_file}")"
if ! target="$(resolve_alias "${alias_file}" 2>/dev/null)"; then
broken_count=$((broken_count + 1))
skipped_count=$((skipped_count + 1))
log "[SKIP] [BROKEN] ${alias_file}"
write_report "broken" "BROKEN" "${alias_file}" "" "Finder/Foundation could not resolve alias"
return 0
fi
if [[ -z "${target}" || "${target}" == "${alias_file}" ]]; then
self_target_count=$((self_target_count + 1))
skipped_count=$((skipped_count + 1))
log "[SKIP] [SELF_TARGET] ${alias_file} -> ${target}"
write_report "broken" "SELF_TARGET" "${alias_file}" "${target}" "resolver returned the alias path itself"
return 0
fi
if [[ ! -e "${target}" ]]; then
missing_target_count=$((missing_target_count + 1))
skipped_count=$((skipped_count + 1))
log "[SKIP] [MISSING_TARGET] ${alias_file} -> ${target}"
write_report "broken" "MISSING_TARGET" "${alias_file}" "${target}" "resolved target does not exist"
return 0
fi
target_real="$(canonical_path "${target}" 2>/dev/null || true)"
if [[ -z "${target_real}" ]]; then
error_count=$((error_count + 1))
skipped_count=$((skipped_count + 1))
log "[ERROR] cannot canonicalize target: ${alias_file} -> ${target}"
write_report "errors" "ERROR" "${alias_file}" "${target}" "cannot canonicalize target"
return 0
fi
if classify_existing_conversion "${alias_file}" "${target}" "${backup}"; then
already_converted_count=$((already_converted_count + 1))
skipped_count=$((skipped_count + 1))
log "[SKIP] [ALREADY_CONVERTED] ${alias_file}"
write_report "errors" "ALREADY_CONVERTED" "${alias_file}" "${target}" "matching link or retained backup already exists"
return 0
fi
if target_is_inside_root "${ROOT_REAL}" "${target_real}"; then
internal_count=$((internal_count + 1))
alias_parent="${alias_file%/*}"
link_target="$(relative_path "${alias_parent}" "${target}")"
log "[INTERNAL] ${alias_file} -> ${target}"
write_report "internal" "INTERNAL" "${alias_file}" "${target}" "target is inside ROOT"
convert_alias "${alias_file}" "${target}" "${link_target}" "INTERNAL"
else
external_count=$((external_count + 1))
log "[WARNING] [EXTERNAL] ${alias_file} -> ${target}"
log "[WARNING] External absolute links may break on backup mounts."
write_report "external" "EXTERNAL" "${alias_file}" "${target}" "target is outside ROOT"
if [[ "${MODE}" == "apply" && "${INCLUDE_EXTERNAL}" == true ]]; then
convert_alias "${alias_file}" "${target}" "${target}" "EXTERNAL"
else
skipped_count=$((skipped_count + 1))
fi
fi
}
scan_aliases() {
local path
local content_type
while IFS= read -r -d '' path; do
((FILES_INSPECTED += 1))
[[ "$path" == *.finder-alias ]] && continue
content_type=$(
/usr/bin/mdls \
-raw \
-name kMDItemContentType \
"$path" 2>/dev/null
) || {
((METADATA_FAILURES += 1))
continue
}
if [[ "$content_type" == "com.apple.alias-file" ]]; then
process_alias "$path"
fi
done < <(/usr/bin/find "$ROOT" -type f -print0)
}
restore_backups() {
local backup original
while IFS= read -r -d '' backup; do
original="${backup%"${BACKUP_SUFFIX}"}"
if [[ ! -L "${original}" ]]; then
log "[SKIP] restore conflict, original is not a generated symlink: ${original}"
skipped_count=$((skipped_count + 1))
continue
fi
if ! is_retained_finder_alias "${backup}"; then
log "[SKIP] restore backup is not a Finder alias: ${backup}"
skipped_count=$((skipped_count + 1))
continue
fi
/bin/rm -f -- "${original}" || {
log "[ERROR] cannot remove generated symlink: ${original}"
error_count=$((error_count + 1))
continue
}
/bin/mv -- "${backup}" "${original}" || {
log "[ERROR] cannot restore Finder alias: ${backup}"
error_count=$((error_count + 1))
continue
}
restored_count=$((restored_count + 1))
log "[OK] restored Finder alias: ${original}"
done < <(/usr/bin/find "${ROOT}" -type f -name "*${BACKUP_SUFFIX}" -print0)
}
cleanup_backups() {
local backup original backup_target link_real backup_real
while IFS= read -r -d '' backup; do
original="${backup%"${BACKUP_SUFFIX}"}"
if [[ ! -L "${original}" || ! -e "${original}" ]]; then
log "[SKIP] cleanup needs a working symlink: ${original}"
skipped_count=$((skipped_count + 1))
continue
fi
if ! is_retained_finder_alias "${backup}"; then
log "[SKIP] cleanup backup is not a Finder alias: ${backup}"
skipped_count=$((skipped_count + 1))
continue
fi
backup_target="$(resolve_alias "${backup}" 2>/dev/null || true)"
link_real="$(link_target_canonical "${original}" 2>/dev/null || true)"
backup_real="$(canonical_path "${backup_target}" 2>/dev/null || true)"
if [[ -z "${backup_target}" || -z "${link_real}" || -z "${backup_real}" || "${link_real}" != "${backup_real}" ]]; then
log "[SKIP] cleanup target mismatch, retaining backup: ${backup}"
skipped_count=$((skipped_count + 1))
continue
fi
/bin/rm -f -- "${backup}" || {
log "[ERROR] cannot delete retained backup: ${backup}"
error_count=$((error_count + 1))
continue
}
cleaned_count=$((cleaned_count + 1))
log "[OK] deleted retained backup: ${backup}"
done < <(/usr/bin/find "${ROOT}" -type f -name "*${BACKUP_SUFFIX}" -print0)
}
print_summary() {
log ""
log "Files inspected: ${FILES_INSPECTED}"
log "Metadata failures: ${METADATA_FAILURES}"
log "Finder aliases found: ${aliases_found}"
log "Internal resolvable: ${internal_count}"
log "External resolvable: ${external_count}"
log "Broken: ${broken_count}"
log "Self-target: ${self_target_count}"
log "Missing target: ${missing_target_count}"
log "Already converted: ${already_converted_count}"
log "Converted successfully: ${converted_count}"
log "Restored successfully: ${restored_count}"
log "Backups cleaned: ${cleaned_count}"
log "Skipped: ${skipped_count}"
log "Errors: ${error_count}"
log "Mode: ${MODE}"
}
main() {
trap cleanup_tmp EXIT
case "${1:-}" in
-h|--help)
usage
exit 0
;;
-v|--version)
printf '%s %s\n' "${SCRIPT_NAME}" "${VERSION}"
exit 0
;;
esac
load_config
parse_args "$@"
require_root
require_tools
setup_logging
build_resolver
ROOT_REAL="$(canonical_path "${ROOT}" 2>/dev/null || true)"
if [[ -z "${ROOT_REAL}" ]]; then
printf 'ERROR: cannot canonicalize ROOT: %s\n' "${ROOT}" >&2
exit 3
fi
log "${SCRIPT_NAME} ${VERSION}"
log "ROOT: ${ROOT}"
log "Mode: ${MODE}"
[[ "${MODE}" == "apply" && "${INCLUDE_EXTERNAL}" == true ]] && \
log "[WARNING] --include-external selected; external links will be absolute."
case "${MODE}" in
dry-run|apply)
scan_aliases
;;
restore)
restore_backups
;;
cleanup)
cleanup_backups
;;
*)
die_args "invalid mode: ${MODE}"
;;
esac
print_summary
[[ "${error_count}" -eq 0 ]] || exit 1
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment