Created
July 19, 2026 11:17
-
-
Save jftuga/f01d46236d9a1f184b68a1eb947b15be to your computer and use it in GitHub Desktop.
flock: Toggle file protection on macOS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/zsh | |
| set -uo pipefail | |
| # flock: Toggle file protection on macOS. | |
| # | |
| # Locks files and directories by stashing the original permissions in an | |
| # xattr (com.flock.mode), stripping write bits, changing ownership to | |
| # root:wheel, and setting the system immutable flag (schg). This prevents | |
| # modification even by the file owner unless the lock is explicitly removed | |
| # with sudo. | |
| # | |
| # Unlocks files and directories by removing the immutable flag, restoring | |
| # ownership to the calling user, and restoring the original permissions | |
| # from the saved xattr. Falls back to 644/755 if no xattr is present. | |
| # | |
| # Usage: | |
| # flock ro [-f] file1 file2 ... # lock (read-only; -f = files only) | |
| # flock rw [-f] file1 file2 ... # unlock (read-write; -f = files only) | |
| # flock ls [file ...] # show protection state (defaults to cwd) | |
| # flock lsl [file ...] # list only locked files | |
| # flock lsu [file ...] # list only unlocked files | |
| # funlock file1 file2 ... # shortcut: equivalent to "flock rw", typically sym-linked | |
| # | |
| # Requires: sudo privileges (ro/rw only). | |
| readonly PGM_VERSION="1.0.0" | |
| readonly CALLER_USER="$USER" | |
| readonly CALLER_GROUP="staff" | |
| readonly XATTR_MODE="com.flock.mode" | |
| # Print usage message and exit. | |
| usage() { | |
| echo "Usage: flock <ro|rw|ls|lsl|lsu> [-f] <file> [file ...]" >&2 | |
| echo " -f files only (skip directories); valid with ro/rw" >&2 | |
| exit 0 | |
| } | |
| # Lock a file or directory by stashing the current mode in an xattr, | |
| # stripping write bits, setting root ownership, and applying the system | |
| # immutable flag (schg). | |
| lock_file() { | |
| local file="$1" | |
| local orig_mode locked_mode | |
| orig_mode="$(stat -f '%Lp' "$file")" | |
| locked_mode="$(printf '%o' $(( 8#${orig_mode} & ~8#0222 )))" | |
| echo "Locking: $file (mode $orig_mode -> $locked_mode)" | |
| sudo xattr -w "$XATTR_MODE" "$orig_mode" "$file" | |
| sudo chown root:wheel "$file" | |
| sudo chmod "$locked_mode" "$file" | |
| sudo chflags schg "$file" | |
| } | |
| # Unlock a file or directory by removing the immutable flag, restoring | |
| # ownership to the calling user, and restoring the original mode from | |
| # the saved xattr. Falls back to 644/755 if no xattr is present. | |
| unlock_file() { | |
| local file="$1" | |
| local orig_mode | |
| echo "Unlocking: $file" | |
| sudo chflags noschg "$file" | |
| sudo chown "${CALLER_USER}:${CALLER_GROUP}" "$file" | |
| if orig_mode="$(sudo xattr -p "$XATTR_MODE" "$file" 2>/dev/null)"; then | |
| sudo chmod "$orig_mode" "$file" | |
| xattr -d "$XATTR_MODE" "$file" | |
| else | |
| if [[ -d "$file" ]]; then | |
| sudo chmod 755 "$file" | |
| else | |
| sudo chmod 644 "$file" | |
| fi | |
| fi | |
| } | |
| # Return 0 if a file should be shown for the given verb, 1 otherwise. | |
| should_show() { | |
| local file="$1" | |
| local verb="$2" | |
| if [[ "$verb" == "ls" ]]; then | |
| return 0 | |
| fi | |
| local flags | |
| flags="$(stat -L -f '%Sf' "$file")" | |
| if [[ "$verb" == "lsl" && "$flags" == *schg* ]]; then | |
| return 0 | |
| elif [[ "$verb" == "lsu" && "$flags" != *schg* ]]; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| # Display the lock state, ownership, mode, and flags of a file or directory. | |
| # Shows the saved original mode if present. Output is padded by filename | |
| # length for aligned columnar display. | |
| show_status() { | |
| local file="$1" | |
| local pad="$2" | |
| local flags owner perms state saved_mode | |
| flags="$(stat -L -f '%Sf' "$file")" | |
| if [[ "$flags" == *schg* ]]; then | |
| state=" LOCKED" | |
| else | |
| state="unlocked" | |
| fi | |
| owner="$(stat -L -f '%Su:%Sg' "$file")" | |
| perms="$(stat -L -f '%Sp' "$file")" | |
| saved_mode="$(xattr -p "$XATTR_MODE" "$file" 2>/dev/null)" || true | |
| if [[ -n "$saved_mode" ]]; then | |
| printf "%-${pad}s %s owner=%s mode=%s saved=%s flags=%s\n" \ | |
| "$file" "$state" "$owner" "$perms" "$saved_mode" "${flags:-(none)}" | |
| else | |
| printf "%-${pad}s %s owner=%s mode=%s flags=%s\n" \ | |
| "$file" "$state" "$owner" "$perms" "${flags:-(none)}" | |
| fi | |
| } | |
| # Parse arguments, validate the verb, and dispatch to the appropriate function. | |
| if [[ $# -lt 1 ]]; then | |
| usage | |
| fi | |
| echo $0 | |
| if [[ "${0:t}" == "funlock" ]]; then | |
| verb="rw" | |
| else | |
| verb="$1" | |
| shift | |
| fi | |
| if [[ "$verb" != "ro" && "$verb" != "rw" && "$verb" != "ls" && "$verb" != "lsl" && "$verb" != "lsu" ]]; then | |
| echo "Error: verb must be 'ro', 'rw', 'ls', 'lsl, or 'lsu', got '$verb'" >&2 | |
| usage | |
| fi | |
| files_only=false | |
| if [[ "$verb" == "ro" || "$verb" == "rw" ]] && [[ ${1:-} == "-f" ]]; then | |
| files_only=true | |
| shift | |
| fi | |
| if [[ $# -eq 0 ]]; then | |
| if [[ "$verb" == ls* ]]; then | |
| set -- *(D) | |
| else | |
| usage | |
| fi | |
| fi | |
| # Find the longest filename for columnar padding, considering only files | |
| # that will actually be displayed for the current verb. | |
| max_len=0 | |
| for file in "$@"; do | |
| if [[ ! -e "$file" ]]; then | |
| continue | |
| fi | |
| if $files_only && [[ -d "$file" ]]; then | |
| continue | |
| fi | |
| if [[ "$verb" == ls* ]] && should_show "$file" "$verb" && (( ${#file} > max_len )); then | |
| max_len=${#file} | |
| fi | |
| done | |
| # Process each target, skipping any that do not exist. | |
| for file in "$@"; do | |
| if [[ ! -e "$file" ]]; then | |
| echo "Error: '$file' does not exist, skipping." >&2 | |
| continue | |
| fi | |
| if $files_only && [[ -d "$file" ]]; then | |
| continue | |
| fi | |
| if [[ "$verb" == ls* ]]; then | |
| should_show "$file" "$verb" && show_status "$file" "$max_len" | |
| elif [[ "$verb" == "ro" ]]; then | |
| lock_file "$file" | |
| else | |
| unlock_file "$file" | |
| fi | |
| done | |
| exit 0 |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See also: HOWTO: File modification and deletion protection on MacOS