User namespaces are underrated, or: How to trick CLion's Rust plugin into working well with Nix dev shells

(This is written for CLion and Rust development, but can be applied to pretty much everything else that has user-wide settings or settings that can’t easily be changed using environment variables that store a fixed path to some tools that should be determined by the dev shell.)

Caveat: this doesn’t work on macOS since it doesn’t have namespaces or any equivalent as far as I can tell. boooo

NB: The /Volumes directory I talk about here has to already exist on the real file system for this to work. Feel free to change it inside the nswrap script, maybe to some empty folder under /run created by tmpfilesd, since it most likely will not exist on your system.

I once again got frustrated at the bad experience with CLion when using it together with nix shells, since its Rust plugin expects you to set the path to both rustc/cargo and the Rust library sources, which isn’t really possible, or at the very least, fairly annoying, with dev shells. In the past I solved this by building a rust symlink in the current project and pointing it there. However, that’s not really ideal because if you leave a bunch of these around they fill up your disk because they won’t get collected, and you have to configure it for every new project, and you also have to remember to update it when you update the nixpkgs you use for the project.

So, I came up with a new solution using user namespaces, specifically using the unshare program which creates new namespaces and runs something inside them. This consists of two scripts, nswrap which does most of the work and can be used for something other than clion, and the clion script which is intended to shadow the real CLion executable in environment.systemPackages.

nswrap takes a map of paths and names (argument --bind) and a list of commands (argument --prepare) which it executes during set-up of the user namespace. (The list of commands I added to make setting up more complex hierarchies possible because I was too lazy to make that work for --bind.) These two arguments set up paths under /Volumes. Finally, it also takes the command to execute inside that namespace, for example CLion.

For example, running nswrap --bind '/run/current-system/sw:sw' bash runs a bash shell in which /Volumes/sw will be bind-mounted to /run/current-system/sw.

The clion script just calls nswrap to set up the namespace: if RUST_SRC_PATH environment variable is set (this one is also recognized by other tools such as racer), it will set up a directory structure under /Volumes/Rust containing links to rustc, cargo, and the Rust stdlib sources, which you can then point CLion to as the default path for Rust.

To make it all work, add the two scripts to your system configuration:

nswrap source
#!/usr/bin/env bash

set -e

: "${SETUP_STAGE:=0}"

VOL_PATH=/Volumes

usage() {
	echo "Usage: $0 [OPTION...] [--] PROGRAM ARGS..."
	echo "Bind mounts paths under $VOL_PATH using a user namespace"
	echo
	echo "      --bind=SOURCE:NAME   Bind SOURCE to $VOL_PATH/NAME"
	echo "  -h, --help               Give this help list"
	echo "      --prepare=PROGRAM    Run PROGRAM to prepare mounts"
}

if (( SETUP_STAGE == 0 )); then
	SETUP_STAGE=1 TARGET_USER=$(id -u) TARGET_GROUP=$(id -g) exec unshare -Urm bash "$0" "${@}"
elif (( SETUP_STAGE == 1 )); then
	if ! TEMP=$(getopt -o h -l bind:,help,prepare: -n "$0" -- "${@}"); then
		exit 64
	fi

	eval set -- "$TEMP"
	unset TEMP
	BINDS=()

	while true; do
		case "$1" in
			--bind) BINDS+=("$2"); shift;;
			-h | --help) usage; exit;;
			--prepare) COMMANDS+=("$2"); shift;;
			--) shift; break;;
			*) break;;
		esac
		shift
	done

	tmp=$(mktemp -d)
	mount -t tmpfs -o mode=755 none "$tmp"

    (
	    cd "$tmp"

		for c in "${COMMANDS[@]}"; do
			# shellcheck disable=SC2086
			env ${c}
		done
	)

	for v in "$VOL_PATH"/*; do
		name="${v##*/}"
		BINDS+=("$v:$name")
	done

	for v in "${BINDS[@]}"; do
		src="${v%:*}"
		name="${v##*:}"

		if [[ -e "$tmp"/"$name" ]]; then
			continue
		fi

		if [[ -f "$src" ]]; then
			touch "$tmp"/"$name"
			mount -o bind "$src" "$tmp"/"$name"
		elif [[ -L "$src" ]]; then
			cp -d "$src" "$tmp"/"$name"
		else
			mkdir "$tmp"/"$name"
			mount --rbind "$src" "$tmp"/"$name"
		fi
	done

	mount --move "$tmp" "$VOL_PATH" || true
	rmdir "$tmp"

	SETUP_STAGE=2 exec unshare -U --map-user="$TARGET_USER" --map-group="$TARGET_GROUP" bash "$0" "${@}"
elif (( SETUP_STAGE == 2 )); then
	unset SETUP_STAGE TARGET_USER TARGET_GROUP
	exec "${@}"
fi
clion source
#!/usr/bin/env bash

args=()

: "${RUST_PREPARE:=0}"

if (( RUST_PREPARE )); then
	mkdir Rust
	mount -t tmpfs -o mode=755 none Rust

	mkdir -p Rust/bin Rust/lib/rustlib/src/rust
	ln -s "$(command -v cargo)" "$(command -v rustc)" Rust/bin
	ln -s "$RUST_SRC_PATH" Rust/lib/rustlib/src/rust/library

	exit
fi

if [[ -n "${RUST_ENV_PATH-}" ]]; then
	args+=(--bind "$RUST_ENV_PATH":Rust)
elif [[ -n "${RUST_SRC_PATH-}" ]]; then
	args+=(--prepare "RUST_PREPARE=1 $0")
fi

exec nswrap "${args[@]}" -- clion "${@}"
{lib, pkgs, ...}: let
  inherit (lib) mkBefore mkMerge readFile;
in {
  environment.systemPackages = mkMerge [
    (mkBefore [
      (pkgs.writeShellApplication {
        name = "clion";
        text = readFile ./scripts/clion;
        runtimeInputs = [pkgs.jetbrains.clion];
      })
    ])
    [
      pkgs.jetbrains.clion
      (pkgs.writeShellApplication {
        name = "nswrap";
        text = readFile ./scripts/nswrap;
        runtimeInputs = [pkgs.getopt];
      })
    ]
  ];
}

(You can see this set up in my configuration here.)

and then simply set RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}"; in any Rust dev shells you want to use this with. Running CLion inside those dev shells should then have the /Volumes/Rust directory, with /Volumes/Rust/bin/{cargo,rustc} and /Volumes/Rust/lib/rustlib/src/rust/library, which you can set as the default for new projects in CLion.

Enjoy slightly more hassle-free Rust development with CLion!

3 Likes