Last active
June 18, 2025 22:25
-
-
Save PhrozenByte/478961da706de3896881609829d092d6 to your computer and use it in GitHub Desktop.
Bind mounts a directory with mapped IDs.
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/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