Skip to content

Instantly share code, notes, and snippets.

@PhrozenByte
Last active June 18, 2025 22:25
Show Gist options
  • Save PhrozenByte/478961da706de3896881609829d092d6 to your computer and use it in GitHub Desktop.
Save PhrozenByte/478961da706de3896881609829d092d6 to your computer and use it in GitHub Desktop.
Bind mounts a directory with mapped IDs.
#!/bin/bash
##
# Bind mounts a directory with mapped IDs
#
# This script allows users running Linux 5.12 and later to easily create bind
# mounts with mapped IDs. For example, `bindfs.sh --idmap foo:bar src dst` bind
# mounts './src/' to './dst/', but all files owned by 'foo' appear as if they
# were owned by 'bar' instead. Files and directories owned by different users
# are kept as-is. This is the key difference to `mount --bind --map-users`.
# Usually one must be 'root' to run this script.
#
# Usage:
# bindfs.sh [--verbose] [--idmap FROM_USER:TO_USER]... SOURCE_DIR TARGET_DIR
#
# Copyright (C) 2025 Daniel Rudolf (<https://www.daniel-rudolf.de>)
# License: The MIT License <http://opensource.org/licenses/MIT>
#
# SPDX-License-Identifier: MIT
set -eu -o pipefail
export LC_ALL=C.UTF-8
# check script dependencies
[ -x "$(which find 2> /dev/null)" ] || { echo "Missing script dependency: find" >&2; exit 1; }
[ -x "$(which mount 2> /dev/null)" ] || { echo "Missing script dependency: mount" >&2; exit 1; }
# functions
print_usage() {
echo "Usage:"
echo " $(basename "${BASH_SOURCE[0]}") [--verbose] [--idmap FROM_USER:TO_USER]... \\"
echo " SOURCE_DIR TARGET_DIR"
}
mount_bind() {
local SRC="$1"
local DST="$2"
shift 2
if (( $# > 0 )); then
local -a ID_MAPS=()
local ID_MAP ID_MAPS_LENGTH
local -a TO_FROM_IDS=()
while read -r ID_MAP; do
[[ "$ID_MAP" =~ ^[0-9]+:[0-9]+$ ]] \
|| { echo "Invalid ID map: $ID_MAP" >&2; return 1; }
(( "${ID_MAP##*:}" != $(id -u "nobody") )) \
|| { echo "Invalid ID map remapping 'nobody': $ID_MAP" >&2; return 1; }
(( "${#TO_FROM_IDS[@]}" == 0 )) \
&& ID_MAPS_LENGTH="$(("${ID_MAP##*:}" + 1))" \
|| (( "${TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]##*:}" != "${ID_MAP##*:}" )) \
|| { echo "Duplicate ID map to remap to ID ${ID_MAP##*:}:" \
"$ID_MAP and ${TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]}" >&2; return 1; }
TO_FROM_IDS+=( "$ID_MAP" )
done < <(printf '%s\n' "$@" | sort -t : -k 2hr -k 1hr)
local -a FROM_TO_IDS=()
while read -r ID_MAP; do
(( "${#FROM_TO_IDS[@]}" == 0 )) || (( "${FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]%%:*}" != "${ID_MAP%%:*}" )) \
|| { echo "Duplicate ID map to remap from ID ${ID_MAP%%:*}:" \
"$ID_MAP and ${FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]}" >&2; return 1; }
FROM_TO_IDS+=( "$ID_MAP" )
done < <(printf '%s\n' "$@" | sort -t : -k 1hr -k 2hr)
# minimum map length is 65536 IDs
(( ID_MAPS_LENGTH >= 65536 )) || ID_MAPS_LENGTH=65536
local ID=0 FROM_ID TO_ID BASE_ID COUNT
while true; do
if (( "${#FROM_TO_IDS[@]}" == 0 )) && (( "${#TO_FROM_IDS[@]}" == 0 )); then
# there are no more maps to handle
# create a final mapping to retain all IDs until the end of the mapping
BASE_ID="$ID_MAPS_LENGTH"
elif (( "${#FROM_TO_IDS[@]}" == 0 )) \
|| { (( "${#TO_FROM_IDS[@]}" > 0 )) \
&& (( "${TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]##*:}" <= "${FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]%%:*}" )) \
}
then
# TO_FROM maps represent the maps the user requested
FROM_ID="${TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]%%:*}"
TO_ID="${TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]##*:}"
unset "TO_FROM_IDS[${#TO_FROM_IDS[@]}-1]"
BASE_ID="$TO_ID"
else
# we can't retain ID 1000 if there's an explicit TO_FROM map from ID 1000 (i.e. "1000:*")
# that's why we also create a implicit FROM_TO map, which tells us to skip ID 1000
FROM_ID="${FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]%%:*}"
TO_ID="${FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]##*:}"
unset "FROM_TO_IDS[${#FROM_TO_IDS[@]}-1]"
if (( FROM_ID < ID )); then
# … however, we can still explicitly map another ID to the now unmapped ID 1000 (i.e. "*:1000")
# we then have both a implicit FROM_TO map for "1000:*", and a explicit TO_FROM map for "*:1000"
# TO_FROM maps are handled first; the following FROM_TO map is obsolete and can be skipped
continue
elif (( FROM_ID > ID_MAPS_LENGTH )); then
# implicit FROM_TO maps might protrude beyond the map length
BASE_ID="$ID_MAPS_LENGTH"
else
BASE_ID="$FROM_ID"
fi
fi
if (( ID < BASE_ID )); then
# retain IDs if they are neither mapped to other IDs (e.g. "1000:*"),
# nor if other IDs are mapped to the IDs to retain (e.g. "*:1000")
COUNT="$((BASE_ID - ID))"
ID_MAPS+=( "$ID:$ID:$COUNT" )
(( ID += COUNT ))
fi
if (( ID >= ID_MAPS_LENGTH )); then
# we've reached the end of the mapping
break
fi
if (( BASE_ID == TO_ID )); then
# the user requested to map $FROM_ID to $TO_ID (== $BASE_ID == $ID)
ID_MAPS+=( "$FROM_ID:$TO_ID:1" )
fi
(( ++ID ))
done
[ -z "$VERBOSE" ] || echo + "mount -o bind,X-mount.idmap=$(printf '%q' "${ID_MAPS[*]}") ${SRC@Q} ${DST@Q}" >&2
mount -o bind,X-mount.idmap="${ID_MAPS[*]}" "$SRC" "$DST"
else
[ -z "$VERBOSE" ] || echo + "mount -o bind ${SRC@Q} ${DST@Q}" >&2
mount -o bind "$SRC" "$DST"
fi
}
# read parameters
SOURCE_DIR=
TARGET_DIR=
VERBOSE=
ID_MAPS=()
while (( $# > 0 )); do
case "$1" in
"--help")
print_usage
exit 0
;;
"--verbose")
VERBOSE="y"
shift
;;
"--idmap")
[ -n "${2:-}" ] || { echo "Missing required argument for option '--idmap'" >&2; exit 1; }
for ID_MAP in $2; do
[[ "$ID_MAP" =~ ^([a-zA-Z][a-zA-Z0-9_.-]{0,31}|[1-9][0-9]*):([a-zA-Z][a-zA-Z0-9_.-]{0,31}|[1-9][0-9]*)$ ]] \
|| { echo "Invalid ID map: $2" >&2; exit 1; }
FROM_ID="$(id -u "${BASH_REMATCH[1]}" 2> /dev/null || true)"
[ -n "$FROM_ID" ] || { echo "Invalid user in ID map: ${BASH_REMATCH[1]}" >&2; exit 1; }
TO_ID="$(id -u "${BASH_REMATCH[2]}" 2> /dev/null || true)"
[ -n "$TO_ID" ] || { echo "Invalid user in ID map: ${BASH_REMATCH[2]}" >&2; exit 1; }
ID_MAPS+=( "$FROM_ID:$TO_ID" )
done
shift 2
;;
*)
if [ -z "$SOURCE_DIR" ]; then
[ -e "$1" ] || { echo "Invalid bind mount source directory ${1@Q}: No such file or directory" >&2; exit 1; }
[ -d "$1" ] || { echo "Invalid bind mount source directory ${1@Q}: Not a directory" >&2; exit 1; }
SOURCE_DIR="$1"
shift
elif [ -z "$TARGET_DIR" ]; then
[ -e "$1" ] || { echo "Invalid bind mount target directory ${1@Q}: No such file or directory" >&2; exit 1; }
[ -d "$1" ] || { echo "Invalid bind mount target directory ${1@Q}: Not a directory" >&2; exit 1; }
TARGET_DIR="$1"
shift
else
echo "Invalid argument: $1" >&2
exit 1
fi
;;
esac
done
# mount directory
[ -n "$SOURCE_DIR" ] || { print_usage >&2; exit 1; }
[ -n "$TARGET_DIR" ] || { print_usage >&2; exit 1; }
mount_bind "$SOURCE_DIR" "$TARGET_DIR" "${ID_MAPS[@]}"
exit $?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment