#!/usr/bin/env bash set -eu set -o pipefail # I'm a little agnostic on the choices, but supporting a wide # slate of uses for now, including: # - import-only: `. create-darwin-volume.sh no-main[ ...]` # - legacy: `./create-darwin-volume.sh` or `. create-darwin-volume.sh` # (both will run main()) # - external alt-routine: `./create-darwin-volume.sh no-main func[ ...]` if [ "${1-}" = "no-main" ]; then shift readonly _CREATE_VOLUME_NO_MAIN=1 else readonly _CREATE_VOLUME_NO_MAIN=0 # declare some things we expect to inherit from install-multi-user # I don't love this (because it's a bit of a kludge). # # CAUTION: (Dec 19 2020) # This is a stopgap. It doesn't cover the full slate of # identifiers we inherit--just those necessary to: # - avoid breaking direct invocations of this script (here/now) # - avoid hard-to-reverse structural changes before the call to rm # single-user support is verified # # In the near-mid term, I (personally) think we should: # - decide to deprecate the direct call and add a notice # - fold all of this into install-darwin-multi-user.sh # - intentionally remove the old direct-invocation form (kill the # routine, replace this script w/ deprecation notice and a note # on the remove-after date) # readonly NIX_ROOT="${NIX_ROOT:-/nix}" _sudo() { shift # throw away the 'explanation' /usr/bin/sudo "$@" } failure() { if [ "$*" = "" ]; then cat else echo "$@" fi exit 1 } task() { echo "$@" } fi # usually "disk1" root_disk_identifier() { # For performance (~10ms vs 280ms) I'm parsing 'diskX' from stat output # (~diskXsY)--but I'm retaining the more-semantic approach since # it documents intent better. # /usr/sbin/diskutil info -plist / | xmllint --xpath "/plist/dict/key[text()='ParentWholeDisk']/following-sibling::string[1]/text()" - # local special_device special_device="$(/usr/bin/stat -f "%Sd" /)" echo "${special_device%s[0-9]*}" } # make it easy to play w/ 'Case-sensitive APFS' readonly NIX_VOLUME_FS="${NIX_VOLUME_FS:-APFS}" readonly NIX_VOLUME_LABEL="${NIX_VOLUME_LABEL:-Nix Store}" # Strongly assuming we'll make a volume on the device / is on # But you can override NIX_VOLUME_USE_DISK to create it on some other device readonly NIX_VOLUME_USE_DISK="${NIX_VOLUME_USE_DISK:-$(root_disk_identifier)}" NIX_VOLUME_USE_SPECIAL="${NIX_VOLUME_USE_SPECIAL:-}" NIX_VOLUME_USE_UUID="${NIX_VOLUME_USE_UUID:-}" readonly NIX_VOLUME_MOUNTD_DEST="${NIX_VOLUME_MOUNTD_DEST:-/Library/LaunchDaemons/org.nixos.darwin-store.plist}" if /usr/bin/fdesetup isactive >/dev/null; then test_filevault_in_use() { return 0; } # no readonly; we may modify if user refuses from cure_volume NIX_VOLUME_DO_ENCRYPT="${NIX_VOLUME_DO_ENCRYPT:-1}" else test_filevault_in_use() { return 1; } NIX_VOLUME_DO_ENCRYPT="${NIX_VOLUME_DO_ENCRYPT:-0}" fi should_encrypt_volume() { test_filevault_in_use && (( NIX_VOLUME_DO_ENCRYPT == 1 )) } substep() { printf " %s\n" "" "- $1" "" "${@:2}" } volumes_labeled() { local label="$1" xsltproc --novalid --stringparam label "$label" - <(/usr/sbin/ioreg -ra -c "AppleAPFSVolume") <<'EOF' = EOF # I cut label out of the extracted values, but here it is for reference: # # = } right_disk() { local volume_special="$1" # (i.e., disk1s7) [[ "$volume_special" == "$NIX_VOLUME_USE_DISK"s* ]] } right_volume() { local volume_special="$1" # (i.e., disk1s7) # if set, it must match; otherwise ensure it's on the right disk if [ -z "$NIX_VOLUME_USE_SPECIAL" ]; then if right_disk "$volume_special"; then NIX_VOLUME_USE_SPECIAL="$volume_special" # latch on return 0 else return 1 fi else [ "$volume_special" = "$NIX_VOLUME_USE_SPECIAL" ] fi } right_uuid() { local volume_uuid="$1" # if set, it must match; otherwise allow if [ -z "$NIX_VOLUME_USE_UUID" ]; then NIX_VOLUME_USE_UUID="$volume_uuid" # latch on return 0 else [ "$volume_uuid" = "$NIX_VOLUME_USE_UUID" ] fi } cure_volumes() { local found volume special uuid # loop just in case they have more than one volume # (nothing stops you from doing this) for volume in $(volumes_labeled "$NIX_VOLUME_LABEL"); do # CAUTION: this could (maybe) be a more normal read # loop like: # while IFS== read -r special uuid; do # # ... # done <<<"$(volumes_labeled "$NIX_VOLUME_LABEL")" # # I did it with for to skirt a problem with the obvious # pattern replacing stdin and causing user prompts # inside (which also use read and access stdin) to skip # # If there's an existing encrypted volume we can't find # in keychain, the user never gets prompted to delete # the volume, and the install fails. # # If you change this, a human needs to test a very # specific scenario: you already have an encrypted # Nix Store volume, and have deleted its credential # from keychain. Ensure the script asks you if it can # delete the volume, and then prompts for your sudo # password to confirm. # # shellcheck disable=SC1097 IFS== read -r special uuid <<< "$volume" # take the first one that's on the right disk if [ -z "${found:-}" ]; then if right_volume "$special" && right_uuid "$uuid"; then cure_volume "$special" "$uuid" found="${special} (${uuid})" else warning < # Cryptographic user for (1 found) # Cryptographic users for (2 found) /usr/sbin/diskutil apfs listCryptoUsers -plist "$volume_special" | /usr/bin/grep -q APFSCryptoUserUUID } test_fstab() { /usr/bin/grep -q "$NIX_ROOT apfs rw" /etc/fstab 2>/dev/null } test_nix_root_is_symlink() { [ -L "$NIX_ROOT" ] } test_synthetic_conf_either(){ /usr/bin/grep -qE "^${NIX_ROOT:1}($|\t.{3,}$)" /etc/synthetic.conf 2>/dev/null } test_synthetic_conf_mountable() { /usr/bin/grep -q "^${NIX_ROOT:1}$" /etc/synthetic.conf 2>/dev/null } test_synthetic_conf_symlinked() { /usr/bin/grep -qE "^${NIX_ROOT:1}\t.{3,}$" /etc/synthetic.conf 2>/dev/null } test_nix_volume_mountd_installed() { test -e "$NIX_VOLUME_MOUNTD_DEST" } # current volume password test_keychain_by_uuid() { local volume_uuid="$1" # Note: doesn't need sudo just to check; doesn't output pw security find-generic-password -s "$volume_uuid" &>/dev/null } get_volume_pass() { local volume_uuid="$1" _sudo \ "to confirm keychain has a password that unlocks this volume" \ security find-generic-password -s "$volume_uuid" -w } verify_volume_pass() { local volume_special="$1" # (i.e., disk1s7) local volume_uuid="$2" _sudo "to confirm the password actually unlocks the volume" \ /usr/sbin/diskutil apfs unlockVolume "$volume_special" -verify -stdinpassphrase -user "$volume_uuid" } volume_pass_works() { local volume_special="$1" # (i.e., disk1s7) local volume_uuid="$2" get_volume_pass "$volume_uuid" | verify_volume_pass "$volume_special" "$volume_uuid" } # Create the paths defined in synthetic.conf, saving us a reboot. create_synthetic_objects() { # Big Sur takes away the -B flag we were using and replaces it # with a -t flag that appears to do the same thing (but they # don't behave exactly the same way in terms of return values). # This feels a little dirty, but as far as I can tell the # simplest way to get the right one is to just throw away stderr # and call both... :] { /System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util -t || true # Big Sur /System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util -B || true # Catalina } >/dev/null 2>&1 } test_nix() { test -d "$NIX_ROOT" } test_voldaemon() { test -f "$NIX_VOLUME_MOUNTD_DEST" } generate_mount_command() { local cmd_type="$1" # encrypted|unencrypted local volume_uuid mountpoint cmd=() printf -v volume_uuid "%q" "$2" printf -v mountpoint "%q" "$NIX_ROOT" case "$cmd_type" in encrypted) cmd=(/bin/sh -c "/usr/bin/security find-generic-password -s '$volume_uuid' -w | /usr/sbin/diskutil apfs unlockVolume '$volume_uuid' -mountpoint '$mountpoint' -stdinpassphrase");; unencrypted) cmd=(/usr/sbin/diskutil mount -mountPoint "$mountpoint" "$volume_uuid");; *) failure "Invalid first arg $cmd_type to generate_mount_command";; esac printf " %s\n" "${cmd[@]}" } generate_mount_daemon() { local cmd_type="$1" # encrypted|unencrypted local volume_uuid="$2" cat < RunAtLoad Label org.nixos.darwin-store ProgramArguments $(generate_mount_command "$cmd_type" "$volume_uuid") EOF } _eat_bootout_err() { /usr/bin/grep -v "Boot-out failed: 36: Operation now in progress" } # TODO: remove with --uninstall? uninstall_launch_daemon_directions() { local daemon_label="$1" # i.e., org.nixos.blah-blah local daemon_plist="$2" # abspath substep "Uninstall LaunchDaemon $daemon_label" \ " sudo launchctl bootout system/$daemon_label" \ " sudo rm $daemon_plist" } uninstall_launch_daemon_prompt() { local daemon_label="$1" # i.e., org.nixos.blah-blah local daemon_plist="$2" # abspath local reason_for_daemon="$3" cat < >(_eat_bootout_err >&2) || true # this can "fail" with a message like: # Boot-out failed: 36: Operation now in progress _sudo "to remove the daemon definition" rm "$daemon_plist" fi } nix_volume_mountd_uninstall_directions() { uninstall_launch_daemon_directions "org.nixos.darwin-store" \ "$NIX_VOLUME_MOUNTD_DEST" } nix_volume_mountd_uninstall_prompt() { uninstall_launch_daemon_prompt "org.nixos.darwin-store" \ "$NIX_VOLUME_MOUNTD_DEST" \ "mount your Nix volume" } # TODO: move nix_daemon to install-darwin-multi-user if/when uninstall_launch_daemon_prompt moves up to install-multi-user nix_daemon_uninstall_prompt() { uninstall_launch_daemon_prompt "org.nixos.nix-daemon" \ "$NIX_DAEMON_DEST" \ "run the nix-daemon" } # TODO: remove with --uninstall? nix_daemon_uninstall_directions() { uninstall_launch_daemon_directions "org.nixos.nix-daemon" \ "$NIX_DAEMON_DEST" } # TODO: remove with --uninstall? synthetic_conf_uninstall_directions() { # :1 to strip leading slash substep "Remove ${NIX_ROOT:1} from /etc/synthetic.conf" \ " If nix is the only entry: sudo rm /etc/synthetic.conf" \ " Otherwise: sudo /usr/bin/sed -i '' -e '/^${NIX_ROOT:1}$/d' /etc/synthetic.conf" } synthetic_conf_uninstall_prompt() { cat < "$SCRATCH/synthetic.conf.edit" if test_synthetic_conf_symlinked; then warning </dev/null; then if confirm_rm "/etc/synthetic.conf"; then if test_nix_root_is_symlink; then failure >&2 < $(readlink "$NIX_ROOT")). The system should remove it when you reboot. Once you've rebooted, run the installer again. EOF fi return 0 fi else if confirm_edit "$SCRATCH/synthetic.conf.edit" "/etc/synthetic.conf"; then if test_nix_root_is_symlink; then failure >&2 < $(readlink "$NIX_ROOT")). The system should remove it when you reboot. Once you've rebooted, run the installer again. EOF fi return 0 fi fi # fallback instructions echo "Manually remove nix from /etc/synthetic.conf" return 1 } add_nix_vol_fstab_line() { local uuid="$1" # shellcheck disable=SC1003,SC2026 local escaped_mountpoint="${NIX_ROOT/ /'\\\'040}" shift # wrap `ex` to work around problems w/ vim features breaking exit codes # - plugins (see github.com/NixOS/nix/issues/5468): -u NONE # - swap file: -n # # the first draft used `--noplugin`, but github.com/NixOS/nix/issues/6462 # suggests we need the less-semantic `-u NONE` # # we'd prefer EDITOR="/usr/bin/ex -u NONE" but vifs doesn't word-split # the EDITOR env. # # TODO: at some point we should switch to `--clean`, but it wasn't added # until https://github.com/vim/vim/releases/tag/v8.0.1554 while the macOS # minver 10.12.6 seems to have released with vim 7.4 cat > "$SCRATCH/ex_cleanroom_wrapper" <multi-user reinstalls, which may cover this) # # I'm not sure if it's safe to approach this way? # # I think I think the most-proper way to test for it is: # diskutil info -plist "$NIX_VOLUME_LABEL" | xmllint --xpath "(/plist/dict/key[text()='GlobalPermissionsEnabled'])/following-sibling::*[1][name()='true']" -; echo $? # # There's also `sudo /usr/sbin/vsdbutil -c /path` (which is much faster, but is also # deprecated and needs minor parsing). # # If no one finds a problem with doing so, I think the simplest approach # is to just eagerly set this. I found a few imperative approaches: # (diskutil enableOwnership, ~100ms), a cheap one (/usr/sbin/vsdbutil -a, ~40-50ms), # a very cheap one (append the internal format to /var/db/volinfo.database). # # But vsdbutil's deprecation notice suggests using fstab, so I want to # give that a whirl first. # # TODO: when this is workable, poke infinisil about reproducing the issue # and confirming this fix? } delete_nix_vol_fstab_line() { # TODO: I'm scaffolding this to handle the new nix volumes # but it might be nice to generalize a smidge further to # go ahead and set up a pattern for curing "old" things # we no longer do? EDITOR="/usr/bin/patch" _sudo "to cut nix from fstab" "$@" < <(/usr/bin/diff /etc/fstab <(/usr/bin/grep -v "$NIX_ROOT apfs rw" /etc/fstab)) # leaving some parts out of the grep; people may fiddle this a little? } # TODO: hope to remove with --uninstall fstab_uninstall_directions() { substep "Remove ${NIX_ROOT} from /etc/fstab" \ " If nix is the only entry: sudo rm /etc/fstab" \ " Otherwise, run 'sudo /usr/sbin/vifs' to remove the nix line" } fstab_uninstall_prompt() { cat </dev/null # if the patch test edit, minus comment lines, is equal to empty (:) if /usr/bin/diff -q <(:) <(/usr/bin/grep -v "^#" "$SCRATCH/fstab.edit") &>/dev/null; then # this edit would leave it empty; propose deleting it if confirm_rm "/etc/fstab"; then return 0 else echo "Remove nix from /etc/fstab (or remove the file)" fi else echo "I might be able to help you make this edit. Here's the diff:" if ! _diff "/etc/fstab" "$SCRATCH/fstab.edit" && ui_confirm "Does the change above look right?"; then delete_nix_vol_fstab_line /usr/sbin/vifs else echo "Remove nix from /etc/fstab (or remove the file)" fi fi } remove_volume() { local volume_special="$1" # (i.e., disk1s7) _sudo "to unmount the Nix volume" \ /usr/sbin/diskutil unmount force "$volume_special" || true # might not be mounted _sudo "to delete the Nix volume" \ /usr/sbin/diskutil apfs deleteVolume "$volume_special" } # aspiration: robust enough to both fix problems # *and* update older darwin volumes cure_volume() { local volume_special="$1" # (i.e., disk1s7) local volume_uuid="$2" header "Found existing Nix volume" row " special" "$volume_special" row " uuid" "$volume_uuid" if volume_encrypted "$volume_special"; then row "encrypted" "yes" if volume_pass_works "$volume_special" "$volume_uuid"; then NIX_VOLUME_DO_ENCRYPT=0 ok "Found a working decryption password in keychain :)" echo "" else # - this is a volume we made, and # - the user encrypted it on their own # - something deleted the credential # - this is an old or BYO volume and the pw # just isn't somewhere we can find it. # # We're going to explain why we're freaking out # and prompt them to either delete the volume # (requiring a sudo auth), or abort to fix warning <&2 < $(readlink "$NIX_ROOT")). Please remove it. If nix is in /etc/synthetic.conf, remove it and reboot. EOF fi fi if ! test_synthetic_conf_mountable; then task "Configuring /etc/synthetic.conf to make a mount-point at $NIX_ROOT" >&2 # technically /etc/synthetic.d/nix is supported in Big Sur+ # but handling both takes even more code... # See earlier note; `-u NONE` disables vim plugins/rc, `-n` skips swapfile _sudo "to add Nix to /etc/synthetic.conf" \ /usr/bin/ex -u NONE -n /etc/synthetic.conf <&2 fi create_synthetic_objects if ! test_nix; then failure >&2 <&2 add_nix_vol_fstab_line "$volume_uuid" /usr/sbin/vifs fi } encrypt_volume() { local volume_uuid="$1" local volume_label="$2" local password task "Encrypt the Nix volume" >&2 # Note: mount/unmount are late additions to support the right order # of operations for creating the volume and then baking its uuid into # other artifacts; not as well-trod wrt to potential errors, race # conditions, etc. _sudo "to mount your Nix volume for encrypting" \ /usr/sbin/diskutil mount "$volume_label" password="$(/usr/bin/xxd -l 32 -p -c 256 /dev/random)" _sudo "to add your Nix volume's password to Keychain" \ /usr/bin/security -i </dev/null; do : done } setup_volume() { local use_special use_uuid profile_packages task "Creating a Nix volume" >&2 use_special="${NIX_VOLUME_USE_SPECIAL:-$(create_volume)}" _sudo "to ensure the Nix volume is not mounted" \ /usr/sbin/diskutil unmount force "$use_special" || true # might not be mounted use_uuid=${NIX_VOLUME_USE_UUID:-$(volume_uuid_from_special "$use_special")} setup_fstab "$use_uuid" if should_encrypt_volume; then encrypt_volume "$use_uuid" "$NIX_VOLUME_LABEL" setup_volume_daemon "encrypted" "$use_uuid" # TODO: might be able to save ~60ms by caching or setting # this somewhere rather than re-checking here. elif volume_encrypted "$use_special"; then setup_volume_daemon "encrypted" "$use_uuid" else setup_volume_daemon "unencrypted" "$use_uuid" fi await_volume if [ "$(/usr/sbin/diskutil info -plist "$NIX_ROOT" | xmllint --xpath "(/plist/dict/key[text()='GlobalPermissionsEnabled'])/following-sibling::*[1]" -)" = "" ]; then _sudo "to set enableOwnership (enabling users to own files)" \ /usr/sbin/diskutil enableOwnership "$NIX_ROOT" fi # TODO: below is a vague kludge for now; I just don't know # what if any safe action there is to take here. Also, the # reminder isn't very helpful. # I'm less sure where this belongs, but it also wants mounted, pre-install if type -p nix-env; then profile_packages="$(nix-env --query --installed)" # TODO: can probably do below faster w/ read # intentionally unquoted string to eat whitespace in wc output # shellcheck disable=SC2046,SC2059 if ! [ $(printf "$profile_packages" | /usr/bin/wc -l) = "0" ]; then reminder <&2 # See earlier note; `-u NONE` disables vim plugins/rc, `-n` skips swapfile _sudo "to install the Nix volume mounter" /usr/bin/ex -u NONE -n "$NIX_VOLUME_MOUNTD_DEST" <&2 setup_darwin_volume } main "$@" fi