├── .envrc ├── .dockerignore ├── src ├── os │ ├── mod.rs │ └── darwin │ │ ├── mod.rs │ │ └── diskutil.rs ├── cli │ ├── arg │ │ ├── mod.rs │ │ └── instrumentation.rs │ ├── subcommand │ │ ├── mod.rs │ │ ├── self_test.rs │ │ └── plan.rs │ └── interaction.rs ├── action │ ├── linux │ │ ├── selinux │ │ │ ├── nix.pp │ │ │ ├── README.md │ │ │ ├── build.sh │ │ │ ├── nix.te │ │ │ └── nix.fc │ │ ├── mod.rs │ │ ├── systemctl_daemon_reload.rs │ │ ├── revert_clean_steamos_nix_offload.rs │ │ ├── ensure_steamos_nix_directory.rs │ │ ├── provision_selinux.rs │ │ └── start_systemd_unit.rs │ ├── common │ │ ├── mod.rs │ │ ├── delete_users.rs │ │ ├── configure_upstream_init_service.rs │ │ ├── setup_channels.rs │ │ └── create_nix_tree.rs │ ├── base │ │ ├── mod.rs │ │ ├── remove_directory.rs │ │ ├── delete_user.rs │ │ └── setup_default_profile.rs │ └── macos │ │ ├── enable_ownership.rs │ │ ├── create_synthetic_objects.rs │ │ ├── configure_remote_building.rs │ │ ├── set_tmutil_exclusion.rs │ │ ├── unmount_apfs_volume.rs │ │ ├── set_tmutil_exclusions.rs │ │ ├── bootstrap_launchctl_service.rs │ │ ├── kickstart_launchctl_service.rs │ │ ├── create_fstab_entry.rs │ │ └── create_apfs_volume.rs ├── bin │ └── nix-installer.rs ├── planner │ └── macos │ │ ├── profile.sample.unknown.plist │ │ ├── profile.sample.block.plist │ │ ├── profile_queries.rs │ │ └── profiles.rs ├── util.rs ├── profile │ ├── mod.rs │ ├── nixenv │ │ └── tests.rs │ └── nixprofile │ │ └── tests.rs ├── self_test.rs ├── error.rs └── lib.rs ├── .cargo └── config.toml ├── rust-toolchain.toml ├── .gitignore ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── update.yml │ └── release-script.yml └── actions │ └── install-nix-action │ └── action.yml ├── rustfmt.toml ├── nix ├── tests │ ├── container-test │ │ ├── default │ │ │ └── Dockerfile │ │ └── default.nix │ └── vm-test │ │ └── vagrant_insecure_key └── check.nix ├── .editorconfig ├── docs ├── troubleshooting.md ├── rust-library.md ├── quirks.md └── building.md ├── tests ├── plan.rs └── windows │ └── test-wsl.ps1 ├── upload_s3.sh ├── enter-env.sh ├── Cargo.toml ├── flake.lock └── assemble_installer.py /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /src/os/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod darwin; 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags=["--cfg", "tokio_unstable"] 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = [ "rustfmt" ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .ci-store 3 | .direnv 4 | result* 5 | src/action/linux/selinux/nix.mod 6 | -------------------------------------------------------------------------------- /src/cli/arg/mod.rs: -------------------------------------------------------------------------------- 1 | mod instrumentation; 2 | pub(crate) use instrumentation::Instrumentation; 3 | -------------------------------------------------------------------------------- /src/action/linux/selinux/nix.pp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NixOS/nix-installer/HEAD/src/action/linux/selinux/nix.pp -------------------------------------------------------------------------------- /src/os/darwin/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod diskutil; 2 | 3 | pub use diskutil::{DiskUtilApfsListOutput, DiskUtilInfoOutput}; 4 | -------------------------------------------------------------------------------- /src/action/linux/selinux/README.md: -------------------------------------------------------------------------------- 1 | To refresh the output `pp` file: 2 | 3 | ```bash 4 | ./build.sh 5 | ``` 6 | 7 | ## Method 8 | 9 | We use the same method and definitions as https://github.com/nix-community/nix-installers/tree/master/selinux. 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "cargo" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | -------------------------------------------------------------------------------- /src/action/linux/selinux/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | checkmodule -M -m -c 5 -o nix.mod nix.te 4 | semodule_package -o nix.pp -m nix.mod -f nix.fc 5 | 6 | checkmodule -M -m -c 5 -o nix.mod nix.te 7 | semodule_package -o determinate-nix.pp -m nix.mod -f determinate-nix.fc 8 | -------------------------------------------------------------------------------- /src/action/linux/selinux/nix.te: -------------------------------------------------------------------------------- 1 | module nix 1.0; 2 | 3 | require { 4 | type bin_t; 5 | type lib_t; 6 | type man_t; 7 | type usr_t; 8 | type etc_t; 9 | type var_run_t; 10 | type systemd_unit_file_t; 11 | type default_t; 12 | type init_t; 13 | class lnk_file read; 14 | } 15 | 16 | allow init_t default_t:lnk_file read; 17 | -------------------------------------------------------------------------------- /src/action/linux/selinux/nix.fc: -------------------------------------------------------------------------------- 1 | /nix/store/[^/]+/s?bin(/.*)? system_u:object_r:bin_t:s0 2 | /nix/store/[^/]+/lib/systemd/system(/.*)? system_u:object_r:systemd_unit_file_t:s0 3 | /nix/store/[^/]+/lib(/.*)? system_u:object_r:lib_t:s0 4 | /nix/store/[^/]+/man(/.*)? system_u:object_r:man_t:s0 5 | /nix/store/[^/]+/etc(/.*)? system_u:object_r:etc_t:s0 6 | /nix/store/[^/]+/share(/.*)? system_u:object_r:usr_t:s0 7 | /nix/var/nix/daemon-socket(/.*)? system_u:object_r:var_run_t:s0 8 | /nix/var/nix/profiles(/per-user/[^/]+)?/[^/]+ system_u:object_r:usr_t:s0 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ##### Description 2 | 3 | 6 | 7 | ##### Checklist 8 | 9 | - [ ] Formatted with `cargo fmt` 10 | - [ ] Built with `nix build` 11 | - [ ] Ran flake checks with `nix flake check` 12 | - [ ] Added or updated relevant tests (leave unchecked if not applicable) 13 | - [ ] Added or updated relevant documentation (leave unchecked if not applicable) 14 | - [ ] Linked to related issues (leave unchecked if not applicable) 15 | -------------------------------------------------------------------------------- /src/action/linux/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod ensure_steamos_nix_directory; 2 | pub(crate) mod provision_selinux; 3 | pub(crate) mod revert_clean_steamos_nix_offload; 4 | pub(crate) mod start_systemd_unit; 5 | pub(crate) mod systemctl_daemon_reload; 6 | 7 | pub use ensure_steamos_nix_directory::EnsureSteamosNixDirectory; 8 | pub use provision_selinux::ProvisionSelinux; 9 | pub use revert_clean_steamos_nix_offload::RevertCleanSteamosNixOffload; 10 | pub use start_systemd_unit::{StartSystemdUnit, StartSystemdUnitError}; 11 | pub use systemctl_daemon_reload::SystemctlDaemonReload; 12 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Put a trailing comma after a block based match arm (non-block arms are not affected) 2 | match_block_trailing_comma = true 3 | # Merge multiple derives into a single one. 4 | merge_derives = true 5 | # Reorder import and extern crate statements alphabetically in groups (a group is separated by a newline). 6 | reorder_imports = true 7 | # Reorder mod declarations alphabetically in group. 8 | reorder_modules = true 9 | # Use field initialize shorthand if possible. 10 | use_field_init_shorthand = true 11 | # Replace uses of the try! macro by the ? shorthand 12 | use_try_shorthand = true 13 | -------------------------------------------------------------------------------- /src/cli/subcommand/mod.rs: -------------------------------------------------------------------------------- 1 | mod install; 2 | mod plan; 3 | mod repair; 4 | mod self_test; 5 | mod split_receipt; 6 | mod uninstall; 7 | 8 | use install::Install; 9 | use plan::Plan; 10 | use repair::Repair; 11 | use self_test::SelfTest; 12 | use split_receipt::SplitReceipt; 13 | use uninstall::Uninstall; 14 | 15 | #[allow(clippy::large_enum_variant)] 16 | #[derive(Debug, clap::Subcommand)] 17 | pub enum NixInstallerSubcommand { 18 | Install(Install), 19 | Repair(Repair), 20 | Uninstall(Uninstall), 21 | SelfTest(SelfTest), 22 | Plan(Plan), 23 | SplitReceipt(SplitReceipt), 24 | } 25 | -------------------------------------------------------------------------------- /nix/tests/container-test/default/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM default 2 | COPY nix-installer /nix-installer 3 | RUN chmod +x /nix-installer 4 | COPY binary-tarball /binary-tarball 5 | RUN mv /binary-tarball/nix-*.tar.xz nix.tar.xz 6 | RUN /nix-installer/bin/nix-installer install linux --logger pretty --log-directive nix_installer=trace --nix-package-url file:///nix.tar.xz --init none --extra-conf "sandbox = false" --no-confirm -vvv 7 | ENV PATH="${PATH}:/nix/var/nix/profiles/default/bin" 8 | RUN nix-build --no-substitute -E 'derivation { name = "foo"; system = "x86_64-linux"; builder = "/bin/sh"; args = ["-c" "echo foobar > $out"]; }' 9 | RUN /nix/nix-installer uninstall --no-confirm 10 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update-flake-lock 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * 0" 6 | 7 | permissions: 8 | contents: "read" 9 | 10 | jobs: 11 | lockfile: 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Install Nix 17 | uses: ./.github/actions/install-nix-action 18 | with: 19 | cachix-authtoken: "${{ secrets.CACHIX_AUTH_TOKEN }}" 20 | - name: Check flake 21 | uses: DeterminateSystems/flake-checker-action@main 22 | - name: Update flake.lock 23 | uses: DeterminateSystems/update-flake-lock@main 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file, utf-8 charset 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | 10 | # Rust 11 | [*.rs] 12 | indent_style = space 13 | 14 | # Misc 15 | [*.{yaml,yml,nix,json,sh,service,socket,toml,te}] 16 | insert_final_newline = true 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.md] 21 | insert_final_newline = true 22 | indent_style = space 23 | 24 | [*.plist] 25 | indent_style = tab 26 | 27 | [*.ps1] 28 | indent_style = space 29 | indent_size = 4 30 | 31 | [Cargo.lock] 32 | indent_style = space 33 | indent_size = 1 34 | 35 | # selinux 36 | [*.pp] 37 | charset = unset 38 | end_of_line = unset 39 | indent_size = unset 40 | indent_style = unset 41 | insert_final_newline = unset 42 | trim_trailing_whitespace = unset 43 | -------------------------------------------------------------------------------- /.github/workflows/release-script.yml: -------------------------------------------------------------------------------- 1 | name: Generate Installer Script 2 | 3 | on: 4 | workflow_dispatch: # Allows manual triggering of the workflow 5 | inputs: 6 | testing_hydra_eval_id: 7 | description: "Eval ID of Hydra job to use artifacts from for testing" 8 | required: false 9 | default: "" 10 | 11 | jobs: 12 | create-draft-release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - uses: cachix/install-nix-action@v25 18 | with: 19 | cachix-authtoken: "${{ secrets.CACHIX_AUTH_TOKEN }}" 20 | - name: Create draft release 21 | # The script also depends on gh and git but those are both pre-installed on the runner 22 | run: nix run --inputs-from .# nixpkgs#python3 -- assemble_installer.py "${{ github.event.inputs.testing_hydra_eval_id }}" 23 | env: 24 | GH_TOKEN: ${{ github.token }} 25 | -------------------------------------------------------------------------------- /src/cli/subcommand/self_test.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | 3 | use clap::Parser; 4 | 5 | use crate::{cli::CommandExecute, NixInstallerError}; 6 | 7 | /// Run a self test of Nix to ensure that an install is working 8 | #[derive(Debug, Parser)] 9 | pub struct SelfTest {} 10 | 11 | #[async_trait::async_trait] 12 | impl CommandExecute for SelfTest { 13 | #[tracing::instrument(level = "debug", skip_all, fields())] 14 | async fn execute(self) -> eyre::Result { 15 | crate::self_test::self_test() 16 | .await 17 | .map_err(NixInstallerError::SelfTest)?; 18 | 19 | tracing::info!( 20 | shells = ?crate::self_test::Shell::discover() 21 | .iter() 22 | .map(|v| v.executable()) 23 | .collect::>(), 24 | "Successfully tested Nix install in all discovered shells." 25 | ); 26 | Ok(ExitCode::SUCCESS) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/bin/nix-installer.rs: -------------------------------------------------------------------------------- 1 | use std::{io::IsTerminal, process::ExitCode}; 2 | 3 | use clap::Parser; 4 | use nix_installer::cli::CommandExecute; 5 | 6 | #[tokio::main] 7 | async fn main() -> eyre::Result { 8 | color_eyre::config::HookBuilder::default() 9 | .issue_url(concat!(env!("CARGO_PKG_REPOSITORY"), "/issues/new")) 10 | .add_issue_metadata("version", env!("CARGO_PKG_VERSION")) 11 | .add_issue_metadata("os", std::env::consts::OS) 12 | .add_issue_metadata("arch", std::env::consts::ARCH) 13 | .theme(if !std::io::stderr().is_terminal() { 14 | color_eyre::config::Theme::new() 15 | } else { 16 | color_eyre::config::Theme::dark() 17 | }) 18 | .install()?; 19 | 20 | let cli = nix_installer::cli::NixInstallerCli::parse(); 21 | 22 | cli.instrumentation.setup()?; 23 | 24 | tracing::info!("nix-installer v{}", env!("CARGO_PKG_VERSION")); 25 | 26 | cli.execute().await 27 | } 28 | -------------------------------------------------------------------------------- /src/action/common/mod.rs: -------------------------------------------------------------------------------- 1 | //! [`Action`](crate::action::Action)s which only call other base plugins 2 | 3 | pub(crate) mod configure_init_service; 4 | pub(crate) mod configure_nix; 5 | pub(crate) mod configure_shell_profile; 6 | pub(crate) mod configure_upstream_init_service; 7 | pub(crate) mod create_nix_tree; 8 | pub(crate) mod create_users_and_groups; 9 | pub(crate) mod delete_users; 10 | pub(crate) mod place_nix_configuration; 11 | pub(crate) mod provision_nix; 12 | pub(crate) mod setup_channels; 13 | 14 | pub use configure_init_service::{ConfigureInitService, ConfigureNixDaemonServiceError}; 15 | pub use configure_nix::ConfigureNix; 16 | pub use configure_shell_profile::ConfigureShellProfile; 17 | pub use configure_upstream_init_service::ConfigureUpstreamInitService; 18 | pub use create_nix_tree::CreateNixTree; 19 | pub use create_users_and_groups::CreateUsersAndGroups; 20 | pub use delete_users::DeleteUsersInGroup; 21 | pub use place_nix_configuration::PlaceNixConfiguration; 22 | pub use provision_nix::ProvisionNix; 23 | pub use setup_channels::SetupChannels; 24 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | - [Your system can't find Nix](#your-system-cant-find-nix) 4 | 5 | ## Your system can't find Nix 6 | 7 | ### Issue 8 | 9 | You've run the installer but when you run any Nix command, like `nix --version`, and Nix isn't found: 10 | 11 | ```shell 12 | $ nix --version 13 | bash: nix: command not found 14 | ``` 15 | 16 | ### Likely problem 17 | 18 | Nix isn't currently on your `PATH`. 19 | 20 | ### Potential solutions 21 | 22 | 1. Initialize your Nix profile: 23 | 24 | ```shell 25 | . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh 26 | ``` 27 | 28 | This script sets up various environment variables that Nix needs to work. 29 | The installer does prompt you to run this command when it's finished with installation but it's easy to miss or forget. 30 | 31 | 2. Ensure that you're not overriding your existing `PATH` somewhere. 32 | If you have a `bash_profile`, `zshrc`, or other file that modifies your `PATH`, make sure that it _appends_ to your `PATH` rather than setting it directly. 33 | 34 | ```bash 35 | # Do this ✅ 36 | PATH=$PATH${PATH:+:}path1:path2:path3 37 | 38 | # Not this ❌ 39 | PATH=path1:path2:path3 40 | ``` 41 | -------------------------------------------------------------------------------- /src/action/base/mod.rs: -------------------------------------------------------------------------------- 1 | //! Base [`Action`](crate::action::Action)s that themselves have no other actions as dependencies 2 | 3 | pub(crate) mod add_user_to_group; 4 | pub(crate) mod create_directory; 5 | pub(crate) mod create_file; 6 | pub(crate) mod create_group; 7 | pub(crate) mod create_or_insert_into_file; 8 | pub(crate) mod create_or_merge_nix_config; 9 | pub(crate) mod create_user; 10 | pub(crate) mod delete_user; 11 | pub(crate) mod fetch_and_unpack_nix; 12 | pub(crate) mod move_unpacked_nix; 13 | pub(crate) mod remove_directory; 14 | pub(crate) mod setup_default_profile; 15 | 16 | pub use add_user_to_group::AddUserToGroup; 17 | pub use create_directory::CreateDirectory; 18 | pub use create_file::CreateFile; 19 | pub use create_group::CreateGroup; 20 | pub use create_or_insert_into_file::CreateOrInsertIntoFile; 21 | pub use create_or_merge_nix_config::CreateOrMergeNixConfig; 22 | pub use create_user::CreateUser; 23 | pub use delete_user::DeleteUser; 24 | pub use fetch_and_unpack_nix::{FetchAndUnpackNix, FetchUrlError}; 25 | pub use move_unpacked_nix::{MoveUnpackedNix, MoveUnpackedNixError}; 26 | pub use remove_directory::RemoveDirectory; 27 | pub use setup_default_profile::{SetupDefaultProfile, SetupDefaultProfileError}; 28 | -------------------------------------------------------------------------------- /tests/plan.rs: -------------------------------------------------------------------------------- 1 | use nix_installer::InstallPlan; 2 | 3 | const LINUX: &str = include_str!("./fixtures/linux/linux.json"); 4 | const STEAM_DECK: &str = include_str!("./fixtures/linux/steam-deck.json"); 5 | const MACOS: &str = include_str!("./fixtures/macos/macos.json"); 6 | 7 | // Ensure existing plans still parse 8 | // If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. 9 | #[test] 10 | fn plan_compat_linux() -> eyre::Result<()> { 11 | let _: InstallPlan = serde_json::from_str(LINUX)?; 12 | Ok(()) 13 | } 14 | 15 | // Ensure existing plans still parse 16 | // If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. 17 | #[test] 18 | fn plan_compat_steam_deck() -> eyre::Result<()> { 19 | let _: InstallPlan = serde_json::from_str(STEAM_DECK)?; 20 | Ok(()) 21 | } 22 | 23 | // Ensure existing plans still parse 24 | // If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. 25 | #[test] 26 | fn plan_compat_macos() -> eyre::Result<()> { 27 | let _: InstallPlan = serde_json::from_str(MACOS)?; 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /.github/actions/install-nix-action/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install Nix" 2 | description: "Helper action for installing Nix" 3 | 4 | inputs: 5 | dogfood: 6 | description: "Whether to dogfood the latest version of the installer" 7 | default: false 8 | dogfood-path: 9 | description: "Path to the local `nix-installer` binary root" 10 | required: false 11 | use-cache: 12 | description: "Whether to cache paths in /nix/store" 13 | default: true 14 | no-init: 15 | description: "Whether to install Nix without init system integration" 16 | default: false 17 | cachix-authtoken: 18 | description: "cachix authentication token" 19 | default: "" 20 | 21 | runs: 22 | using: "composite" 23 | steps: 24 | - uses: DeterminateSystems/nix-installer-action@786fff0690178f1234e4e1fe9b536e94f5433196 # v20 25 | with: 26 | determinate: false 27 | 28 | backtrace: ${{ inputs.dogfood == 'true' && 'full' || '' }} 29 | local-root: ${{ inputs.dogfood == 'true' && inputs.dogfood-path || '' }} 30 | log-directives: ${{ inputs.dogfood == 'true' && 'nix_installer=debug' || '' }} 31 | logger: ${{ inputs.dogfood == 'true' && 'pretty' || '' }} 32 | 33 | init: ${{ inputs.no-init == 'true' && 'none' || '' }} 34 | planner: ${{ inputs.no-init == 'true' && 'linux' || '' }} 35 | 36 | #- uses: cachix/cachix-action@v15 37 | # with: 38 | # name: nix-installer 39 | # authToken: "${{ inputs.cachix-authtoken}}" 40 | -------------------------------------------------------------------------------- /src/planner/macos/profile.sample.unknown.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _computerlevel 6 | 7 | 8 | ProfileDescription 9 | 10 | ProfileDisplayName 11 | macOS Software Update Policy: Mandatory Minor Upgrades 12 | ProfileIdentifier 13 | com.example 14 | ProfileInstallDate 15 | 2024-04-22 00:00:00 +0000 16 | ProfileItems 17 | 18 | 19 | PayloadContent 20 | 21 | AllowPreReleaseInstallation 22 | 23 | AutomaticCheckEnabled 24 | 25 | 26 | PayloadIdentifier 27 | abc123 28 | PayloadType 29 | com.apple.SoftwareUpdate 30 | PayloadUUID 31 | def456 32 | PayloadVersion 33 | 1 34 | 35 | 36 | ProfileRemovalDisallowed 37 | true 38 | ProfileType 39 | Configuration 40 | ProfileUUID 41 | F7972F85-2A4D-4609-A4BB-02CB0C34A3F8 42 | ProfileVerificationState 43 | verified 44 | ProfileVersion 45 | 1 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /nix/check.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: 2 | 3 | let 4 | inherit (pkgs) writeShellApplication; 5 | in 6 | { 7 | 8 | # Format 9 | check-rustfmt = (writeShellApplication { 10 | name = "check-rustfmt"; 11 | runtimeInputs = with pkgs; [ cargo rustfmt ]; 12 | text = "cargo fmt --check"; 13 | }); 14 | 15 | # Spelling 16 | check-spelling = (writeShellApplication { 17 | name = "check-spelling"; 18 | runtimeInputs = with pkgs; [ git codespell ]; 19 | text = '' 20 | codespell \ 21 | --ignore-words-list="ba,sur,crate,pullrequest,pullrequests,ser,distroname" \ 22 | --skip="./target,.git,./src/action/linux/selinux,*.lock" \ 23 | . 24 | ''; 25 | }); 26 | 27 | # NixFormatting 28 | check-nixpkgs-fmt = (writeShellApplication { 29 | name = "check-nixpkgs-fmt"; 30 | runtimeInputs = with pkgs; [ git nixpkgs-fmt findutils ]; 31 | text = '' 32 | nixpkgs-fmt --check . 33 | ''; 34 | }); 35 | 36 | # EditorConfig 37 | check-editorconfig = (writeShellApplication { 38 | name = "check-editorconfig"; 39 | runtimeInputs = with pkgs; [ editorconfig-checker ]; 40 | text = '' 41 | editorconfig-checker 42 | ''; 43 | }); 44 | 45 | # Semver 46 | check-semver = (writeShellApplication { 47 | name = "check-semver"; 48 | runtimeInputs = with pkgs; [ cargo-semver-checks ]; 49 | text = '' 50 | cargo-semver-checks semver-checks check-release 51 | ''; 52 | }); 53 | # Clippy 54 | check-clippy = (writeShellApplication { 55 | name = "check-clippy"; 56 | runtimeInputs = with pkgs; [ cargo clippy rustc ]; 57 | text = '' 58 | cargo clippy 59 | ''; 60 | }); 61 | 62 | } 63 | -------------------------------------------------------------------------------- /docs/rust-library.md: -------------------------------------------------------------------------------- 1 | ## As a Rust library 2 | 3 | > [!WARNING] 4 | > Using Determinate Nix Installer as a [Rust] library is still experimental. 5 | > This feature is likely to be removed in the future without an advocate. 6 | > If you're using this, please let us know and we can provide a path to stabilization. 7 | 8 | Add the [`nix-installer` library][lib] to your dependencies: 9 | 10 | ```shell 11 | cargo add nix-installer 12 | ``` 13 | 14 | If you're building a CLI, check out the `cli` feature flag for [`clap`][clap] integration. 15 | 16 | You'll also need to edit your `.cargo/config.toml` to use `tokio_unstable` as we utilize [Tokio's process groups][process-groups], which wrap stable `std` APIs, but are unstable due to it requiring an MSRV bump: 17 | 18 | ```toml 19 | # .cargo/config.toml 20 | [build] 21 | rustflags=["--cfg", "tokio_unstable"] 22 | ``` 23 | 24 | You'll also need to set the `NIX_INSTALLER_TARBALL_PATH` environment variable to point to a target-appropriate Nix installation tarball, like nix-2.21.2-aarch64-darwin.tar.xz. 25 | The contents are embedded in the resulting binary instead of downloaded at installation time. 26 | 27 | Then it's possible to review the [documentation]: 28 | 29 | ```shell 30 | cargo doc --open -p nix-installer 31 | ``` 32 | 33 | Documentation is also available via `nix build`: 34 | 35 | ```shell 36 | nix build github:DeterminateSystems/nix-installer#nix-installer.doc 37 | firefox result-doc/nix-installer/index.html 38 | ``` 39 | 40 | [clap]: https://clap.rs 41 | [documentation]: https://docs.rs/nix-installer/latest/nix_installer 42 | [lib]: https://docs.rs/nix-installer 43 | [process-groups]: https://docs.rs/tokio/1.24.1/tokio/process/struct.Command.html#method.process_group 44 | [rust]: https://rust-lang.com 45 | -------------------------------------------------------------------------------- /nix/tests/vm-test/vagrant_insecure_key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzI 3 | w+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoP 4 | kcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2 5 | hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NO 6 | Td0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcW 7 | yLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQIBIwKCAQEA4iqWPJXtzZA68mKd 8 | ELs4jJsdyky+ewdZeNds5tjcnHU5zUYE25K+ffJED9qUWICcLZDc81TGWjHyAqD1 9 | Bw7XpgUwFgeUJwUlzQurAv+/ySnxiwuaGJfhFM1CaQHzfXphgVml+fZUvnJUTvzf 10 | TK2Lg6EdbUE9TarUlBf/xPfuEhMSlIE5keb/Zz3/LUlRg8yDqz5w+QWVJ4utnKnK 11 | iqwZN0mwpwU7YSyJhlT4YV1F3n4YjLswM5wJs2oqm0jssQu/BT0tyEXNDYBLEF4A 12 | sClaWuSJ2kjq7KhrrYXzagqhnSei9ODYFShJu8UWVec3Ihb5ZXlzO6vdNQ1J9Xsf 13 | 4m+2ywKBgQD6qFxx/Rv9CNN96l/4rb14HKirC2o/orApiHmHDsURs5rUKDx0f9iP 14 | cXN7S1uePXuJRK/5hsubaOCx3Owd2u9gD6Oq0CsMkE4CUSiJcYrMANtx54cGH7Rk 15 | EjFZxK8xAv1ldELEyxrFqkbE4BKd8QOt414qjvTGyAK+OLD3M2QdCQKBgQDtx8pN 16 | CAxR7yhHbIWT1AH66+XWN8bXq7l3RO/ukeaci98JfkbkxURZhtxV/HHuvUhnPLdX 17 | 3TwygPBYZFNo4pzVEhzWoTtnEtrFueKxyc3+LjZpuo+mBlQ6ORtfgkr9gBVphXZG 18 | YEzkCD3lVdl8L4cw9BVpKrJCs1c5taGjDgdInQKBgHm/fVvv96bJxc9x1tffXAcj 19 | 3OVdUN0UgXNCSaf/3A/phbeBQe9xS+3mpc4r6qvx+iy69mNBeNZ0xOitIjpjBo2+ 20 | dBEjSBwLk5q5tJqHmy/jKMJL4n9ROlx93XS+njxgibTvU6Fp9w+NOFD/HvxB3Tcz 21 | 6+jJF85D5BNAG3DBMKBjAoGBAOAxZvgsKN+JuENXsST7F89Tck2iTcQIT8g5rwWC 22 | P9Vt74yboe2kDT531w8+egz7nAmRBKNM751U/95P9t88EDacDI/Z2OwnuFQHCPDF 23 | llYOUI+SpLJ6/vURRbHSnnn8a/XG+nzedGH5JGqEJNQsz+xT2axM0/W/CRknmGaJ 24 | kda/AoGANWrLCz708y7VYgAtW2Uf1DPOIYMdvo6fxIB5i9ZfISgcJ/bbCUkFrhoH 25 | +vq/5CIWxCPp0f85R4qxxQ5ihxJ0YDQT9Jpx4TMss4PSavPaBH3RXow5Ohe+bYoQ 26 | NE5OgEXk2wVfZczCZpigBKbKZHNYcelXtTt/nP3rsCuGcM4h53s= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::action::ActionErrorKind; 4 | 5 | #[derive(Debug, PartialEq, Eq)] 6 | pub(crate) enum OnMissing { 7 | Ignore, 8 | Error, 9 | } 10 | 11 | #[tracing::instrument(skip(path), fields(path = %path.display()))] 12 | pub(crate) async fn remove_file(path: &Path, on_missing: OnMissing) -> std::io::Result<()> { 13 | tracing::trace!("Removing file"); 14 | let res = tokio::fs::remove_file(path).await; 15 | match res { 16 | Ok(_) => Ok(()), 17 | Err(e) if e.kind() == std::io::ErrorKind::NotFound && on_missing == OnMissing::Ignore => { 18 | tracing::trace!("Ignoring nonexistent file"); 19 | Ok(()) 20 | }, 21 | e @ Err(_) => e, 22 | } 23 | } 24 | 25 | #[tracing::instrument(skip(path), fields(path = %path.display()))] 26 | pub(crate) async fn remove_dir_all(path: &Path, on_missing: OnMissing) -> std::io::Result<()> { 27 | tracing::trace!("Removing directory and all contents"); 28 | let res = tokio::fs::remove_dir_all(path).await; 29 | match res { 30 | Ok(_) => Ok(()), 31 | Err(e) if e.kind() == std::io::ErrorKind::NotFound && on_missing == OnMissing::Ignore => { 32 | tracing::trace!("Ignoring nonexistent directory"); 33 | Ok(()) 34 | }, 35 | e @ Err(_) => e, 36 | } 37 | } 38 | 39 | pub(crate) async fn write_atomic(destination: &Path, body: &str) -> Result<(), ActionErrorKind> { 40 | let temp = destination.with_extension("tmp"); 41 | 42 | tokio::fs::write(&temp, body) 43 | .await 44 | .map_err(|e| ActionErrorKind::Write(temp.to_owned(), e))?; 45 | 46 | tokio::fs::rename(&temp, &destination) 47 | .await 48 | .map_err(|e| ActionErrorKind::Rename(temp, destination.into(), e))?; 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /src/cli/subcommand/plan.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, process::ExitCode}; 2 | 3 | use crate::{cli::ensure_root, error::HasExpectedErrors, BuiltinPlanner}; 4 | use clap::Parser; 5 | 6 | use eyre::WrapErr; 7 | use owo_colors::OwoColorize; 8 | 9 | use crate::cli::CommandExecute; 10 | 11 | /** 12 | Emit a JSON install plan that can be manually edited before execution 13 | 14 | Primarily intended for development, debugging, and handling install cases. 15 | */ 16 | #[derive(Debug, Parser)] 17 | pub struct Plan { 18 | #[clap(subcommand)] 19 | pub planner: Option, 20 | /// Where to write the generated plan (in JSON format) 21 | #[clap( 22 | long = "out-file", 23 | env = "NIX_INSTALLER_PLAN_OUT_FILE", 24 | default_value = "/dev/stdout" 25 | )] 26 | pub output: PathBuf, 27 | } 28 | 29 | #[async_trait::async_trait] 30 | impl CommandExecute for Plan { 31 | #[tracing::instrument(level = "debug", skip_all, fields())] 32 | async fn execute(self) -> eyre::Result { 33 | let Self { planner, output } = self; 34 | 35 | ensure_root()?; 36 | 37 | let planner = match planner { 38 | Some(planner) => planner, 39 | None => BuiltinPlanner::default().await?, 40 | }; 41 | 42 | let res = planner.plan().await; 43 | 44 | let install_plan = match res { 45 | Ok(plan) => plan, 46 | Err(err) => { 47 | if let Some(expected) = err.expected() { 48 | eprintln!("{}", expected.red()); 49 | return Ok(ExitCode::FAILURE); 50 | } 51 | return Err(err)?; 52 | }, 53 | }; 54 | 55 | let json = serde_json::to_string_pretty(&install_plan)?; 56 | tokio::fs::write(output, format!("{json}\n")) 57 | .await 58 | .wrap_err("Writing plan")?; 59 | 60 | Ok(ExitCode::SUCCESS) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/os/darwin/diskutil.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(serde::Deserialize)] 4 | #[serde(rename_all = "PascalCase")] 5 | pub struct DiskUtilInfoOutput { 6 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] 7 | pub parent_whole_disk: String, 8 | pub global_permissions_enabled: bool, 9 | pub mount_point: Option, 10 | } 11 | 12 | impl DiskUtilInfoOutput { 13 | pub async fn for_volume_name( 14 | volume_name: &str, 15 | ) -> Result { 16 | Self::for_volume_path(std::path::Path::new(volume_name)).await 17 | } 18 | 19 | pub async fn for_volume_path( 20 | volume_path: &std::path::Path, 21 | ) -> Result { 22 | let buf = crate::execute_command( 23 | tokio::process::Command::new("/usr/sbin/diskutil") 24 | .process_group(0) 25 | .args(["info", "-plist"]) 26 | .arg(volume_path) 27 | .stdin(std::process::Stdio::null()), 28 | ) 29 | .await? 30 | .stdout; 31 | 32 | Ok(plist::from_reader(std::io::Cursor::new(buf))?) 33 | } 34 | 35 | pub fn is_mounted(&self) -> bool { 36 | match self.mount_point { 37 | None => false, 38 | Some(ref mp) => !mp.as_os_str().is_empty(), 39 | } 40 | } 41 | } 42 | 43 | #[derive(serde::Deserialize, Clone, Debug)] 44 | #[serde(rename_all = "PascalCase")] 45 | pub struct DiskUtilApfsListOutput { 46 | pub containers: Vec, 47 | } 48 | 49 | #[derive(serde::Deserialize, Clone, Debug)] 50 | #[serde(rename_all = "PascalCase")] 51 | pub struct DiskUtilApfsContainer { 52 | pub volumes: Vec, 53 | } 54 | 55 | #[derive(serde::Deserialize, Clone, Debug)] 56 | #[serde(rename_all = "PascalCase")] 57 | pub struct DiskUtilApfsListVolume { 58 | pub name: Option, 59 | pub file_vault: Option, 60 | } 61 | -------------------------------------------------------------------------------- /docs/quirks.md: -------------------------------------------------------------------------------- 1 | ## Quirks 2 | 3 | While the experimental Nix Installer tries to provide a comprehensive and unquirky experience, there are unfortunately some issues that may require manual intervention or operator choices. 4 | 5 | ### Using MacOS after removing Nix while nix-darwin was still installed, network requests fail 6 | 7 | If Nix was previously uninstalled without uninstalling [nix-darwin] first, you may experience errors similar to this: 8 | 9 | ```shell 10 | nix shell nixpkgs#curl 11 | 12 | error: unable to download 'https://cache.nixos.org/g8bqlgmpa4yg601w561qy2n576i6g0vh.narinfo': Problem with the SSL CA cert (path? access rights?) (77) 13 | ``` 14 | 15 | This occurs because `nix-darwin` provisions an `org.nixos.activate-system` service which remains after Nix is uninstalled. 16 | The `org.nixos.activate-system` service in this state interacts with the newly installed Nix and changes the SSL certificates it uses to be a broken symlink. 17 | 18 | ```shell 19 | ls -lah /etc/ssl/certs 20 | 21 | total 0 22 | drwxr-xr-x 3 root wheel 96B Oct 17 08:26 . 23 | drwxr-xr-x 6 root wheel 192B Sep 16 06:28 .. 24 | lrwxr-xr-x 1 root wheel 41B Oct 17 08:26 ca-certificates.crt -> /etc/static/ssl/certs/ca-certificates.crt 25 | ``` 26 | 27 | The problem is compounded by the matter that the [`nix-darwin` uninstaller][uninstalling] will not work after uninstalling Nix, since it uses Nix and requires network connectivity. 28 | 29 | It's possible to resolve this situation by removing the `org.nixos.activate-system` service and the `ca-certificates`: 30 | 31 | ```shell 32 | sudo rm /Library/LaunchDaemons/org.nixos.activate-system.plist 33 | sudo launchctl bootout system/org.nixos.activate-system 34 | /nix/nix-installer uninstall 35 | sudo rm /etc/ssl/certs/ca-certificates.crt 36 | ``` 37 | 38 | Run the installer again and it should work. 39 | 40 | Up-to-date versions of the installer will refuse to uninstall until [nix-darwin] is uninstalled first, helping to mitigate this problem. 41 | 42 | [nix-darwin]: https://github.com/LnL7/nix-darwin 43 | [uninstalling]: https://github.com/LnL7/nix-darwin#uninstalling 44 | -------------------------------------------------------------------------------- /src/planner/macos/profile.sample.block.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | foo 7 | 8 | 9 | 10 | ProfileDescription 11 | The description 12 | ProfileDisplayName 13 | Don't allow mounting internal devices 14 | ProfileIdentifier 15 | MyProfile.6F6670A3-65AC-4EA4-8665-91F8FCE289AB 16 | ProfileInstallDate 17 | 2024-04-22 14:12:42 +0000 18 | ProfileType 19 | Configuration 20 | ProfileUUID 21 | 6F6670A3-65AC-4EA4-8665-91F8FCE289AB 22 | ProfileVersion 23 | 1 24 | 25 | 26 | ProfileItems 27 | 28 | 29 | 30 | PayloadType 31 | com.apple.systemuiserver 32 | 33 | PayloadContent 34 | 35 | mount-controls 36 | 37 | harddisk-internal 38 | 39 | 49 | deny 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/building.md: -------------------------------------------------------------------------------- 1 | ## Building a binary 2 | 3 | Since you'll be using the installer to install Nix on systems without Nix, the default build is a static binary. 4 | This guide shows you how to build the installer on [Linux](#on-linux) and [macOS](#on-macos). 5 | 6 | ## On Linux 7 | 8 | To build a portable Linux binary on a system with Nix: 9 | 10 | ```shell 11 | # to build a local copy 12 | nix build -L ".#nix-installer-static" 13 | # to build the remote main development branch 14 | nix build -L "github:determinatesystems/nix-installer#nix-installer-static" 15 | # for a specific version of the installer: 16 | export NIX_INSTALLER_TAG="v0.6.0" 17 | nix build -L "github:determinatesystems/nix-installer/$NIX_INSTALLER_TAG#nix-installer-static" 18 | ``` 19 | 20 | ## On macOS 21 | 22 | ```shell 23 | # to build a local copy 24 | nix build -L ".#nix-installer" 25 | # to build the remote main development branch 26 | nix build -L "github:determinatesystems/nix-installer#nix-installer" 27 | # for a specific version of the installer: 28 | export NIX_INSTALLER_TAG="v0.6.0" 29 | nix build -L "github:determinatesystems/nix-installer/$NIX_INSTALLER_TAG#nix-installer" 30 | ``` 31 | 32 | ## Copying the executable 33 | 34 | Once Nix has built the executable for the desired system, you can copy `result/bin/nix-installer` to the machine you wish to run it on (in Nix, `result` is a symlink to a directory in the Nix store). 35 | You can also add the installer to a system without Nix using [cargo], as there are no system dependencies to worry about: 36 | 37 | ```shell 38 | # to build and run a local copy 39 | RUSTFLAGS="--cfg tokio_unstable" cargo run -- --help 40 | # to build the remote main development branch 41 | RUSTFLAGS="--cfg tokio_unstable" cargo install --git https://github.com/DeterminateSystems/nix-installer 42 | nix-installer --help 43 | # for a specific version of the installer: 44 | export NIX_INSTALLER_TAG="v0.6.0" 45 | RUSTFLAGS="--cfg tokio_unstable" cargo install --git https://github.com/DeterminateSystems/nix-installer --tag $NIX_INSTALLER_TAG 46 | nix-installer --help 47 | ``` 48 | 49 | To make this build portable, pass the `--target x86_64-unknown-linux-musl` option. 50 | 51 | > [!NOTE] 52 | > We currently require `--cfg tokio_unstable` as we utilize [Tokio's process groups](https://docs.rs/tokio/1.24.1/tokio/process/struct.Command.html#method.process_group), which wrap stable `std` APIs, but are unstable due to it requiring an MSRV bump. 53 | 54 | [cargo]: https://doc.rust-lang.org/cargo 55 | -------------------------------------------------------------------------------- /src/action/linux/systemctl_daemon_reload.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionErrorKind, ActionTag}; 7 | use crate::execute_command; 8 | 9 | use crate::action::{Action, ActionDescription, StatefulAction}; 10 | 11 | /** 12 | Run `systemctl daemon-reload` (on both execute and revert) 13 | */ 14 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 15 | pub struct SystemctlDaemonReload; 16 | 17 | impl SystemctlDaemonReload { 18 | #[tracing::instrument(level = "debug", skip_all)] 19 | pub async fn plan() -> Result, ActionError> { 20 | if !Path::new("/run/systemd/system").exists() { 21 | return Err(Self::error(ActionErrorKind::SystemdMissing)); 22 | } 23 | 24 | if which::which("systemctl").is_err() { 25 | return Err(Self::error(ActionErrorKind::SystemdMissing)); 26 | } 27 | 28 | Ok(StatefulAction::uncompleted(SystemctlDaemonReload)) 29 | } 30 | } 31 | 32 | #[async_trait::async_trait] 33 | #[typetag::serde(name = "systemctl_daemon_reload")] 34 | impl Action for SystemctlDaemonReload { 35 | fn action_tag() -> ActionTag { 36 | ActionTag("systemctl_daemon_reload") 37 | } 38 | fn tracing_synopsis(&self) -> String { 39 | "Run `systemctl daemon-reload`".to_string() 40 | } 41 | 42 | fn tracing_span(&self) -> Span { 43 | span!(tracing::Level::DEBUG, "systemctl_daemon_reload",) 44 | } 45 | 46 | fn execute_description(&self) -> Vec { 47 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 48 | } 49 | 50 | #[tracing::instrument(level = "debug", skip_all)] 51 | async fn execute(&mut self) -> Result<(), ActionError> { 52 | execute_command( 53 | Command::new("systemctl") 54 | .process_group(0) 55 | .arg("daemon-reload") 56 | .stdin(std::process::Stdio::null()), 57 | ) 58 | .await 59 | .map_err(Self::error)?; 60 | 61 | Ok(()) 62 | } 63 | 64 | fn revert_description(&self) -> Vec { 65 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 66 | } 67 | 68 | #[tracing::instrument(level = "debug", skip_all)] 69 | async fn revert(&mut self) -> Result<(), ActionError> { 70 | execute_command( 71 | Command::new("systemctl") 72 | .process_group(0) 73 | .arg("daemon-reload") 74 | .stdin(std::process::Stdio::null()), 75 | ) 76 | .await 77 | .map_err(Self::error)?; 78 | 79 | Ok(()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/action/linux/revert_clean_steamos_nix_offload.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use tracing::{span, Span}; 4 | 5 | use crate::action::{ActionError, ActionErrorKind, ActionTag}; 6 | 7 | use crate::action::{Action, ActionDescription, StatefulAction}; 8 | use crate::util::OnMissing; 9 | 10 | const OFFLOAD_PATH: &str = "/home/.steamos/offload/nix"; 11 | 12 | /** 13 | Clean out the `/home/.steamos/offload/nix` 14 | 15 | In SteamOS build ID 20230522.1000 (and, presumably, later) a `/home/.steamos/offload/nix` directory 16 | exists by default and needs to be cleaned out on uninstall, otherwise uninstall won't work. 17 | */ 18 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 19 | pub struct RevertCleanSteamosNixOffload; 20 | 21 | impl RevertCleanSteamosNixOffload { 22 | #[tracing::instrument(level = "debug", skip_all)] 23 | pub async fn plan() -> Result, ActionError> { 24 | if Path::new(OFFLOAD_PATH).exists() { 25 | Ok(StatefulAction::uncompleted(RevertCleanSteamosNixOffload)) 26 | } else { 27 | Ok(StatefulAction::completed(RevertCleanSteamosNixOffload)) 28 | } 29 | } 30 | } 31 | 32 | #[async_trait::async_trait] 33 | #[typetag::serde(name = "revert_clean_steamos_nix_offload")] 34 | impl Action for RevertCleanSteamosNixOffload { 35 | fn action_tag() -> ActionTag { 36 | ActionTag("revert_clean_steamos_nix_offload") 37 | } 38 | fn tracing_synopsis(&self) -> String { 39 | format!("Clean the `{OFFLOAD_PATH}` directory") 40 | } 41 | 42 | fn tracing_span(&self) -> Span { 43 | span!(tracing::Level::DEBUG, "revert_clean_steamos_nix_offload",) 44 | } 45 | 46 | fn execute_description(&self) -> Vec { 47 | vec![] 48 | } 49 | 50 | #[tracing::instrument(level = "debug", skip_all)] 51 | async fn execute(&mut self) -> Result<(), ActionError> { 52 | // noop 53 | 54 | Ok(()) 55 | } 56 | 57 | fn revert_description(&self) -> Vec { 58 | vec![ActionDescription::new( 59 | self.tracing_synopsis(), 60 | vec![ 61 | format!("On more recent versions of SteamOS, the `{OFFLOAD_PATH}` folder contains the Nix store, and needs to be cleaned on uninstall."), 62 | ], 63 | )] 64 | } 65 | 66 | #[tracing::instrument(level = "debug", skip_all)] 67 | async fn revert(&mut self) -> Result<(), ActionError> { 68 | let paths = glob::glob(OFFLOAD_PATH).map_err(Self::error)?; 69 | 70 | for path in paths { 71 | let path = path.map_err(Self::error)?; 72 | crate::util::remove_dir_all(&path, OnMissing::Error) 73 | .await 74 | .map_err(|e| Self::error(ActionErrorKind::Remove(path, e)))?; 75 | } 76 | 77 | Ok(()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/action/base/remove_directory.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tracing::{span, Span}; 4 | 5 | use crate::action::{Action, ActionDescription, ActionErrorKind, ActionState}; 6 | use crate::action::{ActionError, StatefulAction}; 7 | use crate::util::OnMissing; 8 | 9 | /** Remove a directory, does nothing on revert. 10 | */ 11 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 12 | #[serde(tag = "action_name", rename = "remove_directory")] 13 | pub struct RemoveDirectory { 14 | path: PathBuf, 15 | } 16 | 17 | impl RemoveDirectory { 18 | #[tracing::instrument(level = "debug", skip_all)] 19 | pub async fn plan(path: impl AsRef) -> Result, ActionError> { 20 | let path = path.as_ref().to_path_buf(); 21 | 22 | Ok(StatefulAction { 23 | action: Self { path }, 24 | state: ActionState::Uncompleted, 25 | }) 26 | } 27 | } 28 | 29 | #[async_trait::async_trait] 30 | #[typetag::serde(name = "remove_directory")] 31 | impl Action for RemoveDirectory { 32 | fn action_tag() -> crate::action::ActionTag { 33 | crate::action::ActionTag("remove_directory") 34 | } 35 | fn tracing_synopsis(&self) -> String { 36 | format!("Remove directory `{}`", self.path.display()) 37 | } 38 | 39 | fn tracing_span(&self) -> Span { 40 | span!( 41 | tracing::Level::DEBUG, 42 | "remove_directory", 43 | path = tracing::field::display(self.path.display()), 44 | ) 45 | } 46 | 47 | fn execute_description(&self) -> Vec { 48 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 49 | } 50 | 51 | #[tracing::instrument(level = "debug", skip_all)] 52 | async fn execute(&mut self) -> Result<(), ActionError> { 53 | if self.path.exists() { 54 | if !self.path.is_dir() { 55 | return Err(Self::error(ActionErrorKind::PathWasNotDirectory( 56 | self.path.clone(), 57 | ))); 58 | } 59 | 60 | // At this point, we know the path exists, but just in case it was deleted between then 61 | // and now, we still ignore the case where it no longer exists. 62 | crate::util::remove_dir_all(&self.path, OnMissing::Ignore) 63 | .await 64 | .map_err(|e| Self::error(ActionErrorKind::Remove(self.path.clone(), e)))?; 65 | } else { 66 | tracing::debug!("Directory `{}` not present, skipping", self.path.display(),); 67 | }; 68 | 69 | Ok(()) 70 | } 71 | 72 | fn revert_description(&self) -> Vec { 73 | vec![] 74 | } 75 | 76 | #[tracing::instrument(level = "debug", skip_all)] 77 | async fn revert(&mut self) -> Result<(), ActionError> { 78 | Ok(()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /upload_s3.sh: -------------------------------------------------------------------------------- 1 | set -eu 2 | 3 | DEST="$1" 4 | GIT_ISH="$2" 5 | DEST_INSTALL_URL="$3" 6 | 7 | is_tag() { 8 | if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then 9 | return 0 10 | else 11 | return 1 12 | fi 13 | } 14 | 15 | # If the revision directory has already been created in S3 somehow, we don't want to reupload 16 | if aws s3 ls "$AWS_BUCKET"/"$GIT_ISH"/; then 17 | # Only exit if it's not a tag (since we're tagging a commit previously pushed to main) 18 | if ! is_tag; then 19 | echo "Revision $GIT_ISH was already uploaded; exiting" 20 | exit 1 21 | fi 22 | fi 23 | 24 | sudo chown $USER: -R artifacts/ 25 | 26 | mkdir "$DEST" 27 | mkdir "$GIT_ISH" 28 | 29 | cp nix-installer.sh "$DEST"/ 30 | cp nix-installer.sh "$GIT_ISH"/ 31 | 32 | for artifact in $(find artifacts/ -type f); do 33 | chmod +x "$artifact" 34 | cp "$artifact" "$DEST"/ 35 | cp "$artifact" "$GIT_ISH"/ 36 | done 37 | 38 | sed -i "s@https://install.determinate.systems/nix@$DEST_INSTALL_URL@" "$DEST/nix-installer.sh" 39 | sed -i "s@https://install.determinate.systems/nix@https://install.determinate.systems/nix/rev/$GIT_ISH@" "$GIT_ISH/nix-installer.sh" 40 | 41 | if is_tag; then 42 | cp "$DEST/nix-installer.sh" ./nix-installer.sh 43 | fi 44 | 45 | # If any artifact already exists in S3 and the hash is the same, we don't want to reupload 46 | check_reupload() { 47 | dest="$1" 48 | 49 | for file in $(find "$dest" -type f); do 50 | artifact_path="$dest"/"$(basename "$file")" 51 | md5="$(md5sum "$file" | cut -d' ' -f1)" 52 | obj="$(aws s3api head-object --bucket "$AWS_BUCKET" --key "$artifact_path" || echo '{}')" 53 | obj_md5="$(jq -r .ETag <<<"$obj" | jq -r)" # head-object call returns ETag quoted, so `jq -r` again to unquote it 54 | 55 | # Object doesn't exist, so let's check the next one 56 | if [[ "$obj_md5" == "null" ]]; then 57 | continue 58 | fi 59 | 60 | if [[ "$md5" != "$obj_md5" ]]; then 61 | echo "Artifact $artifact was already uploaded; exiting" 62 | # If we already uploaded to a tag, that's probably bad 63 | is_tag && exit 1 || exit 0 64 | fi 65 | done 66 | } 67 | 68 | check_reupload "$DEST" 69 | if ! is_tag; then 70 | check_reupload "$GIT_ISH" 71 | fi 72 | 73 | sync_args=(--acl public-read) 74 | 75 | # NOTE(cole-h): never allow reuploading to a tag 76 | if is_tag; then 77 | sync_args+=(--if-none-match '*') 78 | fi 79 | 80 | # NOTE(cole-h): never allow reuploading to a rev 81 | if ! is_tag; then 82 | find "$GIT_ISH/" -type f -print0 | 83 | while IFS= read -r -d '' artifact; do 84 | aws s3api put-object --bucket "$AWS_BUCKET" --key "$artifact" --body "$artifact" "${sync_args[@]}" --if-none-match '*' 85 | done 86 | fi 87 | 88 | find "$DEST/" -type f -print0 | 89 | while IFS= read -r -d '' artifact; do 90 | aws s3api put-object --bucket "$AWS_BUCKET" --key "$artifact" --body "$artifact" "${sync_args[@]}" 91 | done 92 | -------------------------------------------------------------------------------- /tests/windows/test-wsl.ps1: -------------------------------------------------------------------------------- 1 | param([switch]$Systemd = $false) 2 | Set-StrictMode -Version Latest 3 | $ErrorActionPreference = "Stop" 4 | 5 | 6 | # 22.04 https://cloud-images.ubuntu.com/wsl/jammy/current/ 7 | $url = "https://cloud-images.ubuntu.com/wsl/jammy/current/ubuntu-jammy-wsl-amd64-wsl.rootfs.tar.gz" 8 | $File = "ubuntu-jammy-wsl-amd64-wsl.rootfs.tar.gz" 9 | $Name = "ubuntu-jammy" 10 | 11 | 12 | $TemporaryDirectory = "$HOME/nix-installer-wsl-tests-temp" 13 | $Image = "$TemporaryDirectory\$File" 14 | if (!(Test-Path -Path $Image)) { 15 | Write-Output "Fetching $File to $Image..." 16 | New-Item $TemporaryDirectory -ItemType Directory | Out-Null 17 | Invoke-WebRequest -Uri "https://cloud-images.ubuntu.com/wsl/jammy/current/ubuntu-jammy-wsl-amd64-wsl.rootfs.tar.gz" -OutFile $Image 18 | } else { 19 | Write-Output "Found existing $Image..." 20 | } 21 | 22 | $DistroName = "nix-installer-test-$Name" 23 | $InstallRoot = "$TemporaryDirectory\wsl-$Name" 24 | Write-Output "Creating WSL distribution $DistroName from $Image at $InstallRoot..." 25 | wsl --import $DistroName $InstallRoot $Image 26 | if ($LastExitCode -ne 0) { 27 | exit $LastExitCode 28 | } 29 | 30 | Write-Output "Preparing $DistroName for nix-installer..." 31 | wsl --distribution $DistroName bash --login -c "apt update --quiet" 32 | if ($LastExitCode -ne 0) { 33 | exit $LastExitCode 34 | } 35 | wsl --distribution $DistroName bash --login -c "apt install --quiet --yes curl build-essential" 36 | if ($LastExitCode -ne 0) { 37 | exit $LastExitCode 38 | } 39 | wsl --distribution $DistroName bash --login -c "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --quiet" 40 | if ($LastExitCode -ne 0) { 41 | exit $LastExitCode 42 | } 43 | 44 | if ($Systemd) { 45 | $wslConf = "[boot]`nsystemd=true" 46 | New-Item -Path "\\wsl$\$DistroName\etc\wsl.conf" -ItemType "file" -Value $wslConf 47 | wsl --shutdown 48 | if ($LastExitCode -ne 0) { 49 | exit $LastExitCode 50 | } 51 | } 52 | 53 | Write-Output "Building and runnings nix-installer in $DistroName..." 54 | Copy-Item -Recurse "$PSScriptRoot\..\.." -Destination "\\wsl$\$DistroName\nix-installer" 55 | $MaybeInitChoice = switch ($Systemd) { 56 | $true { "" } 57 | $false { "--init none" } 58 | } 59 | wsl --distribution $DistroName bash --login -c "/root/.cargo/bin/cargo run --quiet --manifest-path /nix-installer/Cargo.toml -- install linux --no-confirm $MaybeInitChoice" 60 | if ($LastExitCode -ne 0) { 61 | exit $LastExitCode 62 | } 63 | 64 | Write-Output "Testing installed Nix on $DistroName..." 65 | wsl --distribution $DistroName bash --login -c "nix run nixpkgs#hello" 66 | if ($LastExitCode -ne 0) { 67 | exit $LastExitCode 68 | } 69 | 70 | Write-Output "Unregistering $DistroName and removing $InstallRoot..." 71 | wsl --unregister $DistroName 72 | if ($LastExitCode -ne 0) { 73 | exit $LastExitCode 74 | } 75 | Remove-Item $InstallRoot 76 | -------------------------------------------------------------------------------- /enter-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-shell 2 | #!nix-shell -p vault awscli2 jq -i bash 3 | # shellcheck shell=bash 4 | 5 | set +x # don't leak secrets! 6 | set -eu 7 | umask 077 8 | 9 | scriptroot=$(dirname "$(realpath "$0")") 10 | scratch=$(mktemp -d -t tmp.XXXXXXXXXX) 11 | 12 | vault token lookup &>/dev/null || { 13 | echo "You're not logged in to vault! Exiting." 14 | exit 1 15 | } 16 | 17 | function finish { 18 | set +e 19 | rm -rf "$scratch" 20 | if [ "${VAULT_EXIT_ACCESSOR:-}" != "" ]; then 21 | if vault token lookup &>/dev/null; then 22 | echo "--> Revoking my token..." >&2 23 | vault token revoke -self 24 | fi 25 | fi 26 | set -e 27 | } 28 | trap finish EXIT 29 | 30 | assume_role() { 31 | role=$1 32 | echo "--> Assuming role: $role" >&2 33 | vault_creds=$(vault token create \ 34 | -display-name="$role" \ 35 | -format=json \ 36 | -role "$role") 37 | 38 | VAULT_EXIT_ACCESSOR=$(jq -r .auth.accessor <<<"$vault_creds") 39 | export VAULT_TOKEN 40 | VAULT_TOKEN=$(jq -r .auth.client_token <<<"$vault_creds") 41 | } 42 | 43 | function provision_aws_creds() { 44 | url="$1" 45 | local ok= 46 | echo "--> Setting AWS variables: " >&2 47 | echo " AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN" >&2 48 | 49 | aws_creds=$(vault kv get -format=json "$url") 50 | export AWS_ACCESS_KEY_ID 51 | AWS_ACCESS_KEY_ID=$(jq -r .data.access_key <<<"$aws_creds") 52 | export AWS_SECRET_ACCESS_KEY 53 | AWS_SECRET_ACCESS_KEY=$(jq -r .data.secret_key <<<"$aws_creds") 54 | export AWS_SESSION_TOKEN 55 | AWS_SESSION_TOKEN=$(jq -r .data.security_token <<<"$aws_creds") 56 | if [ -z "$AWS_SESSION_TOKEN" ] || [ "$AWS_SESSION_TOKEN" == "null" ]; then 57 | unset AWS_SESSION_TOKEN 58 | fi 59 | 60 | echo "--> Preflight testing the AWS credentials..." >&2 61 | for _ in {0..20}; do 62 | if check_output=$(aws sts get-caller-identity 2>&1 >/dev/null); then 63 | ok=1 64 | break 65 | else 66 | echo -n "." >&2 67 | sleep 1 68 | fi 69 | done 70 | if [[ -z "$ok" ]]; then 71 | echo $'\nPreflight test failed:\n'"$check_output" >&2 72 | return 1 73 | fi 74 | echo 75 | unset aws_creds 76 | } 77 | 78 | assume_role "internalservices_nix_installer_developer" 79 | provision_aws_creds "internalservices/aws/creds/nix_installer" 80 | 81 | if [ "${1:-}" == "" ]; then 82 | cat <<\BASH > "$scratch/bashrc" 83 | expiration_ts=$(date +%s -d "$(vault token lookup -format=json | jq -r '.data.expire_time')") 84 | vault_prompt() { 85 | local remaining=$(( $expiration_ts - $(date '+%s'))) 86 | if [[ "$remaining" -lt 1 ]]; then 87 | remaining=expired 88 | printf '\n\e[01;33mtoken expired\e[m'; 89 | return 90 | fi 91 | printf '\n\e[01;32mTTL:%ss\e[m' "$remaining" 92 | } 93 | PROMPT_COMMAND=vault_prompt 94 | BASH 95 | 96 | bash --init-file "$scratch/bashrc" 97 | else 98 | "$@" 99 | fi 100 | -------------------------------------------------------------------------------- /src/action/macos/enable_ownership.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionErrorKind, ActionTag, StatefulAction}; 7 | use crate::execute_command; 8 | 9 | use crate::action::{Action, ActionDescription}; 10 | use crate::os::darwin::DiskUtilInfoOutput; 11 | 12 | /** 13 | Enable ownership on a volume 14 | */ 15 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 16 | #[serde(tag = "action_name", rename = "enable_ownership")] 17 | pub struct EnableOwnership { 18 | path: PathBuf, 19 | } 20 | 21 | impl EnableOwnership { 22 | #[tracing::instrument(level = "debug", skip_all)] 23 | pub async fn plan(path: impl AsRef) -> Result, ActionError> { 24 | Ok(Self { 25 | path: path.as_ref().to_path_buf(), 26 | } 27 | .into()) 28 | } 29 | } 30 | 31 | #[async_trait::async_trait] 32 | #[typetag::serde(name = "enable_ownership")] 33 | impl Action for EnableOwnership { 34 | fn action_tag() -> ActionTag { 35 | ActionTag("enable_ownership") 36 | } 37 | fn tracing_synopsis(&self) -> String { 38 | format!("Enable ownership on `{}`", self.path.display()) 39 | } 40 | 41 | fn tracing_span(&self) -> Span { 42 | span!( 43 | tracing::Level::DEBUG, 44 | "enable_ownership", 45 | path = %self.path.display(), 46 | ) 47 | } 48 | 49 | fn execute_description(&self) -> Vec { 50 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 51 | } 52 | 53 | #[tracing::instrument(level = "debug", skip_all)] 54 | async fn execute(&mut self) -> Result<(), ActionError> { 55 | let should_enable_ownership = { 56 | let the_plist = DiskUtilInfoOutput::for_volume_path(&self.path) 57 | .await 58 | .map_err(Self::error)?; 59 | 60 | !the_plist.global_permissions_enabled 61 | }; 62 | 63 | if should_enable_ownership { 64 | execute_command( 65 | Command::new("/usr/sbin/diskutil") 66 | .process_group(0) 67 | .arg("enableOwnership") 68 | .arg(&self.path) 69 | .stdin(std::process::Stdio::null()), 70 | ) 71 | .await 72 | .map_err(Self::error)?; 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | fn revert_description(&self) -> Vec { 79 | vec![] 80 | } 81 | 82 | #[tracing::instrument(level = "debug", skip_all)] 83 | async fn revert(&mut self) -> Result<(), ActionError> { 84 | // noop 85 | Ok(()) 86 | } 87 | } 88 | 89 | #[non_exhaustive] 90 | #[derive(Debug, thiserror::Error)] 91 | pub enum EnableOwnershipError { 92 | #[error("Failed to execute command")] 93 | Command(#[source] std::io::Error), 94 | } 95 | 96 | impl From for ActionErrorKind { 97 | fn from(val: EnableOwnershipError) -> Self { 98 | ActionErrorKind::Custom(Box::new(val)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/cli/interaction.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::{stdin, stdout, BufRead, Write}; 3 | 4 | use eyre::{eyre, WrapErr}; 5 | use owo_colors::OwoColorize; 6 | 7 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 8 | pub enum PromptChoice { 9 | Yes, 10 | No, 11 | Explain, 12 | } 13 | 14 | // Do not try to get clever! 15 | // 16 | // Mac is extremely janky if you `curl $URL | sudo sh` and the TTY may not be set up right. 17 | // The below method was adopted from Rustup at https://github.com/rust-lang/rustup/blob/3331f34c01474bf216c99a1b1706725708833de1/src/cli/term2.rs#L37 18 | pub(crate) async fn prompt( 19 | question: impl AsRef, 20 | default: PromptChoice, 21 | currently_explaining: bool, 22 | ) -> eyre::Result { 23 | let stdout = stdout(); 24 | let terminfo = term::terminfo::TermInfo::from_env().unwrap_or_else(|_| { 25 | tracing::warn!("Couldn't find terminfo, using empty fallback terminfo"); 26 | term::terminfo::TermInfo { 27 | names: vec![], 28 | bools: HashMap::new(), 29 | numbers: HashMap::new(), 30 | strings: HashMap::new(), 31 | } 32 | }); 33 | let mut term = term::terminfo::TerminfoTerminal::new_with_terminfo(stdout, terminfo); 34 | let with_confirm = format!( 35 | "\ 36 | {question}\n\ 37 | \n\ 38 | {are_you_sure} ({yes}/{no}{maybe_explain}): \ 39 | ", 40 | question = question.as_ref(), 41 | are_you_sure = "Proceed?".bold(), 42 | no = if default == PromptChoice::No { 43 | "[N]o" 44 | } else { 45 | "[n]o" 46 | } 47 | .red(), 48 | yes = if default == PromptChoice::Yes { 49 | "[Y]es" 50 | } else { 51 | "[y]es" 52 | } 53 | .green(), 54 | maybe_explain = if !currently_explaining { 55 | format!( 56 | "/{}", 57 | if default == PromptChoice::Explain { 58 | "[E]xplain" 59 | } else { 60 | "[e]xplain" 61 | } 62 | ) 63 | } else { 64 | "".into() 65 | }, 66 | ); 67 | 68 | term.write_all(with_confirm.as_bytes())?; 69 | term.flush()?; 70 | 71 | let input = read_line()?; 72 | 73 | let r = match &*input.to_lowercase() { 74 | "y" | "yes" => PromptChoice::Yes, 75 | "n" | "no" => PromptChoice::No, 76 | "e" | "explain" => PromptChoice::Explain, 77 | "" => default, 78 | _ => PromptChoice::No, 79 | }; 80 | 81 | Ok(r) 82 | } 83 | 84 | pub(crate) fn read_line() -> eyre::Result { 85 | let stdin = stdin(); 86 | let stdin = stdin.lock(); 87 | let mut lines = stdin.lines(); 88 | let lines = lines.next().transpose()?; 89 | match lines { 90 | None => Err(eyre!("no lines found from stdin")), 91 | Some(v) => Ok(v), 92 | } 93 | .context("unable to read from stdin for confirmation") 94 | } 95 | 96 | pub(crate) async fn clean_exit_with_message(message: impl AsRef) -> ! { 97 | eprintln!("{}", message.as_ref()); 98 | std::process::exit(0) 99 | } 100 | -------------------------------------------------------------------------------- /src/action/macos/create_synthetic_objects.rs: -------------------------------------------------------------------------------- 1 | use tokio::process::Command; 2 | use tracing::{span, Span}; 3 | 4 | use crate::execute_command; 5 | 6 | use crate::action::{Action, ActionDescription, ActionError, ActionTag, StatefulAction}; 7 | 8 | /// Create the synthetic objects defined in `/etc/synthetic.conf` 9 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 10 | pub struct CreateSyntheticObjects; 11 | 12 | impl CreateSyntheticObjects { 13 | #[tracing::instrument(level = "debug", skip_all)] 14 | pub async fn plan() -> Result, ActionError> { 15 | Ok(Self.into()) 16 | } 17 | } 18 | 19 | #[async_trait::async_trait] 20 | #[typetag::serde(name = "create_synthetic_objects")] 21 | impl Action for CreateSyntheticObjects { 22 | fn action_tag() -> ActionTag { 23 | ActionTag("create_synthetic_objects") 24 | } 25 | fn tracing_synopsis(&self) -> String { 26 | "Create objects defined in `/etc/synthetic.conf`".to_string() 27 | } 28 | 29 | fn tracing_span(&self) -> Span { 30 | span!(tracing::Level::DEBUG, "create_synthetic_objects",) 31 | } 32 | 33 | fn execute_description(&self) -> Vec { 34 | vec![ActionDescription::new( 35 | self.tracing_synopsis(), 36 | vec!["Populates the `/nix` path".to_string()], 37 | )] 38 | } 39 | 40 | #[tracing::instrument(level = "debug", skip_all)] 41 | async fn execute(&mut self) -> Result<(), ActionError> { 42 | // Yup we literally call both and ignore the error! Reasoning: https://github.com/NixOS/nix/blob/95331cb9c99151cbd790ceb6ddaf49fc1c0da4b3/scripts/create-darwin-volume.sh#L261 43 | execute_command( 44 | Command::new("/System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util") 45 | .process_group(0) 46 | .arg("-t") 47 | .stdin(std::process::Stdio::null()), 48 | ) 49 | .await 50 | .ok(); // Deliberate 51 | execute_command( 52 | Command::new("/System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util") 53 | .process_group(0) 54 | .arg("-B") 55 | .stdin(std::process::Stdio::null()), 56 | ) 57 | .await 58 | .ok(); // Deliberate 59 | 60 | Ok(()) 61 | } 62 | 63 | fn revert_description(&self) -> Vec { 64 | vec![ActionDescription::new( 65 | "Refresh the objects defined in `/etc/synthetic.conf`".to_string(), 66 | vec!["Will remove the `/nix` path".to_string()], 67 | )] 68 | } 69 | 70 | #[tracing::instrument(level = "debug", skip_all)] 71 | async fn revert(&mut self) -> Result<(), ActionError> { 72 | // Yup we literally call both and ignore the error! Reasoning: https://github.com/NixOS/nix/blob/95331cb9c99151cbd790ceb6ddaf49fc1c0da4b3/scripts/create-darwin-volume.sh#L261 73 | execute_command( 74 | Command::new("/System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util") 75 | .process_group(0) 76 | .arg("-t") 77 | .stdin(std::process::Stdio::null()), 78 | ) 79 | .await 80 | .ok(); // Deliberate 81 | execute_command( 82 | Command::new("/System/Library/Filesystems/apfs.fs/Contents/Resources/apfs.util") 83 | .process_group(0) 84 | .arg("-B") 85 | .stdin(std::process::Stdio::null()), 86 | ) 87 | .await 88 | .ok(); // Deliberate 89 | 90 | Ok(()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/profile/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | pub(crate) mod nixenv; 4 | pub(crate) mod nixprofile; 5 | 6 | #[non_exhaustive] 7 | #[derive(Debug, thiserror::Error)] 8 | pub enum Error { 9 | #[error("Could not identify a home directory for root")] 10 | NoRootHome, 11 | 12 | #[error("Failed to enumerate a store path: {0}")] 13 | EnumeratingStorePathContent(std::io::Error), 14 | 15 | #[error("The following package has paths that intersect with other paths in other packages you want to install: {0}. Paths: {1:?}")] 16 | PathConflict(PathBuf, Vec), 17 | 18 | #[error("Failed to create a temp dir: {0}")] 19 | CreateTempDir(std::io::Error), 20 | 21 | #[error("Failed to start the nix command `{0}`: {1}")] 22 | StartNixCommand(String, std::io::Error), 23 | 24 | #[error("Failed to run the nix command `{0}`: {1:?}")] 25 | NixCommand(String, std::process::Output), 26 | #[error("Failed to add the package {0} to the profile: {1:?}")] 27 | AddPackage(PathBuf, std::process::Output), 28 | 29 | #[error("Failed to update the user's profile at {0}: {1:?}")] 30 | UpdateProfile(PathBuf, std::process::Output), 31 | 32 | #[error("Deserializing the list of installed packages for the profile: {0}")] 33 | Deserialization(#[from] serde_json::Error), 34 | } 35 | 36 | pub enum WriteToDefaultProfile { 37 | WriteToDefault, 38 | 39 | #[cfg(test)] 40 | Isolated, 41 | } 42 | 43 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 44 | pub enum BackendType { 45 | NixEnv, 46 | NixProfile, 47 | } 48 | 49 | pub(crate) struct Profile<'a> { 50 | pub nix_store_path: &'a Path, 51 | pub nss_ca_cert_path: &'a Path, 52 | 53 | pub profile: &'a Path, 54 | pub pkgs: &'a [&'a Path], 55 | } 56 | 57 | impl Profile<'_> { 58 | pub(crate) async fn install_packages( 59 | &self, 60 | to_default: WriteToDefaultProfile, 61 | ) -> Result<(), Error> { 62 | match get_profile_backend_type(self.profile).await { 63 | Some(BackendType::NixProfile) => { 64 | nixprofile::NixProfile { 65 | nix_store_path: self.nix_store_path, 66 | nss_ca_cert_path: self.nss_ca_cert_path, 67 | profile: self.profile, 68 | pkgs: self.pkgs, 69 | } 70 | .install_packages(to_default) 71 | .await 72 | }, 73 | _ => { 74 | nixenv::NixEnv { 75 | nix_store_path: self.nix_store_path, 76 | nss_ca_cert_path: self.nss_ca_cert_path, 77 | profile: self.profile, 78 | pkgs: self.pkgs, 79 | } 80 | .install_packages(to_default) 81 | .await 82 | }, 83 | } 84 | } 85 | } 86 | 87 | pub async fn get_profile_backend_type(profile: &std::path::Path) -> Option { 88 | // If the file has a manifest.json, that means `nix profile` touched it, and ONLY `nix profile` can touch it. 89 | if tokio::fs::metadata(profile.join("manifest.json")) 90 | .await 91 | .is_ok() 92 | { 93 | return Some(BackendType::NixProfile); 94 | } 95 | 96 | // If the file has a manifest.nix, that means it was created by `nix-env`. 97 | if tokio::fs::metadata(profile.join("manifest.nix")) 98 | .await 99 | .is_ok() 100 | { 101 | return Some(BackendType::NixEnv); 102 | } 103 | 104 | // If neither of those exist, it can be managed by either, so express no preference. 105 | None 106 | } 107 | -------------------------------------------------------------------------------- /src/action/linux/ensure_steamos_nix_directory.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::fs::create_dir; 4 | use tokio::process::Command; 5 | use tracing::{span, Span}; 6 | 7 | use crate::action::{ActionError, ActionErrorKind, ActionTag}; 8 | use crate::execute_command; 9 | 10 | use crate::action::{Action, ActionDescription, StatefulAction}; 11 | 12 | /** 13 | Ensure SeamOS's `/nix` folder exists. 14 | 15 | In SteamOS build ID 20230522.1000 (and, presumably, later) a `/nix` directory and related units 16 | exist. In previous versions of `nix-installer` the uninstall process would remove that directory. 17 | This action ensures that the folder does indeed exist. 18 | */ 19 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 20 | pub struct EnsureSteamosNixDirectory; 21 | 22 | impl EnsureSteamosNixDirectory { 23 | #[tracing::instrument(level = "debug", skip_all)] 24 | pub async fn plan() -> Result, ActionError> { 25 | if which::which("steamos-readonly").is_err() { 26 | return Err(Self::error(ActionErrorKind::MissingSteamosBinary( 27 | "steamos-readonly".into(), 28 | ))); 29 | } 30 | if Path::new("/nix").exists() { 31 | Ok(StatefulAction::completed(EnsureSteamosNixDirectory)) 32 | } else { 33 | Ok(StatefulAction::uncompleted(EnsureSteamosNixDirectory)) 34 | } 35 | } 36 | } 37 | 38 | #[async_trait::async_trait] 39 | #[typetag::serde(name = "ensure_steamos_nix_directory")] 40 | impl Action for EnsureSteamosNixDirectory { 41 | fn action_tag() -> ActionTag { 42 | ActionTag("ensure_steamos_nix_directory") 43 | } 44 | fn tracing_synopsis(&self) -> String { 45 | "Ensure SteamOS's `/nix` directory exists".to_string() 46 | } 47 | 48 | fn tracing_span(&self) -> Span { 49 | span!(tracing::Level::DEBUG, "ensure_steamos_nix_directory",) 50 | } 51 | 52 | fn execute_description(&self) -> Vec { 53 | vec![ActionDescription::new( 54 | self.tracing_synopsis(), 55 | vec![ 56 | "On more recent versions of SteamOS, a `/nix` folder now exists on the base image.".to_string(), 57 | "Previously, `nix-installer` created this directory through systemd units.".to_string(), 58 | "It's likely you updated SteamOS, then ran `/nix/nix-installer uninstall`, which deleted the `/nix` directory.".to_string(), 59 | ], 60 | )] 61 | } 62 | 63 | #[tracing::instrument(level = "debug", skip_all)] 64 | async fn execute(&mut self) -> Result<(), ActionError> { 65 | execute_command( 66 | Command::new("steamos-readonly") 67 | .process_group(0) 68 | .arg("disable") 69 | .stdin(std::process::Stdio::null()), 70 | ) 71 | .await 72 | .map_err(Self::error)?; 73 | 74 | let path = PathBuf::from("/nix"); 75 | create_dir(&path) 76 | .await 77 | .map_err(|e| ActionErrorKind::CreateDirectory(path.clone(), e)) 78 | .map_err(Self::error)?; 79 | 80 | execute_command( 81 | Command::new("steamos-readonly") 82 | .process_group(0) 83 | .arg("enable") 84 | .stdin(std::process::Stdio::null()), 85 | ) 86 | .await 87 | .map_err(Self::error)?; 88 | 89 | Ok(()) 90 | } 91 | 92 | fn revert_description(&self) -> Vec { 93 | vec![] 94 | } 95 | 96 | #[tracing::instrument(level = "debug", skip_all)] 97 | async fn revert(&mut self) -> Result<(), ActionError> { 98 | // noop 99 | 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /nix/tests/container-test/default.nix: -------------------------------------------------------------------------------- 1 | # Largely derived from https://github.com/NixOS/nix/blob/14f7dae3e4eb0c34192d0077383a7f2a2d630129/tests/installer/default.nix 2 | { forSystem, binaryTarball }: 3 | 4 | let 5 | images = { 6 | 7 | # Found via https://hub.docker.com/_/ubuntu/ under "How is the rootfs build?" 8 | # Jammy 9 | "ubuntu-v22_04" = { 10 | tarball = builtins.fetchurl { 11 | url = "http://cdimage.ubuntu.com/ubuntu-base/releases/22.04/release/ubuntu-base-22.04-base-amd64.tar.gz"; 12 | sha256 = "01sbpjb32x1z1yr9q78zrk0a6kfw5c4fxw1jqmm23g8ixryffvyz"; 13 | }; 14 | tester = ./default/Dockerfile; 15 | system = "x86_64-linux"; 16 | }; 17 | 18 | # focal 19 | "ubuntu-v20_04" = { 20 | tarball = builtins.fetchurl { 21 | url = "http://cdimage.ubuntu.com/ubuntu-base/releases/20.04/release/ubuntu-base-20.04.1-base-amd64.tar.gz"; 22 | sha256 = "0ryn38csmx41a415g9b3wk30csaxxlkgkdij9v4754pk877wpxlp"; 23 | }; 24 | tester = ./default/Dockerfile; 25 | system = "x86_64-linux"; 26 | }; 27 | 28 | # bionic 29 | "ubuntu-v18_04" = { 30 | tarball = builtins.fetchurl { 31 | url = "http://cdimage.ubuntu.com/ubuntu-base/releases/18.04/release/ubuntu-base-18.04.5-base-amd64.tar.gz"; 32 | sha256 = "1sh73pqwgyzkyssv3ngpxa2ynnkbdvjpxdw1v9ql4ghjpd3hpwlg"; 33 | }; 34 | tester = ./default/Dockerfile; 35 | system = "x86_64-linux"; 36 | }; 37 | }; 38 | 39 | makeTest = containerTool: imageName: 40 | let image = images.${imageName}; in 41 | with (forSystem image.system ({ system, pkgs, lib, ... }: pkgs)); 42 | testers.nixosTest 43 | { 44 | name = "container-test-${imageName}"; 45 | nodes = { 46 | machine = 47 | { config, pkgs, ... }: { 48 | virtualisation.${containerTool}.enable = true; 49 | virtualisation.diskSize = 4 * 1024; 50 | }; 51 | }; 52 | testScript = '' 53 | machine.start() 54 | machine.copy_from_host("${image.tarball}", "/image") 55 | machine.succeed("mkdir -p /test") 56 | machine.copy_from_host("${image.tester}", "/test/Dockerfile") 57 | machine.copy_from_host("${nix-installer-static}", "/test/nix-installer") 58 | machine.copy_from_host("${binaryTarball.${system}}", "/test/binary-tarball") 59 | machine.succeed("${containerTool} import /image default") 60 | machine.succeed("${containerTool} build -t test /test") 61 | ''; 62 | }; 63 | 64 | container-tests = builtins.mapAttrs 65 | (imageName: image: (with (forSystem "x86_64-linux" ({ system, pkgs, ... }: pkgs)); { 66 | ${image.system} = rec { 67 | docker = makeTest "docker" imageName; 68 | podman = makeTest "podman" imageName; 69 | all = pkgs.releaseTools.aggregate { 70 | name = "all"; 71 | constituents = [ 72 | docker 73 | podman 74 | ]; 75 | }; 76 | }; 77 | })) 78 | images; 79 | 80 | in 81 | container-tests // { 82 | all."x86_64-linux" = rec { 83 | all = (with (forSystem "x86_64-linux" ({ system, pkgs, ... }: pkgs)); pkgs.releaseTools.aggregate { 84 | name = "all"; 85 | constituents = [ 86 | docker 87 | podman 88 | ]; 89 | }); 90 | docker = (with (forSystem "x86_64-linux" ({ system, pkgs, ... }: pkgs)); pkgs.releaseTools.aggregate { 91 | name = "all"; 92 | constituents = pkgs.lib.mapAttrsToList (name: value: value."x86_64-linux".docker) container-tests; 93 | }); 94 | podman = (with (forSystem "x86_64-linux" ({ system, pkgs, ... }: pkgs)); pkgs.releaseTools.aggregate { 95 | name = "all"; 96 | constituents = pkgs.lib.mapAttrsToList (name: value: value."x86_64-linux".podman) container-tests; 97 | }); 98 | }; 99 | } 100 | 101 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nix-installer" 3 | description = "Experimental Nix Installer" 4 | version = "3.11.3" 5 | edition = "2021" 6 | resolver = "2" 7 | license = "LGPL-2.1" 8 | repository = "https://github.com/NixOS/experimental-nix-installer" 9 | 10 | [features] 11 | default = ["cli"] 12 | cli = ["eyre", "color-eyre", "clap", "tracing-subscriber", "tracing-error"] 13 | 14 | [[bin]] 15 | name = "nix-installer" 16 | required-features = [ "cli" ] 17 | 18 | [dependencies] 19 | async-trait = { version = "0.1.57", default-features = false } 20 | bytes = { version = "1.2.1", default-features = false, features = ["std", "serde"] } 21 | clap = { version = "4", features = ["std", "color", "usage", "help", "error-context", "suggestions", "derive", "env"], optional = true } 22 | color-eyre = { version = "0.6.2", default-features = false, features = [ "track-caller", "issue-url", "tracing-error", "capture-spantrace", "color-spantrace" ], optional = true } 23 | eyre = { version = "0.6.8", default-features = false, features = [ "track-caller" ], optional = true } 24 | glob = { version = "0.3.0", default-features = false } 25 | nix = { version = "0.29.0", default-features = false, features = ["user", "fs", "process", "term"] } 26 | owo-colors = { version = "4.0.0", default-features = false, features = [ "supports-colors" ] } 27 | reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls-native-roots", "stream", "socks"] } 28 | serde = { version = "1.0.203", default-features = false, features = [ "std", "derive" ] } 29 | serde_json = { version = "1.0.120", default-features = false, features = [ "std" ] } 30 | serde_with = { version = "3", default-features = false, features = [ "std", "macros" ] } 31 | tar = { version = "0.4.38", default-features = false, features = [ "xattr" ] } 32 | target-lexicon = { version = "0.12.4", default-features = false, features = [ "std" ] } 33 | thiserror = { version = "1.0.61", default-features = false } 34 | tokio = { version = "1.21.0", default-features = false, features = ["time", "io-std", "process", "fs", "signal", "tracing", "rt-multi-thread", "macros", "io-util", "parking_lot" ] } 35 | tracing = { version = "0.1.36", default-features = false, features = [ "std", "attributes" ] } 36 | tracing-error = { version = "0.2.0", default-features = false, optional = true, features = ["traced-error"] } 37 | tracing-subscriber = { version = "0.3.15", default-features = false, features = [ "std", "registry", "fmt", "json", "ansi", "env-filter" ], optional = true } 38 | url = { version = "2.3.1", default-features = false, features = ["serde"] } 39 | xz2 = { version = "0.1.7", default-features = false, features = ["static", "tokio"] } 40 | plist = { version = "=1.7.2", default-features = false, features = [ "serde" ]} 41 | dirs = { version = "5.0.0", default-features = false } 42 | typetag = { version = "0.2.17", default-features = false } 43 | dyn-clone = { version = "1.0.9", default-features = false } 44 | rand = { version = "0.8.5", default-features = false, features = [ "std", "std_rng" ] } 45 | semver = { version = "1.0.23", default-features = false, features = ["serde", "std"] } 46 | term = { version = "1.0.0", default-features = false } 47 | uuid = { version = "1.2.2", features = ["serde"] } 48 | os-release = { version = "0.1.0", default-features = false } 49 | strum = { version = "0.26.1", features = ["derive"] } 50 | nix-config-parser = { version = "0.2", features = ["serde"] } 51 | which = "6.0.0" 52 | sysctl = "0.6.0" 53 | walkdir = "2.3.3" 54 | indexmap = { version = "2.0.2", features = ["serde"] } 55 | once_cell = "1.19.0" 56 | tempfile = "3.3.0" 57 | 58 | [dev-dependencies] 59 | eyre = { version = "0.6.8", default-features = false, features = [ "track-caller" ] } 60 | 61 | [profile.release] 62 | strip = true # Automatically strip symbols from the binary. 63 | opt-level = "z" # Optimize for size. 64 | 65 | # Disabled for now while we are in experimental phase 66 | # This gives maybe 10% smaller binaries but increases compile time significantly. 67 | # lto = true 68 | -------------------------------------------------------------------------------- /src/action/macos/configure_remote_building.rs: -------------------------------------------------------------------------------- 1 | use crate::action::base::{create_or_insert_into_file, CreateOrInsertIntoFile}; 2 | use crate::action::{Action, ActionDescription, ActionError, ActionTag, StatefulAction}; 3 | 4 | use std::path::Path; 5 | use tracing::{span, Instrument, Span}; 6 | 7 | const PROFILE_NIX_FILE_SHELL: &str = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; 8 | 9 | /** 10 | Configure macOS's zshenv to load the Nix environment when ForceCommand is used. 11 | This enables remote building, which requires `ssh host nix` to work. 12 | */ 13 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 14 | #[serde(tag = "action_name", rename = "configure_remote_building")] 15 | pub struct ConfigureRemoteBuilding { 16 | create_or_insert_into_file: Option>, 17 | } 18 | 19 | impl ConfigureRemoteBuilding { 20 | #[tracing::instrument(level = "debug", skip_all)] 21 | pub async fn plan() -> Result, ActionError> { 22 | let shell_buf = format!( 23 | r#" 24 | # Set up Nix only on SSH connections 25 | # See: https://github.com/DeterminateSystems/nix-installer/pull/714 26 | if [ -e '{PROFILE_NIX_FILE_SHELL}' ] && [ -n "${{SSH_CONNECTION}}" ] && [ "${{SHLVL}}" -eq 1 ]; then 27 | . '{PROFILE_NIX_FILE_SHELL}' 28 | fi 29 | # End Nix 30 | "# 31 | ); 32 | 33 | let zshenv = Path::new("/etc/zshenv"); 34 | 35 | let create_or_insert_into_file = if !zshenv.is_symlink() { 36 | Some( 37 | CreateOrInsertIntoFile::plan( 38 | zshenv, 39 | None, 40 | None, 41 | 0o644, 42 | shell_buf.to_string(), 43 | create_or_insert_into_file::Position::Beginning, 44 | ) 45 | .await 46 | .map_err(Self::error)?, 47 | ) 48 | } else { 49 | None 50 | }; 51 | 52 | Ok(Self { 53 | create_or_insert_into_file, 54 | } 55 | .into()) 56 | } 57 | } 58 | 59 | #[async_trait::async_trait] 60 | #[typetag::serde(name = "configure_remote_building")] 61 | impl Action for ConfigureRemoteBuilding { 62 | fn action_tag() -> ActionTag { 63 | ActionTag("configure_remote_building") 64 | } 65 | fn tracing_synopsis(&self) -> String { 66 | "Configuring zsh to support using Nix in non-interactive shells".to_string() 67 | } 68 | 69 | fn tracing_span(&self) -> Span { 70 | span!(tracing::Level::DEBUG, "configure_remote_building",) 71 | } 72 | 73 | fn execute_description(&self) -> Vec { 74 | vec![ActionDescription::new( 75 | if self.create_or_insert_into_file.is_none() { 76 | "Skipping configuring zsh to support using Nix in non-interactive shells, `/etc/zshenv` is a symlink".to_string() 77 | } else { 78 | self.tracing_synopsis() 79 | }, 80 | vec!["Update `/etc/zshenv` to import Nix".to_string()], 81 | )] 82 | } 83 | 84 | #[tracing::instrument(level = "debug", skip_all)] 85 | async fn execute(&mut self) -> Result<(), ActionError> { 86 | let span = tracing::Span::current().clone(); 87 | if let Some(create_or_insert_into_file) = &mut self.create_or_insert_into_file { 88 | create_or_insert_into_file 89 | .try_execute() 90 | .instrument(span) 91 | .await 92 | .map_err(Self::error)?; 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | fn revert_description(&self) -> Vec { 99 | vec![ActionDescription::new( 100 | "Remove the Nix configuration from zsh's non-login shells".to_string(), 101 | vec!["Update `/etc/zshenv` to no longer import Nix".to_string()], 102 | )] 103 | } 104 | 105 | #[tracing::instrument(level = "debug", skip_all)] 106 | async fn revert(&mut self) -> Result<(), ActionError> { 107 | if let Some(create_or_insert_into_file) = &mut self.create_or_insert_into_file { 108 | create_or_insert_into_file.try_revert().await? 109 | }; 110 | 111 | Ok(()) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/action/macos/set_tmutil_exclusion.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix::process::ExitStatusExt as _; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use tokio::process::Command; 5 | use tracing::{span, Span}; 6 | 7 | use crate::action::{ActionError, ActionTag, StatefulAction}; 8 | use crate::execute_command; 9 | 10 | use crate::action::{Action, ActionDescription}; 11 | 12 | /** 13 | Set a time machine exclusion on a path. 14 | 15 | Note, this cannot be used on Volumes easily: 16 | 17 | ```bash,no_run 18 | % sudo tmutil addexclusion -v "Nix Store" 19 | tmutil: addexclusion requires Full Disk Access privileges. 20 | To allow this operation, select Full Disk Access in the Privacy 21 | tab of the Security & Privacy preference pane, and add Terminal 22 | to the list of applications which are allowed Full Disk Access. 23 | % sudo tmutil addexclusion /nix 24 | /nix: The operation couldn’t be completed. Invalid argument 25 | ``` 26 | 27 | */ 28 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 29 | #[serde(tag = "action_name", rename = "set_tmutil_exclusion")] 30 | pub struct SetTmutilExclusion { 31 | path: PathBuf, 32 | } 33 | 34 | impl SetTmutilExclusion { 35 | #[tracing::instrument(level = "debug", skip_all)] 36 | pub async fn plan(path: impl AsRef) -> Result, ActionError> { 37 | Ok(Self { 38 | path: path.as_ref().to_path_buf(), 39 | } 40 | .into()) 41 | } 42 | } 43 | 44 | #[async_trait::async_trait] 45 | #[typetag::serde(name = "set_tmutil_exclusion")] 46 | impl Action for SetTmutilExclusion { 47 | fn action_tag() -> ActionTag { 48 | ActionTag("set_tmutil_exclusion") 49 | } 50 | fn tracing_synopsis(&self) -> String { 51 | format!( 52 | "Configure Time Machine exclusion on `{}`", 53 | self.path.display() 54 | ) 55 | } 56 | 57 | fn tracing_span(&self) -> Span { 58 | span!( 59 | tracing::Level::DEBUG, 60 | "set_tmutil_exclusion", 61 | path = %self.path.display(), 62 | ) 63 | } 64 | 65 | fn execute_description(&self) -> Vec { 66 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 67 | } 68 | 69 | #[tracing::instrument(level = "debug", skip_all)] 70 | async fn execute(&mut self) -> Result<(), ActionError> { 71 | let tmutil_ret = execute_command( 72 | Command::new("tmutil") 73 | .process_group(0) 74 | .arg("addexclusion") 75 | .arg(&self.path) 76 | .stdin(std::process::Stdio::null()), 77 | ) 78 | .await; 79 | 80 | match tmutil_ret { 81 | Ok(_) => Ok(()), 82 | Err(err) => { 83 | if let crate::action::ActionErrorKind::CommandOutput { ref output, .. } = err { 84 | if output.status.signal() == Some(9) { 85 | tracing::debug!(%err, "tmutil failed because it was killed with signal 9; ignoring"); 86 | return Ok(()); 87 | } 88 | } 89 | 90 | Err(Self::error(err))? 91 | }, 92 | } 93 | } 94 | 95 | fn revert_description(&self) -> Vec { 96 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 97 | } 98 | 99 | #[tracing::instrument(level = "debug", skip_all)] 100 | async fn revert(&mut self) -> Result<(), ActionError> { 101 | let tmutil_ret = execute_command( 102 | Command::new("tmutil") 103 | .process_group(0) 104 | .arg("removeexclusion") 105 | .arg(&self.path) 106 | .stdin(std::process::Stdio::null()), 107 | ) 108 | .await; 109 | 110 | match tmutil_ret { 111 | Ok(_) => Ok(()), 112 | Err(err) => { 113 | if let crate::action::ActionErrorKind::CommandOutput { ref output, .. } = err { 114 | if output.status.signal() == Some(9) { 115 | tracing::debug!(%err, "tmutil failed because it was killed with signal 9; ignoring"); 116 | return Ok(()); 117 | } 118 | } 119 | 120 | Err(Self::error(err))? 121 | }, 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/action/base/delete_user.rs: -------------------------------------------------------------------------------- 1 | use nix::unistd::User; 2 | use target_lexicon::OperatingSystem; 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::base::create_user::delete_user_macos; 7 | use crate::action::{ActionError, ActionErrorKind, ActionTag}; 8 | use crate::execute_command; 9 | 10 | use crate::action::{Action, ActionDescription, StatefulAction}; 11 | 12 | /** 13 | Delete an operating system level user 14 | */ 15 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 16 | #[serde(tag = "action_name", rename = "delete_user")] 17 | pub struct DeleteUser { 18 | name: String, 19 | } 20 | 21 | impl DeleteUser { 22 | #[tracing::instrument(level = "debug", skip_all)] 23 | pub async fn plan(name: String) -> Result, ActionError> { 24 | let this = Self { name: name.clone() }; 25 | 26 | match OperatingSystem::host() { 27 | OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin => (), 28 | _ => { 29 | if !(which::which("userdel").is_ok() || which::which("deluser").is_ok()) { 30 | return Err(Self::error(ActionErrorKind::MissingUserDeletionCommand)); 31 | } 32 | }, 33 | } 34 | 35 | // Ensure user exists 36 | let _ = User::from_name(name.as_str()) 37 | .map_err(|e| ActionErrorKind::GettingUserId(name.clone(), e)) 38 | .map_err(Self::error)? 39 | .ok_or_else(|| ActionErrorKind::NoUser(name.clone())) 40 | .map_err(Self::error)?; 41 | 42 | // There is no "StatefulAction::completed" for this action since if the user is to be deleted 43 | // it is an error if it does not exist. 44 | 45 | Ok(StatefulAction::uncompleted(this)) 46 | } 47 | } 48 | 49 | #[async_trait::async_trait] 50 | #[typetag::serde(name = "delete_user")] 51 | impl Action for DeleteUser { 52 | fn action_tag() -> ActionTag { 53 | ActionTag("delete_user") 54 | } 55 | fn tracing_synopsis(&self) -> String { 56 | format!( 57 | "Delete user `{}`, which exists due to a previous install, but is no longer required", 58 | self.name 59 | ) 60 | } 61 | 62 | fn tracing_span(&self) -> Span { 63 | span!(tracing::Level::DEBUG, "delete_user", user = self.name,) 64 | } 65 | 66 | fn execute_description(&self) -> Vec { 67 | vec![ActionDescription::new( 68 | self.tracing_synopsis(), 69 | vec![format!( 70 | "Nix with `auto-allocate-uids = true` no longer requires explicitly created users, so this user can be removed" 71 | )], 72 | )] 73 | } 74 | 75 | #[tracing::instrument(level = "debug", skip_all)] 76 | async fn execute(&mut self) -> Result<(), ActionError> { 77 | match OperatingSystem::host() { 78 | OperatingSystem::MacOSX { .. } | OperatingSystem::Darwin => { 79 | delete_user_macos(&self.name).await.map_err(Self::error)?; 80 | }, 81 | _ => { 82 | if which::which("userdel").is_ok() { 83 | execute_command( 84 | Command::new("userdel") 85 | .process_group(0) 86 | .arg(&self.name) 87 | .stdin(std::process::Stdio::null()), 88 | ) 89 | .await 90 | .map_err(Self::error)?; 91 | } else if which::which("deluser").is_ok() { 92 | execute_command( 93 | Command::new("deluser") 94 | .process_group(0) 95 | .arg(&self.name) 96 | .stdin(std::process::Stdio::null()), 97 | ) 98 | .await 99 | .map_err(Self::error)?; 100 | } else { 101 | return Err(Self::error(ActionErrorKind::MissingUserDeletionCommand)); 102 | } 103 | }, 104 | }; 105 | 106 | Ok(()) 107 | } 108 | 109 | fn revert_description(&self) -> Vec { 110 | vec![] 111 | } 112 | 113 | #[tracing::instrument(level = "debug", skip_all)] 114 | async fn revert(&mut self) -> Result<(), ActionError> { 115 | Ok(()) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/action/common/delete_users.rs: -------------------------------------------------------------------------------- 1 | use crate::action::{ 2 | base::DeleteUser, Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, 3 | StatefulAction, 4 | }; 5 | use tracing::{span, Span}; 6 | 7 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 8 | #[serde(tag = "action_name", rename = "delete_users_in_group")] 9 | pub struct DeleteUsersInGroup { 10 | group_name: String, 11 | group_id: u32, 12 | delete_users: Vec>, 13 | } 14 | 15 | impl DeleteUsersInGroup { 16 | #[tracing::instrument(level = "debug", skip_all)] 17 | pub async fn plan( 18 | group_name: String, 19 | group_id: u32, 20 | users: Vec, 21 | ) -> Result, ActionError> { 22 | let mut delete_users = vec![]; 23 | for users in users { 24 | delete_users.push(DeleteUser::plan(users).await?) 25 | } 26 | 27 | Ok(Self { 28 | group_name, 29 | group_id, 30 | delete_users, 31 | } 32 | .into()) 33 | } 34 | } 35 | 36 | #[async_trait::async_trait] 37 | #[typetag::serde(name = "delete_users_in_group")] 38 | impl Action for DeleteUsersInGroup { 39 | fn action_tag() -> ActionTag { 40 | ActionTag("delete_users_in_group") 41 | } 42 | fn tracing_synopsis(&self) -> String { 43 | format!( 44 | "Delete users part of group `{}` (GID {}), they are part of a previous install and are no longer required with `auto-allocate-uids = true` in nix.conf", 45 | self.group_name, 46 | self.group_id, 47 | ) 48 | } 49 | 50 | fn tracing_span(&self) -> Span { 51 | span!( 52 | tracing::Level::DEBUG, 53 | "delete_users_in_group", 54 | group_name = self.group_name, 55 | group_id = self.group_id, 56 | ) 57 | } 58 | 59 | fn execute_description(&self) -> Vec { 60 | let mut delete_users_descriptions = Vec::new(); 61 | for delete_user in self.delete_users.iter() { 62 | if let Some(val) = delete_user.describe_execute().first() { 63 | delete_users_descriptions.push(val.description.clone()) 64 | } 65 | } 66 | 67 | let mut explanation = vec![ 68 | format!("The `auto-allocate-uids` feature allows Nix to create UIDs dynamically as needed, meaning these users leftover from a previous install can be deleted"), 69 | ]; 70 | explanation.append(&mut delete_users_descriptions); 71 | 72 | vec![ActionDescription::new(self.tracing_synopsis(), explanation)] 73 | } 74 | 75 | #[tracing::instrument(level = "debug", skip_all)] 76 | async fn execute(&mut self) -> Result<(), ActionError> { 77 | for delete_user in self.delete_users.iter_mut() { 78 | delete_user.try_execute().await.map_err(Self::error)?; 79 | } 80 | Ok(()) 81 | } 82 | 83 | fn revert_description(&self) -> Vec { 84 | let mut delete_users_descriptions = Vec::new(); 85 | for delete_user in self.delete_users.iter() { 86 | if let Some(val) = delete_user.describe_revert().first() { 87 | delete_users_descriptions.push(val.description.clone()) 88 | } 89 | } 90 | 91 | let mut explanation = vec![ 92 | format!("The `auto-allocate-uids` feature allows Nix to create UIDs dynamically as needed, meaning these users leftover from a previous install can be deleted"), 93 | ]; 94 | explanation.append(&mut delete_users_descriptions); 95 | 96 | vec![ActionDescription::new(self.tracing_synopsis(), explanation)] 97 | } 98 | 99 | #[tracing::instrument(level = "debug", skip_all)] 100 | async fn revert(&mut self) -> Result<(), ActionError> { 101 | let mut errors = vec![]; 102 | for delete_user in self.delete_users.iter_mut() { 103 | if let Err(err) = delete_user.try_revert().await { 104 | errors.push(err); 105 | } 106 | } 107 | 108 | if errors.is_empty() { 109 | Ok(()) 110 | } else if errors.len() == 1 { 111 | Err(errors 112 | .into_iter() 113 | .next() 114 | .expect("Expected 1 len Vec to have at least 1 item")) 115 | } else { 116 | Err(Self::error(ActionErrorKind::MultipleChildren(errors))) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/action/common/configure_upstream_init_service.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use tracing::{span, Span}; 4 | 5 | use crate::action::{ActionError, ActionTag, StatefulAction}; 6 | 7 | use crate::action::common::configure_init_service::{SocketFile, UnitSrc}; 8 | use crate::action::{common::ConfigureInitService, Action, ActionDescription}; 9 | use crate::settings::InitSystem; 10 | 11 | // Linux 12 | const SERVICE_SRC: &str = "/nix/var/nix/profiles/default/lib/systemd/system/nix-daemon.service"; 13 | const SERVICE_DEST: &str = "/etc/systemd/system/nix-daemon.service"; 14 | 15 | // Darwin 16 | const DARWIN_NIX_DAEMON_SOURCE: &str = 17 | "/nix/var/nix/profiles/default/Library/LaunchDaemons/org.nixos.nix-daemon.plist"; 18 | pub(crate) const DARWIN_NIX_DAEMON_DEST: &str = "/Library/LaunchDaemons/org.nixos.nix-daemon.plist"; 19 | const DARWIN_LAUNCHD_SERVICE_NAME: &str = "org.nixos.nix-daemon"; 20 | 21 | /** 22 | Configure the init to run the Nix daemon 23 | */ 24 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 25 | #[serde(tag = "action_name", rename = "create_upstream_init_service")] 26 | pub struct ConfigureUpstreamInitService { 27 | configure_init_service: StatefulAction, 28 | } 29 | 30 | impl ConfigureUpstreamInitService { 31 | #[tracing::instrument(level = "debug", skip_all)] 32 | pub async fn plan( 33 | init: InitSystem, 34 | start_daemon: bool, 35 | ) -> Result, ActionError> { 36 | let service_src: Option = match init { 37 | InitSystem::Launchd => Some(UnitSrc::Path(DARWIN_NIX_DAEMON_SOURCE.into())), 38 | InitSystem::Systemd => Some(UnitSrc::Path(SERVICE_SRC.into())), 39 | InitSystem::None => None, 40 | }; 41 | let service_dest: Option = match init { 42 | InitSystem::Launchd => Some(DARWIN_NIX_DAEMON_DEST.into()), 43 | InitSystem::Systemd => Some(SERVICE_DEST.into()), 44 | InitSystem::None => None, 45 | }; 46 | let service_name: Option = match init { 47 | InitSystem::Launchd => Some(DARWIN_LAUNCHD_SERVICE_NAME.into()), 48 | _ => None, 49 | }; 50 | 51 | let configure_init_service = ConfigureInitService::plan( 52 | init, 53 | start_daemon, 54 | service_src, 55 | service_dest, 56 | service_name, 57 | vec![SocketFile { 58 | name: "nix-daemon.socket".into(), 59 | src: UnitSrc::Path( 60 | "/nix/var/nix/profiles/default/lib/systemd/system/nix-daemon.socket".into(), 61 | ), 62 | dest: "/etc/systemd/system/nix-daemon.socket".into(), 63 | }], 64 | ) 65 | .await 66 | .map_err(Self::error)?; 67 | 68 | Ok(Self { 69 | configure_init_service, 70 | } 71 | .into()) 72 | } 73 | } 74 | 75 | #[async_trait::async_trait] 76 | #[typetag::serde(name = "create_upstream_init_service")] 77 | impl Action for ConfigureUpstreamInitService { 78 | fn action_tag() -> ActionTag { 79 | ActionTag("create_upstream_init_service") 80 | } 81 | fn tracing_synopsis(&self) -> String { 82 | "Configure upstream Nix daemon service".to_string() 83 | } 84 | 85 | fn tracing_span(&self) -> Span { 86 | span!(tracing::Level::DEBUG, "create_upstream_init_service",) 87 | } 88 | 89 | fn execute_description(&self) -> Vec { 90 | vec![ActionDescription::new( 91 | self.tracing_synopsis(), 92 | vec![self.configure_init_service.tracing_synopsis()], 93 | )] 94 | } 95 | 96 | #[tracing::instrument(level = "debug", skip_all)] 97 | async fn execute(&mut self) -> Result<(), ActionError> { 98 | self.configure_init_service 99 | .try_execute() 100 | .await 101 | .map_err(Self::error)?; 102 | 103 | Ok(()) 104 | } 105 | 106 | fn revert_description(&self) -> Vec { 107 | vec![ActionDescription::new( 108 | "Remove upstream Nix daemon service".to_string(), 109 | vec![self.configure_init_service.tracing_synopsis()], 110 | )] 111 | } 112 | 113 | #[tracing::instrument(level = "debug", skip_all)] 114 | async fn revert(&mut self) -> Result<(), ActionError> { 115 | self.configure_init_service.try_revert().await?; 116 | 117 | Ok(()) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/action/linux/provision_selinux.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionErrorKind, ActionTag}; 7 | use crate::execute_command; 8 | 9 | use crate::action::{Action, ActionDescription, StatefulAction}; 10 | use crate::util::OnMissing; 11 | 12 | pub const SELINUX_POLICY_PP_CONTENT: &[u8] = include_bytes!("selinux/nix.pp"); 13 | 14 | /** 15 | Provision the selinux/nix.pp for SELinux compatibility 16 | */ 17 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 18 | #[serde(tag = "action_name", rename = "provision_selinux")] 19 | pub struct ProvisionSelinux { 20 | policy_path: PathBuf, 21 | policy_content: Vec, 22 | } 23 | 24 | impl ProvisionSelinux { 25 | #[tracing::instrument(level = "debug", skip_all)] 26 | pub async fn plan( 27 | policy_path: PathBuf, 28 | policy_content: &[u8], 29 | ) -> Result, ActionError> { 30 | let this = Self { 31 | policy_path, 32 | policy_content: policy_content.to_vec(), 33 | }; 34 | 35 | // Note: `restorecon` requires us to not just skip this, even if everything is in place. 36 | 37 | Ok(StatefulAction::uncompleted(this)) 38 | } 39 | } 40 | 41 | #[async_trait::async_trait] 42 | #[typetag::serde(name = "provision_selinux")] 43 | impl Action for ProvisionSelinux { 44 | fn action_tag() -> ActionTag { 45 | ActionTag("provision_selinux") 46 | } 47 | fn tracing_synopsis(&self) -> String { 48 | "Install an SELinux Policy for Nix".to_string() 49 | } 50 | 51 | fn tracing_span(&self) -> Span { 52 | span!( 53 | tracing::Level::DEBUG, 54 | "provision_selinux", 55 | policy_path = %self.policy_path.display() 56 | ) 57 | } 58 | 59 | fn execute_description(&self) -> Vec { 60 | vec![ActionDescription::new( 61 | self.tracing_synopsis(), 62 | vec![format!( 63 | "On SELinux systems (such as Fedora) a policy for Nix needs to be configured for correct operation." 64 | )], 65 | )] 66 | } 67 | 68 | #[tracing::instrument(level = "debug", skip_all)] 69 | async fn execute(&mut self) -> Result<(), ActionError> { 70 | if self.policy_path.exists() { 71 | // Rebuild it. 72 | remove_existing_policy(&self.policy_path) 73 | .await 74 | .map_err(Self::error)?; 75 | } 76 | 77 | if let Some(parent) = self.policy_path.parent() { 78 | tokio::fs::create_dir_all(&parent) 79 | .await 80 | .map_err(|e| ActionErrorKind::CreateDirectory(parent.into(), e)) 81 | .map_err(Self::error)?; 82 | } 83 | 84 | tokio::fs::write(&self.policy_path, &self.policy_content) 85 | .await 86 | .map_err(|e| ActionErrorKind::Write(self.policy_path.clone(), e)) 87 | .map_err(Self::error)?; 88 | 89 | execute_command( 90 | Command::new("semodule") 91 | .arg("--install") 92 | .arg(&self.policy_path), 93 | ) 94 | .await 95 | .map_err(Self::error)?; 96 | 97 | execute_command(Command::new("restorecon").args(["-FR", "/nix"])) 98 | .await 99 | .map_err(Self::error)?; 100 | 101 | Ok(()) 102 | } 103 | 104 | fn revert_description(&self) -> Vec { 105 | vec![ActionDescription::new( 106 | "Remove the SELinux policy for Nix".into(), 107 | vec![], 108 | )] 109 | } 110 | 111 | #[tracing::instrument(level = "debug", skip_all)] 112 | async fn revert(&mut self) -> Result<(), ActionError> { 113 | if self.policy_path.exists() { 114 | remove_existing_policy(&self.policy_path) 115 | .await 116 | .map_err(Self::error)?; 117 | } 118 | 119 | Ok(()) 120 | } 121 | } 122 | 123 | async fn remove_existing_policy(policy_path: &Path) -> Result<(), ActionErrorKind> { 124 | execute_command(Command::new("semodule").arg("--remove").arg("nix")).await?; 125 | 126 | crate::util::remove_file(policy_path, OnMissing::Ignore) 127 | .await 128 | .map_err(|e| ActionErrorKind::Remove(policy_path.into(), e))?; 129 | 130 | execute_command(Command::new("restorecon").args(["-FR", "/nix"])).await?; 131 | 132 | Ok(()) 133 | } 134 | -------------------------------------------------------------------------------- /src/action/macos/unmount_apfs_volume.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionTag, StatefulAction}; 7 | use crate::execute_command; 8 | 9 | use crate::action::{Action, ActionDescription}; 10 | use crate::os::darwin::DiskUtilInfoOutput; 11 | 12 | /** 13 | Unmount an APFS volume 14 | */ 15 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 16 | #[serde(tag = "action_name", rename = "unmount_apfs_volume")] 17 | pub struct UnmountApfsVolume { 18 | disk: PathBuf, 19 | name: String, 20 | } 21 | 22 | impl UnmountApfsVolume { 23 | #[tracing::instrument(level = "debug", skip_all)] 24 | pub async fn plan( 25 | disk: impl AsRef, 26 | name: String, 27 | ) -> Result, ActionError> { 28 | let disk = disk.as_ref().to_owned(); 29 | Ok(Self { disk, name }.into()) 30 | } 31 | 32 | #[tracing::instrument(level = "debug", skip_all)] 33 | pub async fn plan_skip_if_already_mounted_to_nix( 34 | disk: impl AsRef, 35 | name: String, 36 | ) -> Result, ActionError> { 37 | let diskinfo = DiskUtilInfoOutput::for_volume_name(&name).await; 38 | 39 | let task = Self { 40 | disk: disk.as_ref().to_owned(), 41 | name, 42 | }; 43 | 44 | if let Ok(diskinfo) = diskinfo { 45 | if Path::new(&diskinfo.parent_whole_disk) == disk.as_ref() 46 | && diskinfo.mount_point.as_deref() == Some("/nix".as_ref()) 47 | { 48 | return Ok(StatefulAction::skipped(task)); 49 | } 50 | } 51 | 52 | Ok(task.into()) 53 | } 54 | } 55 | 56 | #[async_trait::async_trait] 57 | #[typetag::serde(name = "unmount_apfs_volume")] 58 | impl Action for UnmountApfsVolume { 59 | fn action_tag() -> ActionTag { 60 | ActionTag("unmount_apfs_volume") 61 | } 62 | fn tracing_synopsis(&self) -> String { 63 | format!("Unmount the `{}` APFS volume", self.name) 64 | } 65 | 66 | fn tracing_span(&self) -> Span { 67 | span!( 68 | tracing::Level::DEBUG, 69 | "unmount_volume", 70 | disk = tracing::field::display(self.disk.display()), 71 | name = self.name, 72 | ) 73 | } 74 | 75 | fn execute_description(&self) -> Vec { 76 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 77 | } 78 | 79 | #[tracing::instrument(level = "debug", skip_all)] 80 | async fn execute(&mut self) -> Result<(), ActionError> { 81 | let currently_mounted = { 82 | let the_plist = DiskUtilInfoOutput::for_volume_name(&self.name) 83 | .await 84 | .map_err(Self::error)?; 85 | 86 | the_plist.is_mounted() 87 | }; 88 | 89 | if currently_mounted { 90 | execute_command( 91 | Command::new("/usr/sbin/diskutil") 92 | .process_group(0) 93 | .args(["unmount", "force"]) 94 | .arg(&self.name) 95 | .stdin(std::process::Stdio::null()), 96 | ) 97 | .await 98 | .map_err(Self::error)?; 99 | } else { 100 | tracing::debug!("Volume was already unmounted, can skip unmounting") 101 | } 102 | 103 | Ok(()) 104 | } 105 | 106 | fn revert_description(&self) -> Vec { 107 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 108 | } 109 | 110 | #[tracing::instrument(level = "debug", skip_all)] 111 | async fn revert(&mut self) -> Result<(), ActionError> { 112 | let currently_mounted = { 113 | let the_plist = DiskUtilInfoOutput::for_volume_name(&self.name) 114 | .await 115 | .map_err(Self::error)?; 116 | 117 | the_plist.is_mounted() 118 | }; 119 | 120 | if currently_mounted { 121 | execute_command( 122 | Command::new("/usr/sbin/diskutil") 123 | .process_group(0) 124 | .args(["unmount", "force"]) 125 | .arg(&self.name) 126 | .stdin(std::process::Stdio::null()), 127 | ) 128 | .await 129 | .map_err(Self::error)?; 130 | } else { 131 | tracing::debug!("Volume was already unmounted, can skip unmounting") 132 | } 133 | 134 | Ok(()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/action/common/setup_channels.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | action::{ActionError, ActionErrorKind, ActionTag, StatefulAction}, 5 | execute_command, 6 | }; 7 | 8 | use tokio::process::Command; 9 | use tracing::{span, Span}; 10 | 11 | use crate::action::{Action, ActionDescription}; 12 | 13 | use crate::action::base::CreateFile; 14 | 15 | use super::ConfigureNix; 16 | 17 | /** 18 | Setup the default system channel with nixpkgs-unstable. 19 | */ 20 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 21 | pub struct SetupChannels { 22 | create_file: StatefulAction, 23 | unpacked_path: PathBuf, 24 | } 25 | 26 | impl SetupChannels { 27 | #[tracing::instrument(level = "debug", skip_all)] 28 | pub async fn plan(unpacked_path: PathBuf) -> Result, ActionError> { 29 | let create_file = CreateFile::plan( 30 | dirs::home_dir() 31 | .ok_or_else(|| Self::error(SetupChannelsError::NoRootHome))? 32 | .join(".nix-channels"), 33 | None, 34 | None, 35 | 0o664, 36 | "https://nixos.org/channels/nixpkgs-unstable nixpkgs\n".to_string(), 37 | false, 38 | ) 39 | .await?; 40 | Ok(Self { 41 | create_file, 42 | unpacked_path, 43 | } 44 | .into()) 45 | } 46 | } 47 | 48 | #[async_trait::async_trait] 49 | #[typetag::serde(name = "setup_channels")] 50 | impl Action for SetupChannels { 51 | fn action_tag() -> ActionTag { 52 | ActionTag("setup_channels") 53 | } 54 | fn tracing_synopsis(&self) -> String { 55 | "Setup the default system channel".to_string() 56 | } 57 | 58 | fn tracing_span(&self) -> Span { 59 | span!( 60 | tracing::Level::DEBUG, 61 | "setup_channels", 62 | unpacked_path = %self.unpacked_path.display(), 63 | ) 64 | } 65 | 66 | fn execute_description(&self) -> Vec { 67 | let mut explanation = vec![]; 68 | 69 | if let Some(val) = self.create_file.describe_execute().first() { 70 | explanation.push(val.description.clone()) 71 | } 72 | 73 | explanation.push("Run `nix-channel --update nixpkgs`".to_string()); 74 | 75 | vec![ActionDescription::new(self.tracing_synopsis(), explanation)] 76 | } 77 | 78 | #[tracing::instrument(level = "debug", skip_all)] 79 | async fn execute(&mut self) -> Result<(), ActionError> { 80 | // Place channel configuration 81 | self.create_file.try_execute().await?; 82 | 83 | let (nix_pkg, nss_ca_cert_pkg) = 84 | ConfigureNix::find_nix_and_ca_cert(&self.unpacked_path).await?; 85 | // Update nixpkgs channel 86 | execute_command( 87 | Command::new(nix_pkg.join("bin/nix-channel")) 88 | .process_group(0) 89 | .arg("--update") 90 | .arg("nixpkgs") 91 | .stdin(std::process::Stdio::null()) 92 | .env( 93 | "HOME", 94 | dirs::home_dir().ok_or_else(|| Self::error(SetupChannelsError::NoRootHome))?, 95 | ) 96 | .env( 97 | "NIX_SSL_CERT_FILE", 98 | nss_ca_cert_pkg.join("etc/ssl/certs/ca-bundle.crt"), 99 | ), /* We could rely on setup_default_profile setting this 100 | environment variable, but add this just to be explicit. */ 101 | ) 102 | .await 103 | .map_err(Self::error)?; 104 | 105 | Ok(()) 106 | } 107 | 108 | fn revert_description(&self) -> Vec { 109 | vec![ActionDescription::new( 110 | "Remove system channel configuration".to_string(), 111 | vec![], 112 | )] 113 | } 114 | 115 | #[tracing::instrument(level = "debug", skip_all)] 116 | async fn revert(&mut self) -> Result<(), ActionError> { 117 | self.create_file.try_revert().await?; 118 | 119 | // We could try to rollback 120 | // /nix/var/nix/profiles/per-user/root/channels, but that will happen 121 | // anyways when /nix gets cleaned up. 122 | 123 | Ok(()) 124 | } 125 | } 126 | 127 | #[non_exhaustive] 128 | #[derive(Debug, thiserror::Error)] 129 | pub enum SetupChannelsError { 130 | #[error("No root home found to place channel configuration in")] 131 | NoRootHome, 132 | } 133 | 134 | impl From for ActionErrorKind { 135 | fn from(val: SetupChannelsError) -> Self { 136 | ActionErrorKind::Custom(Box::new(val)) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/planner/macos/profile_queries.rs: -------------------------------------------------------------------------------- 1 | use crate::planner::macos::profiles::{ 2 | HardDiskInternalOpts, MountControls, Policies, Profile, ProfileItem, SystemUIServer, Target, 3 | }; 4 | 5 | struct TargetProfileItem<'a> { 6 | target: &'a Target, 7 | profile: &'a Profile, 8 | item: &'a ProfileItem, 9 | } 10 | 11 | pub struct TargetProfileHardDiskInternalOpts<'a> { 12 | pub target: &'a Target, 13 | pub profile: &'a Profile, 14 | pub opts: &'a [HardDiskInternalOpts], 15 | } 16 | 17 | impl TargetProfileHardDiskInternalOpts<'_> { 18 | pub fn display(&self) -> String { 19 | let owner = match self.target { 20 | crate::planner::macos::profiles::Target::Computer => { 21 | "A computer-wide profile".to_string() 22 | }, 23 | crate::planner::macos::profiles::Target::User(u) => format!("A profile owned by {u}"), 24 | }; 25 | 26 | let desc = [ 27 | ("Name", &self.profile.profile_display_name), 28 | ( 29 | "Version", 30 | &self.profile.profile_version.map(|v| v.to_string()), 31 | ), 32 | ("Description", &self.profile.profile_description), 33 | ("ID", &self.profile.profile_identifier), 34 | ("UUID", &self.profile.profile_uuid), 35 | ("Installation Date", &self.profile.profile_install_date), 36 | ] 37 | .into_iter() 38 | .filter_map(|(k, v)| Some((k, (*v).as_ref()?))) 39 | .map(|(key, val)| format!(" * {}: {}", key, val)) 40 | .collect::>() 41 | .join("\n"); 42 | 43 | format!("{owner}:\n{}\n", desc) 44 | } 45 | } 46 | 47 | fn flatten(policies: &Policies) -> impl Iterator { 48 | policies 49 | .iter() 50 | .flat_map(|(target, profiles): (&Target, &Vec)| { 51 | profiles.iter().map(move |profile| (target, profile)) 52 | }) 53 | .flat_map(|(target, profile): (&Target, &Profile)| { 54 | profile 55 | .profile_items 56 | .iter() 57 | .map(move |item| TargetProfileItem { 58 | target, 59 | profile, 60 | item, 61 | }) 62 | }) 63 | } 64 | 65 | pub fn blocks_internal_mounting(policies: &Policies) -> Vec { 66 | flatten(policies) 67 | .filter_map(move |target_profile_item| { 68 | let ProfileItem::SystemUIServer(system_ui_server) = target_profile_item.item else { 69 | return None; 70 | }; 71 | let SystemUIServer { 72 | mount_controls: Some(mount_controls), 73 | } = system_ui_server 74 | else { 75 | return None; 76 | }; 77 | 78 | let MountControls { harddisk_internal } = mount_controls; 79 | 80 | Some(TargetProfileHardDiskInternalOpts { 81 | target: target_profile_item.target, 82 | profile: target_profile_item.profile, 83 | opts: harddisk_internal, 84 | }) 85 | }) 86 | .filter(|TargetProfileHardDiskInternalOpts { opts, .. }| { 87 | opts.iter().any(|x| { 88 | [ 89 | HardDiskInternalOpts::ReadOnly, 90 | HardDiskInternalOpts::Deny, 91 | HardDiskInternalOpts::Eject, 92 | ] 93 | .contains(x) 94 | }) 95 | }) 96 | .collect() 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn generate_error() { 105 | let parsed: Policies = plist::from_reader(std::io::Cursor::new(include_str!( 106 | "./profile.sample.block.plist" 107 | ))) 108 | .unwrap(); 109 | 110 | let blocks = blocks_internal_mounting(&parsed); 111 | let err = &blocks[0]; 112 | 113 | assert_eq!( 114 | r#"A profile owned by foo: 115 | * Name: Don't allow mounting internal devices 116 | * Version: 1 117 | * Description: The description 118 | * ID: MyProfile.6F6670A3-65AC-4EA4-8665-91F8FCE289AB 119 | * UUID: 6F6670A3-65AC-4EA4-8665-91F8FCE289AB 120 | * Installation Date: 2024-04-22 14:12:42 +0000"# 121 | .trim() 122 | .to_string(), 123 | err.display().trim() 124 | ); 125 | } 126 | 127 | #[test] 128 | fn no_error() { 129 | let parsed: Policies = plist::from_reader(std::io::Cursor::new(include_str!( 130 | "./profile.sample.unknown.plist" 131 | ))) 132 | .unwrap(); 133 | 134 | let blocks = blocks_internal_mounting(&parsed); 135 | assert!(blocks.is_empty()); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/self_test.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Output, time::SystemTime}; 2 | 3 | use tokio::process::Command; 4 | use which::which; 5 | 6 | #[non_exhaustive] 7 | #[derive(thiserror::Error, Debug, strum::IntoStaticStr)] 8 | pub enum SelfTestError { 9 | #[error("Shell `{shell}` failed self-test with command `{command}`, stderr:\n{}", String::from_utf8_lossy(&output.stderr))] 10 | ShellFailed { 11 | shell: Shell, 12 | command: String, 13 | output: Output, 14 | }, 15 | /// Failed to execute command 16 | #[error("Failed to execute command `{command}`", 17 | command = .command, 18 | )] 19 | Command { 20 | shell: Shell, 21 | command: String, 22 | #[source] 23 | error: std::io::Error, 24 | }, 25 | #[error(transparent)] 26 | SystemTime(#[from] std::time::SystemTimeError), 27 | } 28 | 29 | #[derive(Clone, Copy, Debug)] 30 | pub enum Shell { 31 | Sh, 32 | Bash, 33 | Fish, 34 | Zsh, 35 | } 36 | 37 | impl std::fmt::Display for Shell { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | write!(f, "{}", self.executable()) 40 | } 41 | } 42 | 43 | impl Shell { 44 | pub fn all() -> &'static [Shell] { 45 | &[Shell::Sh, Shell::Bash, Shell::Fish, Shell::Zsh] 46 | } 47 | pub fn executable(&self) -> &'static str { 48 | match &self { 49 | Shell::Sh => "sh", 50 | Shell::Bash => "bash", 51 | Shell::Fish => "fish", 52 | Shell::Zsh => "zsh", 53 | } 54 | } 55 | 56 | #[tracing::instrument(skip_all)] 57 | pub async fn self_test(&self) -> Result<(), SelfTestError> { 58 | let executable = self.executable(); 59 | let mut command = match &self { 60 | // On Mac, `bash -ic nix` won't work, but `bash -lc nix` will. 61 | Shell::Sh | Shell::Bash => { 62 | let mut command = Command::new(executable); 63 | command.arg("-lc"); 64 | command 65 | }, 66 | Shell::Zsh | Shell::Fish => { 67 | let mut command = Command::new(executable); 68 | command.arg("-ic"); 69 | command 70 | }, 71 | }; 72 | 73 | #[cfg(all(target_os = "linux", target_arch = "x86_64"))] 74 | const SYSTEM: &str = "x86_64-linux"; 75 | #[cfg(all(target_os = "linux", target_arch = "aarch64"))] 76 | const SYSTEM: &str = "aarch64-linux"; 77 | #[cfg(all(target_os = "macos", target_arch = "x86_64"))] 78 | const SYSTEM: &str = "x86_64-darwin"; 79 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 80 | const SYSTEM: &str = "aarch64-darwin"; 81 | 82 | let timestamp_millis = SystemTime::now() 83 | .duration_since(SystemTime::UNIX_EPOCH)? 84 | .as_millis(); 85 | 86 | command.arg(format!( 87 | r#"exec nix build --option substitute false --option post-build-hook '' --no-link --expr 'derivation {{ name = "self-test-{executable}-{timestamp_millis}"; system = "{SYSTEM}"; builder = "/bin/sh"; args = ["-c" "echo hello > \$out"]; }}'"# 88 | )); 89 | let command_str = format!("{:?}", command.as_std()); 90 | 91 | tracing::debug!( 92 | command = command_str, 93 | "Testing Nix install via `{executable}`" 94 | ); 95 | let output = command 96 | .stdin(std::process::Stdio::null()) 97 | .env("NIX_REMOTE", "daemon") 98 | .output() 99 | .await 100 | .map_err(|error| SelfTestError::Command { 101 | shell: *self, 102 | command: command_str.clone(), 103 | error, 104 | })?; 105 | 106 | if output.status.success() { 107 | Ok(()) 108 | } else { 109 | Err(SelfTestError::ShellFailed { 110 | shell: *self, 111 | command: command_str, 112 | output, 113 | }) 114 | } 115 | } 116 | 117 | #[tracing::instrument(skip_all)] 118 | pub fn discover() -> Vec { 119 | let mut found_shells = vec![]; 120 | for shell in Self::all() { 121 | if which(shell.executable()).is_ok() { 122 | tracing::debug!("Discovered `{shell}`"); 123 | found_shells.push(*shell) 124 | } 125 | } 126 | found_shells 127 | } 128 | } 129 | 130 | #[tracing::instrument(skip_all)] 131 | pub async fn self_test() -> Result<(), Vec> { 132 | let shells = Shell::discover(); 133 | 134 | let mut failures = vec![]; 135 | 136 | for shell in shells { 137 | match shell.self_test().await { 138 | Ok(()) => (), 139 | Err(err) => failures.push(err), 140 | } 141 | } 142 | 143 | if failures.is_empty() { 144 | Ok(()) 145 | } else { 146 | Err(failures) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/action/macos/set_tmutil_exclusions.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use tracing::{span, Span}; 4 | 5 | use crate::action::{ 6 | Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction, 7 | }; 8 | 9 | use super::SetTmutilExclusion; 10 | 11 | /** 12 | Set a time machine exclusion on several paths. 13 | 14 | Note, this cannot be used on Volumes easily: 15 | 16 | ```bash,no_run 17 | % sudo tmutil addexclusion -v "Nix Store" 18 | tmutil: addexclusion requires Full Disk Access privileges. 19 | To allow this operation, select Full Disk Access in the Privacy 20 | tab of the Security & Privacy preference pane, and add Terminal 21 | to the list of applications which are allowed Full Disk Access. 22 | % sudo tmutil addexclusion /nix 23 | /nix: The operation couldn’t be completed. Invalid argument 24 | ``` 25 | 26 | */ 27 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 28 | #[serde(tag = "action_name", rename = "set_tmutil_exclusions")] 29 | pub struct SetTmutilExclusions { 30 | set_tmutil_exclusions: Vec>, 31 | } 32 | 33 | impl SetTmutilExclusions { 34 | #[tracing::instrument(level = "debug", skip_all)] 35 | pub async fn plan(paths: Vec) -> Result, ActionError> { 36 | /* Testing with `sudo tmutil addexclusion -p /nix` and `sudo tmutil addexclusion -v "Nix Store"` on DetSys's Macs 37 | yielded this error: 38 | 39 | ``` 40 | tmutil: addexclusion requires Full Disk Access privileges. 41 | To allow this operation, select Full Disk Access in the Privacy 42 | tab of the Security & Privacy preference pane, and add Terminal 43 | to the list of applications which are allowed Full Disk Access. 44 | ``` 45 | 46 | So we do these subdirectories instead. 47 | */ 48 | let mut set_tmutil_exclusions = Vec::new(); 49 | for path in paths { 50 | let set_tmutil_exclusion = SetTmutilExclusion::plan(path).await.map_err(Self::error)?; 51 | set_tmutil_exclusions.push(set_tmutil_exclusion); 52 | } 53 | 54 | Ok(Self { 55 | set_tmutil_exclusions, 56 | } 57 | .into()) 58 | } 59 | } 60 | 61 | #[async_trait::async_trait] 62 | #[typetag::serde(name = "set_tmutil_exclusions")] 63 | impl Action for SetTmutilExclusions { 64 | fn action_tag() -> ActionTag { 65 | ActionTag("set_tmutil_exclusions") 66 | } 67 | fn tracing_synopsis(&self) -> String { 68 | String::from("Configure Time Machine exclusions") 69 | } 70 | 71 | fn tracing_span(&self) -> Span { 72 | span!(tracing::Level::DEBUG, "set_tmutil_exclusions",) 73 | } 74 | 75 | fn execute_description(&self) -> Vec { 76 | let Self { 77 | set_tmutil_exclusions, 78 | } = &self; 79 | 80 | let mut set_tmutil_exclusion_descriptions = Vec::new(); 81 | for set_tmutil_exclusion in set_tmutil_exclusions { 82 | if let Some(val) = set_tmutil_exclusion.describe_execute().first() { 83 | set_tmutil_exclusion_descriptions.push(val.description.clone()) 84 | } 85 | } 86 | vec![ActionDescription::new( 87 | self.tracing_synopsis(), 88 | set_tmutil_exclusion_descriptions, 89 | )] 90 | } 91 | 92 | #[tracing::instrument(level = "debug", skip_all)] 93 | async fn execute(&mut self) -> Result<(), ActionError> { 94 | // Just do sequential since parallelizing this will have little benefit 95 | for set_tmutil_exclusion in self.set_tmutil_exclusions.iter_mut() { 96 | set_tmutil_exclusion 97 | .try_execute() 98 | .await 99 | .map_err(Self::error)?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | 105 | fn revert_description(&self) -> Vec { 106 | vec![ActionDescription::new( 107 | "Remove time machine exclusions".to_string(), 108 | vec![], 109 | )] 110 | } 111 | 112 | #[tracing::instrument(level = "debug", skip_all)] 113 | async fn revert(&mut self) -> Result<(), ActionError> { 114 | let mut errors = vec![]; 115 | // Just do sequential since parallelizing this will have little benefit 116 | for set_tmutil_exclusion in self.set_tmutil_exclusions.iter_mut().rev() { 117 | if let Err(err) = set_tmutil_exclusion.try_revert().await { 118 | errors.push(err); 119 | } 120 | } 121 | 122 | if errors.is_empty() { 123 | Ok(()) 124 | } else if errors.len() == 1 { 125 | Err(errors 126 | .into_iter() 127 | .next() 128 | .expect("Expected 1 len Vec to have at least 1 item")) 129 | } else { 130 | Err(Self::error(ActionErrorKind::MultipleChildren(errors))) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/action/macos/bootstrap_launchctl_service.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionErrorKind, ActionTag, StatefulAction}; 7 | use crate::execute_command; 8 | 9 | use crate::action::{Action, ActionDescription}; 10 | 11 | use super::{service_is_disabled, DARWIN_LAUNCHD_DOMAIN}; 12 | 13 | /** 14 | Bootstrap and kickstart an APFS volume 15 | */ 16 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 17 | #[serde(tag = "action_name", rename = "bootstrap_launchctl_service")] 18 | pub struct BootstrapLaunchctlService { 19 | service: String, 20 | path: PathBuf, 21 | is_present: bool, 22 | is_disabled: bool, 23 | } 24 | 25 | impl BootstrapLaunchctlService { 26 | #[tracing::instrument(level = "debug", skip_all)] 27 | pub async fn plan(service: &str, path: &str) -> Result, ActionError> { 28 | let service = service.to_owned(); 29 | let path = PathBuf::from(path); 30 | 31 | let is_present = { 32 | let mut command = Command::new("launchctl"); 33 | command.process_group(0); 34 | command.arg("print"); 35 | command.arg(format!("{DARWIN_LAUNCHD_DOMAIN}/{service}")); 36 | command.stdin(std::process::Stdio::null()); 37 | command.stdout(std::process::Stdio::piped()); 38 | command.stderr(std::process::Stdio::piped()); 39 | let command_output = command 40 | .output() 41 | .await 42 | .map_err(|e| Self::error(ActionErrorKind::command(&command, e)))?; 43 | // We presume that success means it's found 44 | command_output.status.success() 45 | }; 46 | 47 | let is_disabled = service_is_disabled(DARWIN_LAUNCHD_DOMAIN, &service) 48 | .await 49 | .map_err(Self::error)?; 50 | 51 | Ok(StatefulAction::uncompleted(Self { 52 | service, 53 | path, 54 | is_present, 55 | is_disabled, 56 | })) 57 | } 58 | } 59 | 60 | #[async_trait::async_trait] 61 | #[typetag::serde(name = "bootstrap_launchctl_service")] 62 | impl Action for BootstrapLaunchctlService { 63 | fn action_tag() -> ActionTag { 64 | ActionTag("bootstrap_launchctl_service") 65 | } 66 | fn tracing_synopsis(&self) -> String { 67 | format!( 68 | "Bootstrap the `{}` service via `launchctl bootstrap {} {}`", 69 | self.service, 70 | DARWIN_LAUNCHD_DOMAIN, 71 | self.path.display() 72 | ) 73 | } 74 | 75 | fn tracing_span(&self) -> Span { 76 | span!( 77 | tracing::Level::DEBUG, 78 | "bootstrap_launchctl_service", 79 | path = %self.path.display(), 80 | is_disabled = self.is_disabled, 81 | is_present = self.is_present, 82 | ) 83 | } 84 | 85 | fn execute_description(&self) -> Vec { 86 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 87 | } 88 | 89 | #[tracing::instrument(level = "debug", skip_all)] 90 | async fn execute(&mut self) -> Result<(), ActionError> { 91 | let Self { 92 | service, 93 | path, 94 | is_present, 95 | is_disabled, 96 | } = self; 97 | 98 | if *is_disabled { 99 | execute_command( 100 | Command::new("launchctl") 101 | .process_group(0) 102 | .arg("enable") 103 | .arg(format!("{DARWIN_LAUNCHD_DOMAIN}/{service}")) 104 | .stdin(std::process::Stdio::null()), 105 | ) 106 | .await 107 | .map_err(Self::error)?; 108 | } 109 | 110 | if *is_present { 111 | crate::action::macos::retry_bootout(DARWIN_LAUNCHD_DOMAIN, service) 112 | .await 113 | .map_err(Self::error)?; 114 | } 115 | 116 | crate::action::macos::retry_bootstrap(DARWIN_LAUNCHD_DOMAIN, service, path) 117 | .await 118 | .map_err(Self::error)?; 119 | 120 | Ok(()) 121 | } 122 | 123 | fn revert_description(&self) -> Vec { 124 | vec![ActionDescription::new( 125 | format!( 126 | "Run `launchctl bootout {} {}`", 127 | DARWIN_LAUNCHD_DOMAIN, 128 | self.path.display() 129 | ), 130 | vec![], 131 | )] 132 | } 133 | 134 | #[tracing::instrument(level = "debug", skip_all)] 135 | async fn revert(&mut self) -> Result<(), ActionError> { 136 | crate::action::macos::retry_bootout(DARWIN_LAUNCHD_DOMAIN, &self.service) 137 | .await 138 | .map_err(Self::error)?; 139 | 140 | crate::action::macos::remove_socket_path(Path::new("/var/run/nix-daemon.socket")).await; 141 | 142 | Ok(()) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, path::PathBuf}; 2 | 3 | use semver::Version; 4 | 5 | use crate::{ 6 | action::ActionError, planner::PlannerError, self_test::SelfTestError, 7 | settings::InstallSettingsError, 8 | }; 9 | 10 | /// An error occurring during a call defined in this crate 11 | #[non_exhaustive] 12 | #[derive(thiserror::Error, Debug, strum::IntoStaticStr)] 13 | pub enum NixInstallerError { 14 | /// An error originating from an [`Action`](crate::action::Action) 15 | #[error("Error executing action")] 16 | Action(#[source] ActionError), 17 | /// An error originating from a [`self_test`](crate::self_test) 18 | #[error("Self test error, install may be only partially functional\n{}", .0.iter().map(|err| { 19 | if let Some(source) = err.source() { 20 | format!("{err}\n{source}\n") 21 | } else { 22 | format!("{err}\n") 23 | } 24 | }).collect::>().join("\n"))] 25 | SelfTest(Vec), 26 | /// An error originating from an [`Action`](crate::action::Action) while reverting 27 | #[error("Error reverting\n{}", .0.iter().map(|err| { 28 | if let Some(source) = err.source() { 29 | format!("{err}\n{source}\n") 30 | } else { 31 | format!("{err}\n") 32 | } 33 | }).collect::>().join("\n"))] 34 | ActionRevert(Vec), 35 | /// An error while writing the [`InstallPlan`](crate::InstallPlan) 36 | #[error("Recording install receipt")] 37 | RecordingReceipt(PathBuf, #[source] std::io::Error), 38 | /// An error while writing copying the binary into the `/nix` folder 39 | #[error("Copying `nix-installer` binary into `/nix`")] 40 | CopyingSelf( 41 | #[source] 42 | #[from] 43 | std::io::Error, 44 | ), 45 | /// An error while serializing the [`InstallPlan`](crate::InstallPlan) 46 | #[error("Serializing receipt")] 47 | SerializingReceipt( 48 | #[from] 49 | #[source] 50 | serde_json::Error, 51 | ), 52 | /// An error occurring when a signal is issued along [`InstallPlan::install`](crate::InstallPlan::install)'s `cancel_channel` argument 53 | #[error("Cancelled by user")] 54 | Cancelled, 55 | /// Semver error 56 | #[error("Semantic Versioning error")] 57 | SemVer( 58 | #[from] 59 | #[source] 60 | semver::Error, 61 | ), 62 | /// Planner error 63 | #[error("Planner error")] 64 | Planner( 65 | #[from] 66 | #[source] 67 | PlannerError, 68 | ), 69 | /// Install setting error 70 | #[error("Install setting error")] 71 | InstallSettings( 72 | #[from] 73 | #[source] 74 | InstallSettingsError, 75 | ), 76 | 77 | /// Could not parse the value as a version requirement in order to ensure it's compatible 78 | #[error("Could not parse `{0}` as a version requirement in order to ensure it's compatible")] 79 | InvalidVersionRequirement(String, semver::Error), 80 | /// Could not parse `nix-installer`'s version as a valid version according to Semantic Versioning, therefore the plan version compatibility cannot be checked 81 | #[error("Could not parse `nix-installer`'s version `{0}` as a valid version according to Semantic Versioning, therefore the plan version compatibility cannot be checked")] 82 | InvalidCurrentVersion(String, semver::Error), 83 | /// This version of `nix-installer` is not compatible with this plan's version 84 | #[error("`nix-installer` version `{}` is not compatible with this plan's version `{}`", .binary, .plan)] 85 | IncompatibleVersion { binary: Version, plan: Version }, 86 | } 87 | 88 | pub(crate) trait HasExpectedErrors: std::error::Error + Sized + Send + Sync { 89 | fn expected<'a>(&'a self) -> Option>; 90 | } 91 | 92 | impl HasExpectedErrors for NixInstallerError { 93 | fn expected<'a>(&'a self) -> Option> { 94 | match self { 95 | NixInstallerError::Action(action_error) => action_error.kind().expected(), 96 | NixInstallerError::ActionRevert(_) => None, 97 | this @ NixInstallerError::SelfTest(_) => Some(Box::new(this)), 98 | NixInstallerError::RecordingReceipt(_, _) => None, 99 | NixInstallerError::CopyingSelf(_) => None, 100 | NixInstallerError::SerializingReceipt(_) => None, 101 | NixInstallerError::Cancelled => None, 102 | NixInstallerError::SemVer(_) => None, 103 | NixInstallerError::Planner(planner_error) => planner_error.expected(), 104 | NixInstallerError::InstallSettings(_) => None, 105 | this @ NixInstallerError::InvalidVersionRequirement(_, _) => Some(Box::new(this)), 106 | this @ NixInstallerError::InvalidCurrentVersion(_, _) => Some(Box::new(this)), 107 | this @ NixInstallerError::IncompatibleVersion { binary: _, plan: _ } => { 108 | Some(Box::new(this)) 109 | }, 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // .filter_map() predicates returns Some/None, which is more clear than .filter()'s -> bool predicates. 2 | #![allow(clippy::unnecessary_filter_map)] 3 | 4 | /*! The Determinate [Nix](https://github.com/NixOS/nix) Installer 5 | 6 | `nix-installer` breaks down into three main concepts: 7 | 8 | * [`Action`]: An executable or revertable step, possibly orchestrating sub-[`Action`]s using things 9 | like [`JoinSet`](tokio::task::JoinSet)s. 10 | * [`InstallPlan`]: A set of [`Action`]s, along with some metadata, which can be carried out to 11 | drive an install or revert. 12 | * [`Planner`](planner::Planner): Something which can be used to plan out an [`InstallPlan`]. 13 | 14 | It is possible to create custom [`Action`]s and [`Planner`](planner::Planner)s to suit the needs of your project, team, or organization. 15 | 16 | In the simplest case, `nix-installer` can be asked to determine a default plan for the platform and install 17 | it, uninstalling if anything goes wrong: 18 | 19 | ```rust,no_run 20 | use std::error::Error; 21 | use nix_installer::InstallPlan; 22 | # async fn default_install() -> color_eyre::Result<()> { 23 | let mut plan = InstallPlan::default().await?; 24 | match plan.install(None).await { 25 | Ok(()) => tracing::info!("Done"), 26 | Err(e) => { 27 | match e.source() { 28 | Some(source) => tracing::error!("{e}: {}", source), 29 | None => tracing::error!("{e}"), 30 | }; 31 | plan.uninstall(None).await?; 32 | }, 33 | }; 34 | # 35 | # Ok(()) 36 | # } 37 | ``` 38 | Sometimes choosing a specific planner is desired: 39 | ```rust,no_run 40 | use std::error::Error; 41 | use nix_installer::{InstallPlan, planner::Planner}; 42 | 43 | # async fn chosen_planner_install() -> color_eyre::Result<()> { 44 | #[cfg(target_os = "linux")] 45 | let planner = nix_installer::planner::steam_deck::SteamDeck::default().await?; 46 | #[cfg(target_os = "macos")] 47 | let planner = nix_installer::planner::macos::Macos::default().await?; 48 | 49 | // Or call `crate::planner::BuiltinPlanner::default()` 50 | // Match on the result to customize. 51 | 52 | // Customize any settings... 53 | 54 | let mut plan = InstallPlan::plan(planner).await?; 55 | match plan.install(None).await { 56 | Ok(()) => tracing::info!("Done"), 57 | Err(e) => { 58 | match e.source() { 59 | Some(source) => tracing::error!("{e}: {}", source), 60 | None => tracing::error!("{e}"), 61 | }; 62 | plan.uninstall(None).await?; 63 | }, 64 | }; 65 | # 66 | # Ok(()) 67 | # } 68 | ``` 69 | 70 | */ 71 | 72 | pub mod action; 73 | #[cfg(feature = "cli")] 74 | pub mod cli; 75 | mod error; 76 | mod os; 77 | mod plan; 78 | pub mod planner; 79 | mod profile; 80 | pub mod self_test; 81 | pub mod settings; 82 | mod util; 83 | 84 | use std::{ffi::OsStr, path::Path, process::Output}; 85 | 86 | pub use error::NixInstallerError; 87 | pub use plan::InstallPlan; 88 | use planner::BuiltinPlanner; 89 | 90 | use reqwest::Certificate; 91 | use tokio::process::Command; 92 | 93 | use crate::action::{Action, ActionErrorKind}; 94 | 95 | #[tracing::instrument(level = "debug", skip_all, fields(command = %format!("{:?}", command.as_std())))] 96 | async fn execute_command(command: &mut Command) -> Result { 97 | tracing::trace!("Executing"); 98 | let output = command 99 | .output() 100 | .await 101 | .map_err(|e| ActionErrorKind::command(command, e))?; 102 | match output.status.success() { 103 | true => { 104 | tracing::trace!( 105 | stderr = %String::from_utf8_lossy(&output.stderr), 106 | stdout = %String::from_utf8_lossy(&output.stdout), 107 | "Command success" 108 | ); 109 | Ok(output) 110 | }, 111 | false => Err(ActionErrorKind::command_output(command, output)), 112 | } 113 | } 114 | 115 | #[tracing::instrument(level = "debug", skip_all, fields( 116 | k = %k.as_ref().to_string_lossy(), 117 | v = %v.as_ref().to_string_lossy(), 118 | ))] 119 | fn set_env(k: impl AsRef, v: impl AsRef) { 120 | tracing::trace!("Setting env"); 121 | std::env::set_var(k.as_ref(), v.as_ref()); 122 | } 123 | 124 | async fn parse_ssl_cert(ssl_cert_file: &Path) -> Result { 125 | let cert_buf = tokio::fs::read(ssl_cert_file) 126 | .await 127 | .map_err(|e| CertificateError::Read(ssl_cert_file.to_path_buf(), e))?; 128 | // We actually try them since things could be `.crt` and `pem` format or `der` format 129 | let cert = if let Ok(cert) = Certificate::from_pem(cert_buf.as_slice()) { 130 | cert 131 | } else if let Ok(cert) = Certificate::from_der(cert_buf.as_slice()) { 132 | cert 133 | } else { 134 | return Err(CertificateError::UnknownCertFormat); 135 | }; 136 | Ok(cert) 137 | } 138 | 139 | #[derive(Debug, thiserror::Error)] 140 | pub enum CertificateError { 141 | #[error(transparent)] 142 | Reqwest(reqwest::Error), 143 | #[error("Read path `{0}`")] 144 | Read(std::path::PathBuf, #[source] std::io::Error), 145 | #[error("Unknown certificate format, `der` and `pem` supported")] 146 | UnknownCertFormat, 147 | } 148 | -------------------------------------------------------------------------------- /src/action/macos/kickstart_launchctl_service.rs: -------------------------------------------------------------------------------- 1 | use std::process::Output; 2 | 3 | use tokio::process::Command; 4 | use tracing::{span, Span}; 5 | 6 | use crate::action::{ActionError, ActionErrorKind, ActionTag, StatefulAction}; 7 | 8 | use crate::action::{Action, ActionDescription}; 9 | use crate::execute_command; 10 | 11 | /** 12 | Bootstrap and kickstart an APFS volume 13 | */ 14 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 15 | #[serde(tag = "action_name", rename = "kickstart_launchctl_service")] 16 | pub struct KickstartLaunchctlService { 17 | domain: String, 18 | service: String, 19 | } 20 | 21 | impl KickstartLaunchctlService { 22 | #[tracing::instrument(level = "debug")] 23 | pub async fn plan(domain: &str, service: &str) -> Result, ActionError> { 24 | let domain = domain.to_string(); 25 | let service = service.to_string(); 26 | 27 | let mut service_exists = false; 28 | let mut service_started = false; 29 | let output = execute_command( 30 | Command::new("launchctl") 31 | .process_group(0) 32 | .arg("print") 33 | .arg(format!("{domain}/{service}")) 34 | .stdin(std::process::Stdio::null()) 35 | .stdout(std::process::Stdio::piped()) 36 | .stderr(std::process::Stdio::piped()), 37 | ) 38 | .await 39 | .ok(); 40 | 41 | if let Some(output) = output { 42 | service_exists = true; 43 | 44 | let output_string = String::from_utf8(output.stdout).map_err(Self::error)?; 45 | // We are looking for a line containing "state = " with some trailing content 46 | // The output is not a JSON or a plist 47 | // MacOS's man pages explicitly tell us not to try to parse this output 48 | // MacOS's man pages explicitly tell us this output is not stable 49 | // Yet, here we are, doing exactly that. 50 | for output_line in output_string.lines() { 51 | let output_line_trimmed = output_line.trim(); 52 | if output_line_trimmed.starts_with("state") { 53 | if !output_line_trimmed.contains("not running") { 54 | service_started = true; 55 | } 56 | break; 57 | } 58 | } 59 | } 60 | 61 | if service_exists && service_started { 62 | return Ok(StatefulAction::completed(Self { domain, service })); 63 | } 64 | 65 | // It's safe to assume the user does not have the service started 66 | Ok(StatefulAction::uncompleted(Self { domain, service })) 67 | } 68 | } 69 | 70 | #[async_trait::async_trait] 71 | #[typetag::serde(name = "kickstart_launchctl_service")] 72 | impl Action for KickstartLaunchctlService { 73 | fn action_tag() -> ActionTag { 74 | ActionTag("kickstart_launchctl_service") 75 | } 76 | fn tracing_synopsis(&self) -> String { 77 | format!( 78 | "Run `launchctl kickstart -k {}/{}`", 79 | self.domain, self.service 80 | ) 81 | } 82 | 83 | fn tracing_span(&self) -> Span { 84 | span!( 85 | tracing::Level::DEBUG, 86 | "kickstart_launchctl_service", 87 | path = %self.service, 88 | ) 89 | } 90 | 91 | fn execute_description(&self) -> Vec { 92 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 93 | } 94 | 95 | #[tracing::instrument(level = "debug", skip_all)] 96 | async fn execute(&mut self) -> Result<(), ActionError> { 97 | super::retry_kickstart(&self.domain, &self.service) 98 | .await 99 | .map_err(Self::error)?; 100 | 101 | Ok(()) 102 | } 103 | 104 | fn revert_description(&self) -> Vec { 105 | vec![ActionDescription::new( 106 | format!("Run `launchctl stop {}`", self.service), 107 | vec![], 108 | )] 109 | } 110 | 111 | #[tracing::instrument(level = "debug", skip_all)] 112 | async fn revert(&mut self) -> Result<(), ActionError> { 113 | // MacOs doesn't offer an "ensure-stopped" like they do with Kickstart 114 | let mut command = Command::new("launchctl"); 115 | command.process_group(0); 116 | command.arg("stop"); 117 | command.arg(format!("{}/{}", self.domain, self.service)); 118 | command.stdin(std::process::Stdio::null()); 119 | let command_str = format!("{:?}", command.as_std()); 120 | 121 | let output = command 122 | .output() 123 | .await 124 | .map_err(|e| Self::error(ActionErrorKind::command(&command, e)))?; 125 | 126 | // On our test Macs, a status code of `3` was reported if the service was stopped while not running. 127 | match output.status.code() { 128 | Some(3) | Some(0) | None => (), 129 | _ => { 130 | return Err(Self::error(ActionErrorKind::Custom(Box::new( 131 | KickstartLaunchctlServiceError::CannotStopService(command_str, output), 132 | )))) 133 | }, 134 | } 135 | 136 | Ok(()) 137 | } 138 | } 139 | 140 | #[derive(Debug, thiserror::Error)] 141 | pub enum KickstartLaunchctlServiceError { 142 | #[error("Command `{0}` failed, stderr: {}", String::from_utf8(.1.stderr.clone()).unwrap_or_else(|_e| String::from("")))] 143 | CannotStopService(String, Output), 144 | } 145 | -------------------------------------------------------------------------------- /src/action/linux/start_systemd_unit.rs: -------------------------------------------------------------------------------- 1 | use tokio::process::Command; 2 | use tracing::{span, Span}; 3 | 4 | use crate::action::{ActionError, ActionErrorKind, ActionState, ActionTag, StatefulAction}; 5 | use crate::execute_command; 6 | 7 | use crate::action::{Action, ActionDescription}; 8 | 9 | /** 10 | Start a given systemd unit 11 | */ 12 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 13 | #[serde(tag = "action_name", rename = "start_systemd_unit")] 14 | pub struct StartSystemdUnit { 15 | unit: String, 16 | enable: bool, 17 | } 18 | 19 | impl StartSystemdUnit { 20 | #[tracing::instrument(level = "debug", skip_all)] 21 | pub async fn plan( 22 | unit: impl AsRef, 23 | enable: bool, 24 | ) -> Result, ActionError> { 25 | let unit = unit.as_ref(); 26 | let mut command = Command::new("systemctl"); 27 | command.arg("is-active"); 28 | command.arg(unit); 29 | let output = command 30 | .output() 31 | .await 32 | .map_err(|e| Self::error(ActionErrorKind::command(&command, e)))?; 33 | 34 | let state = if output.status.success() { 35 | tracing::debug!("Starting systemd unit `{}` already complete", unit); 36 | ActionState::Skipped 37 | } else { 38 | ActionState::Uncompleted 39 | }; 40 | 41 | Ok(StatefulAction { 42 | action: Self { 43 | unit: unit.to_string(), 44 | enable, 45 | }, 46 | state, 47 | }) 48 | } 49 | } 50 | 51 | #[async_trait::async_trait] 52 | #[typetag::serde(name = "start_systemd_unit")] 53 | impl Action for StartSystemdUnit { 54 | fn action_tag() -> ActionTag { 55 | ActionTag("start_systemd_unit") 56 | } 57 | fn tracing_synopsis(&self) -> String { 58 | format!("Enable (and start) the systemd unit `{}`", self.unit) 59 | } 60 | 61 | fn tracing_span(&self) -> Span { 62 | span!( 63 | tracing::Level::DEBUG, 64 | "start_systemd_unit", 65 | unit = %self.unit, 66 | ) 67 | } 68 | 69 | fn execute_description(&self) -> Vec { 70 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 71 | } 72 | 73 | #[tracing::instrument(level = "debug", skip_all)] 74 | async fn execute(&mut self) -> Result<(), ActionError> { 75 | let Self { unit, enable } = self; 76 | 77 | match enable { 78 | true => { 79 | // TODO(@Hoverbear): Handle proxy vars 80 | execute_command( 81 | Command::new("systemctl") 82 | .process_group(0) 83 | .arg("enable") 84 | .arg("--now") 85 | .arg(unit) 86 | .stdin(std::process::Stdio::null()), 87 | ) 88 | .await 89 | .map_err(Self::error)?; 90 | }, 91 | false => { 92 | // TODO(@Hoverbear): Handle proxy vars 93 | execute_command( 94 | Command::new("systemctl") 95 | .process_group(0) 96 | .arg("start") 97 | .arg(unit) 98 | .stdin(std::process::Stdio::null()), 99 | ) 100 | .await 101 | .map_err(Self::error)?; 102 | }, 103 | } 104 | 105 | Ok(()) 106 | } 107 | 108 | fn revert_description(&self) -> Vec { 109 | vec![ActionDescription::new( 110 | format!("Disable (and stop) the systemd unit `{}`", self.unit), 111 | vec![], 112 | )] 113 | } 114 | 115 | #[tracing::instrument(level = "debug", skip_all)] 116 | async fn revert(&mut self) -> Result<(), ActionError> { 117 | let mut errors = vec![]; 118 | 119 | if self.enable { 120 | if let Err(e) = execute_command( 121 | Command::new("systemctl") 122 | .process_group(0) 123 | .arg("disable") 124 | .arg(&self.unit) 125 | .stdin(std::process::Stdio::null()), 126 | ) 127 | .await 128 | .map_err(Self::error) 129 | { 130 | errors.push(e); 131 | } 132 | }; 133 | 134 | // We do both to avoid an error doing `disable --now` if the user did stop it already somehow. 135 | if let Err(e) = execute_command( 136 | Command::new("systemctl") 137 | .process_group(0) 138 | .arg("stop") 139 | .arg(&self.unit) 140 | .stdin(std::process::Stdio::null()), 141 | ) 142 | .await 143 | .map_err(Self::error) 144 | { 145 | errors.push(e); 146 | } 147 | 148 | if errors.is_empty() { 149 | Ok(()) 150 | } else if errors.len() == 1 { 151 | Err(errors 152 | .into_iter() 153 | .next() 154 | .expect("Expected 1 len Vec to have at least 1 item")) 155 | } else { 156 | Err(Self::error(ActionErrorKind::MultipleChildren(errors))) 157 | } 158 | } 159 | } 160 | 161 | #[non_exhaustive] 162 | #[derive(Debug, thiserror::Error)] 163 | pub enum StartSystemdUnitError { 164 | #[error("Failed to execute command")] 165 | Command(#[source] std::io::Error), 166 | } 167 | -------------------------------------------------------------------------------- /src/cli/arg/instrumentation.rs: -------------------------------------------------------------------------------- 1 | use eyre::WrapErr; 2 | use std::error::Error; 3 | use std::io::IsTerminal; 4 | use tracing_error::ErrorLayer; 5 | use tracing_subscriber::{ 6 | filter::Directive, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, 7 | }; 8 | 9 | #[derive(Clone, Default, Debug, clap::ValueEnum)] 10 | pub enum Logger { 11 | #[default] 12 | Compact, 13 | Full, 14 | Pretty, 15 | Json, 16 | } 17 | 18 | impl std::fmt::Display for Logger { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | let logger = match self { 21 | Logger::Compact => "compact", 22 | Logger::Full => "full", 23 | Logger::Pretty => "pretty", 24 | Logger::Json => "json", 25 | }; 26 | write!(f, "{}", logger) 27 | } 28 | } 29 | 30 | #[derive(clap::Args, Debug, Default)] 31 | pub struct Instrumentation { 32 | /// Enable debug logs, -vv for trace 33 | #[clap(short = 'v', env = "NIX_INSTALLER_VERBOSITY", long, action = clap::ArgAction::Count, global = true)] 34 | pub verbose: u8, 35 | /// Which logger to use (options are `compact`, `full`, `pretty`, and `json`) 36 | #[clap(long, env = "NIX_INSTALLER_LOGGER", default_value_t = Default::default(), global = true)] 37 | pub logger: Logger, 38 | /// Tracing directives delimited by comma 39 | /// 40 | /// See https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives 41 | #[clap(long = "log-directive", global = true, env = "NIX_INSTALLER_LOG_DIRECTIVES", value_delimiter = ',', num_args = 0..)] 42 | pub log_directives: Vec, 43 | } 44 | 45 | impl Instrumentation { 46 | pub fn log_level(&self) -> String { 47 | match self.verbose { 48 | 0 => "info", 49 | 1 => "debug", 50 | _ => "trace", 51 | } 52 | .to_string() 53 | } 54 | 55 | pub fn setup(&self) -> eyre::Result<()> { 56 | let filter_layer = self.filter_layer()?; 57 | 58 | let registry = tracing_subscriber::registry() 59 | .with(filter_layer) 60 | .with(ErrorLayer::default()); 61 | 62 | match self.logger { 63 | Logger::Compact => { 64 | let fmt_layer = self.fmt_layer_compact(); 65 | registry.with(fmt_layer).try_init()? 66 | }, 67 | Logger::Full => { 68 | let fmt_layer = self.fmt_layer_full(); 69 | registry.with(fmt_layer).try_init()? 70 | }, 71 | Logger::Pretty => { 72 | let fmt_layer = self.fmt_layer_pretty(); 73 | registry.with(fmt_layer).try_init()? 74 | }, 75 | Logger::Json => { 76 | let fmt_layer = self.fmt_layer_json(); 77 | registry.with(fmt_layer).try_init()? 78 | }, 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | pub fn fmt_layer_full(&self) -> impl tracing_subscriber::layer::Layer 85 | where 86 | S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, 87 | { 88 | tracing_subscriber::fmt::Layer::new() 89 | .with_ansi(std::io::stderr().is_terminal()) 90 | .with_writer(std::io::stderr) 91 | } 92 | 93 | pub fn fmt_layer_pretty(&self) -> impl tracing_subscriber::layer::Layer 94 | where 95 | S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, 96 | { 97 | tracing_subscriber::fmt::Layer::new() 98 | .with_ansi(std::io::stderr().is_terminal()) 99 | .with_writer(std::io::stderr) 100 | .pretty() 101 | } 102 | 103 | pub fn fmt_layer_json(&self) -> impl tracing_subscriber::layer::Layer 104 | where 105 | S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, 106 | { 107 | tracing_subscriber::fmt::Layer::new() 108 | .with_ansi(std::io::stderr().is_terminal()) 109 | .with_writer(std::io::stderr) 110 | .json() 111 | } 112 | 113 | pub fn fmt_layer_compact(&self) -> impl tracing_subscriber::layer::Layer 114 | where 115 | S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, 116 | { 117 | tracing_subscriber::fmt::Layer::new() 118 | .with_ansi(std::io::stderr().is_terminal()) 119 | .with_writer(std::io::stderr) 120 | .compact() 121 | .without_time() 122 | .with_target(false) 123 | .with_thread_ids(false) 124 | .with_thread_names(false) 125 | .with_file(false) 126 | .with_line_number(false) 127 | } 128 | 129 | pub fn filter_layer(&self) -> eyre::Result { 130 | let mut filter_layer = match EnvFilter::try_from_default_env() { 131 | Ok(layer) => layer, 132 | Err(e) => { 133 | // Catch a parse error and report it, ignore a missing env. 134 | if let Some(source) = e.source() { 135 | match source.downcast_ref::() { 136 | Some(std::env::VarError::NotPresent) => (), 137 | _ => return Err(e).wrap_err_with(|| "parsing RUST_LOG directives"), 138 | } 139 | } 140 | EnvFilter::try_new(format!( 141 | "{}={}", 142 | env!("CARGO_PKG_NAME").replace('-', "_"), 143 | self.log_level() 144 | ))? 145 | }, 146 | }; 147 | 148 | for directive in &self.log_directives { 149 | let directive_clone = directive.clone(); 150 | filter_layer = filter_layer.add_directive(directive_clone); 151 | } 152 | 153 | Ok(filter_layer) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/planner/macos/profiles.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::execute_command; 4 | 5 | #[derive(thiserror::Error, Debug)] 6 | pub enum LoadError { 7 | #[error("Profile plist parsing error: {0}")] 8 | Parse(#[from] plist::Error), 9 | 10 | #[error("Profile discovery error: {0}")] 11 | ProfileListing(#[from] crate::ActionErrorKind), 12 | } 13 | 14 | pub async fn load() -> Result { 15 | let buf = execute_command( 16 | tokio::process::Command::new("/usr/bin/profiles") 17 | // "prints all configuration profiles to console" 18 | .arg("-P") 19 | // "path to output XML plist file (for -P, -L, -C). Use 'stdout' to send information to the console." 20 | // NOTE(grahamc): `stdout` doesn't output XML formatting, but `stdout-xml` does 21 | .args(["-o", "stdout-xml"]) 22 | .stdin(std::process::Stdio::null()), 23 | ) 24 | .await? 25 | .stdout; 26 | 27 | Ok(plist::from_reader(std::io::Cursor::new(buf))?) 28 | } 29 | 30 | pub type Policies = HashMap>; 31 | 32 | #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] 33 | pub enum Target { 34 | #[serde(rename(deserialize = "_computerlevel"))] 35 | Computer, 36 | #[serde(untagged)] 37 | User(String), 38 | } 39 | 40 | #[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)] 41 | #[serde(rename_all = "PascalCase")] 42 | pub struct Profile { 43 | pub profile_description: Option, 44 | pub profile_display_name: Option, 45 | pub profile_identifier: Option, 46 | pub profile_install_date: Option, 47 | #[serde(rename = "ProfileUUID")] 48 | pub profile_uuid: Option, 49 | pub profile_version: Option, 50 | 51 | #[serde(default)] 52 | pub profile_items: Vec, 53 | } 54 | 55 | #[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)] 56 | #[serde(tag = "PayloadType", content = "PayloadContent")] 57 | pub enum ProfileItem { 58 | #[serde(rename = "com.apple.systemuiserver")] 59 | SystemUIServer(SystemUIServer), 60 | 61 | #[serde(untagged)] 62 | Unknown(UnknownProfileItem), 63 | } 64 | 65 | #[derive(serde::Deserialize, Clone, Debug, PartialEq)] 66 | #[serde(rename_all = "PascalCase")] 67 | pub struct UnknownProfileItem { 68 | payload_type: Option, 69 | payload_content: Option, 70 | } 71 | 72 | impl std::cmp::Eq for UnknownProfileItem {} 73 | 74 | #[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)] 75 | #[serde(rename_all = "kebab-case")] 76 | pub struct SystemUIServer { 77 | pub mount_controls: Option, 78 | } 79 | 80 | #[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)] 81 | #[serde(rename_all = "kebab-case")] 82 | pub struct MountControls { 83 | #[serde(default)] 84 | pub harddisk_internal: Vec, 85 | } 86 | 87 | #[derive(serde::Deserialize, Clone, Debug, PartialEq, Eq)] 88 | #[serde(rename_all = "kebab-case")] 89 | pub enum HardDiskInternalOpts { 90 | Authenticate, 91 | ReadOnly, 92 | Deny, 93 | Eject, 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::*; 99 | 100 | #[test] 101 | fn try_parse_blocking_policy() { 102 | let parsed: Policies = plist::from_reader(std::io::Cursor::new(include_str!( 103 | "./profile.sample.block.plist" 104 | ))) 105 | .unwrap(); 106 | assert_eq!( 107 | Policies::from([( 108 | Target::User("foo".into()), 109 | vec![Profile { 110 | profile_description: Some("The description".into()), 111 | profile_display_name: Some("Don't allow mounting internal devices".into()), 112 | profile_identifier: Some( 113 | "MyProfile.6F6670A3-65AC-4EA4-8665-91F8FCE289AB".into() 114 | ), 115 | profile_install_date: Some("2024-04-22 14:12:42 +0000".into()), 116 | profile_uuid: Some("6F6670A3-65AC-4EA4-8665-91F8FCE289AB".into()), 117 | profile_version: Some(1), 118 | profile_items: vec![ProfileItem::SystemUIServer(SystemUIServer { 119 | mount_controls: Some(MountControls { 120 | harddisk_internal: vec![HardDiskInternalOpts::Deny], 121 | }) 122 | })], 123 | }] 124 | )]), 125 | parsed 126 | ); 127 | } 128 | 129 | #[test] 130 | fn try_parse_unknown() { 131 | let parsed: Policies = plist::from_reader(std::io::Cursor::new(include_str!( 132 | "./profile.sample.unknown.plist" 133 | ))) 134 | .unwrap(); 135 | 136 | assert_eq!( 137 | Policies::from([( 138 | Target::Computer, 139 | vec![Profile { 140 | profile_description: Some("".into()), 141 | profile_display_name: Some( 142 | "macOS Software Update Policy: Mandatory Minor Upgrades".into() 143 | ), 144 | profile_identifier: Some("com.example".into()), 145 | profile_install_date: Some("2024-04-22 00:00:00 +0000".into()), 146 | profile_uuid: Some("F7972F85-2A4D-4609-A4BB-02CB0C34A3F8".into()), 147 | profile_version: Some(1), 148 | profile_items: vec![ProfileItem::Unknown(UnknownProfileItem { 149 | payload_type: Some("com.apple.SoftwareUpdate".into()), 150 | payload_content: Some(plist::Value::Dictionary({ 151 | let mut dict = plist::dictionary::Dictionary::new(); 152 | dict.insert("AllowPreReleaseInstallation".into(), false.into()); 153 | dict.insert("AutomaticCheckEnabled".into(), true.into()); 154 | dict 155 | })) 156 | })], 157 | }] 158 | )]), 159 | parsed 160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1734808813, 6 | "narHash": "sha256-3aH/0Y6ajIlfy7j52FGZ+s4icVX0oHhqBzRdlOeztqg=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "72e2d02dbac80c8c86bf6bf3e785536acf8ee926", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "ref": "v0.20.0", 15 | "repo": "crane", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "locked": { 21 | "lastModified": 1696267196, 22 | "narHash": "sha256-AAQ/2sD+0D18bb8hKuEEVpHUYD1GmO2Uh/taFamn6XQ=", 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "4f910c9827911b1ec2bf26b5a062cd09f8d89f85", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "ref": "v1.0.0", 31 | "repo": "flake-compat", 32 | "type": "github" 33 | } 34 | }, 35 | "flake-compat_2": { 36 | "flake": false, 37 | "locked": { 38 | "lastModified": 1733328505, 39 | "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", 40 | "owner": "edolstra", 41 | "repo": "flake-compat", 42 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "edolstra", 47 | "repo": "flake-compat", 48 | "type": "github" 49 | } 50 | }, 51 | "flake-parts": { 52 | "inputs": { 53 | "nixpkgs-lib": [ 54 | "nix", 55 | "nixpkgs" 56 | ] 57 | }, 58 | "locked": { 59 | "lastModified": 1733312601, 60 | "narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=", 61 | "owner": "hercules-ci", 62 | "repo": "flake-parts", 63 | "rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "hercules-ci", 68 | "repo": "flake-parts", 69 | "type": "github" 70 | } 71 | }, 72 | "git-hooks-nix": { 73 | "inputs": { 74 | "flake-compat": [ 75 | "nix" 76 | ], 77 | "gitignore": [ 78 | "nix" 79 | ], 80 | "nixpkgs": [ 81 | "nix", 82 | "nixpkgs" 83 | ], 84 | "nixpkgs-stable": [ 85 | "nix", 86 | "nixpkgs" 87 | ] 88 | }, 89 | "locked": { 90 | "lastModified": 1734279981, 91 | "narHash": "sha256-NdaCraHPp8iYMWzdXAt5Nv6sA3MUzlCiGiR586TCwo0=", 92 | "owner": "cachix", 93 | "repo": "git-hooks.nix", 94 | "rev": "aa9f40c906904ebd83da78e7f328cd8aeaeae785", 95 | "type": "github" 96 | }, 97 | "original": { 98 | "owner": "cachix", 99 | "repo": "git-hooks.nix", 100 | "type": "github" 101 | } 102 | }, 103 | "nix": { 104 | "inputs": { 105 | "flake-compat": "flake-compat_2", 106 | "flake-parts": "flake-parts", 107 | "git-hooks-nix": "git-hooks-nix", 108 | "nixpkgs": "nixpkgs", 109 | "nixpkgs-23-11": "nixpkgs-23-11", 110 | "nixpkgs-regression": "nixpkgs-regression" 111 | }, 112 | "locked": { 113 | "lastModified": 1762807651, 114 | "narHash": "sha256-8QYnRyGOTm3h/Dp8I6HCmQzlO7C009Odqyp28pTWgcY=", 115 | "owner": "NixOS", 116 | "repo": "nix", 117 | "rev": "d7fc293353fce6cf4b785d23e6f7e73914f01283", 118 | "type": "github" 119 | }, 120 | "original": { 121 | "owner": "NixOS", 122 | "ref": "2.32.4", 123 | "repo": "nix", 124 | "type": "github" 125 | } 126 | }, 127 | "nixpkgs": { 128 | "locked": { 129 | "lastModified": 1761597516, 130 | "narHash": "sha256-wxX7u6D2rpkJLWkZ2E932SIvDJW8+ON/0Yy8+a5vsDU=", 131 | "owner": "NixOS", 132 | "repo": "nixpkgs", 133 | "rev": "daf6dc47aa4b44791372d6139ab7b25269184d55", 134 | "type": "github" 135 | }, 136 | "original": { 137 | "owner": "NixOS", 138 | "ref": "nixos-25.05", 139 | "repo": "nixpkgs", 140 | "type": "github" 141 | } 142 | }, 143 | "nixpkgs-23-11": { 144 | "locked": { 145 | "lastModified": 1717159533, 146 | "narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=", 147 | "owner": "NixOS", 148 | "repo": "nixpkgs", 149 | "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", 150 | "type": "github" 151 | }, 152 | "original": { 153 | "owner": "NixOS", 154 | "repo": "nixpkgs", 155 | "rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446", 156 | "type": "github" 157 | } 158 | }, 159 | "nixpkgs-regression": { 160 | "locked": { 161 | "lastModified": 1643052045, 162 | "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", 163 | "owner": "NixOS", 164 | "repo": "nixpkgs", 165 | "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", 166 | "type": "github" 167 | }, 168 | "original": { 169 | "owner": "NixOS", 170 | "repo": "nixpkgs", 171 | "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", 172 | "type": "github" 173 | } 174 | }, 175 | "nixpkgs_2": { 176 | "locked": { 177 | "lastModified": 1756178832, 178 | "narHash": "sha256-O2CIn7HjZwEGqBrwu9EU76zlmA5dbmna7jL1XUmAId8=", 179 | "owner": "NixOS", 180 | "repo": "nixpkgs", 181 | "rev": "d98ce345cdab58477ca61855540999c86577d19d", 182 | "type": "github" 183 | }, 184 | "original": { 185 | "owner": "NixOS", 186 | "repo": "nixpkgs", 187 | "rev": "d98ce345cdab58477ca61855540999c86577d19d", 188 | "type": "github" 189 | } 190 | }, 191 | "root": { 192 | "inputs": { 193 | "crane": "crane", 194 | "flake-compat": "flake-compat", 195 | "nix": "nix", 196 | "nixpkgs": "nixpkgs_2" 197 | } 198 | } 199 | }, 200 | "root": "root", 201 | "version": 7 202 | } 203 | -------------------------------------------------------------------------------- /src/action/base/setup_default_profile.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | action::{common::ConfigureNix, ActionError, ActionErrorKind, ActionTag, StatefulAction}, 5 | profile::WriteToDefaultProfile, 6 | set_env, 7 | }; 8 | 9 | use tokio::{io::AsyncWriteExt, process::Command}; 10 | use tracing::{span, Span}; 11 | 12 | use crate::action::{Action, ActionDescription}; 13 | 14 | /** 15 | Setup the default Nix profile with `nss-cacert` and `nix` itself. 16 | */ 17 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 18 | #[serde(tag = "action_name", rename = "setup_default_profile")] 19 | pub struct SetupDefaultProfile { 20 | unpacked_path: PathBuf, 21 | } 22 | 23 | impl SetupDefaultProfile { 24 | #[tracing::instrument(level = "debug", skip_all)] 25 | pub async fn plan(unpacked_path: PathBuf) -> Result, ActionError> { 26 | Ok(Self { unpacked_path }.into()) 27 | } 28 | } 29 | 30 | #[async_trait::async_trait] 31 | #[typetag::serde(name = "setup_default_profile")] 32 | impl Action for SetupDefaultProfile { 33 | fn action_tag() -> ActionTag { 34 | ActionTag("setup_default_profile") 35 | } 36 | fn tracing_synopsis(&self) -> String { 37 | "Setup the default Nix profile".to_string() 38 | } 39 | 40 | fn tracing_span(&self) -> Span { 41 | span!( 42 | tracing::Level::DEBUG, 43 | "setup_default_profile", 44 | unpacked_path = %self.unpacked_path.display(), 45 | ) 46 | } 47 | 48 | fn execute_description(&self) -> Vec { 49 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 50 | } 51 | 52 | #[tracing::instrument(level = "debug", skip_all)] 53 | async fn execute(&mut self) -> Result<(), ActionError> { 54 | let (nix_pkg, nss_ca_cert_pkg) = 55 | ConfigureNix::find_nix_and_ca_cert(&self.unpacked_path).await?; 56 | let found_nix_paths = glob::glob(&format!("{}/nix-*", self.unpacked_path.display())) 57 | .map_err(Self::error)? 58 | .collect::, _>>() 59 | .map_err(Self::error)?; 60 | if found_nix_paths.len() != 1 { 61 | return Err(Self::error(ActionErrorKind::MalformedBinaryTarball)); 62 | } 63 | let found_nix_path = found_nix_paths.into_iter().next().unwrap(); 64 | let reginfo_path = found_nix_path.join(".reginfo"); 65 | let reginfo = tokio::fs::read(®info_path) 66 | .await 67 | .map_err(|e| ActionErrorKind::Read(reginfo_path.to_path_buf(), e)) 68 | .map_err(Self::error)?; 69 | let mut load_db_command = Command::new(nix_pkg.join("bin/nix-store")); 70 | load_db_command.process_group(0); 71 | load_db_command.arg("--load-db"); 72 | load_db_command.stdin(std::process::Stdio::piped()); 73 | load_db_command.stdout(std::process::Stdio::piped()); 74 | load_db_command.stderr(std::process::Stdio::piped()); 75 | load_db_command.env( 76 | "HOME", 77 | dirs::home_dir().ok_or_else(|| Self::error(SetupDefaultProfileError::NoRootHome))?, 78 | ); 79 | tracing::trace!( 80 | "Executing `{:?}` with stdin from `{}`", 81 | load_db_command.as_std(), 82 | reginfo_path.display() 83 | ); 84 | let mut handle = load_db_command 85 | .spawn() 86 | .map_err(|e| ActionErrorKind::command(&load_db_command, e)) 87 | .map_err(Self::error)?; 88 | 89 | let mut stdin = handle.stdin.take().unwrap(); 90 | stdin 91 | .write_all(®info) 92 | .await 93 | .map_err(|e| ActionErrorKind::Write(PathBuf::from("/dev/stdin"), e)) 94 | .map_err(Self::error)?; 95 | stdin 96 | .flush() 97 | .await 98 | .map_err(|e| ActionErrorKind::Write(PathBuf::from("/dev/stdin"), e)) 99 | .map_err(Self::error)?; 100 | drop(stdin); 101 | tracing::trace!( 102 | "Wrote `{}` to stdin of `nix-store --load-db`", 103 | reginfo_path.display() 104 | ); 105 | 106 | let output = handle 107 | .wait_with_output() 108 | .await 109 | .map_err(|e| ActionErrorKind::command(&load_db_command, e)) 110 | .map_err(Self::error)?; 111 | if !output.status.success() { 112 | return Err(Self::error(ActionErrorKind::command_output( 113 | &load_db_command, 114 | output, 115 | ))); 116 | }; 117 | 118 | let profile = crate::profile::Profile { 119 | nix_store_path: &nix_pkg, 120 | nss_ca_cert_path: &nss_ca_cert_pkg, 121 | 122 | profile: std::path::Path::new("/nix/var/nix/profiles/default"), 123 | pkgs: &[&nix_pkg, &nss_ca_cert_pkg], 124 | }; 125 | profile 126 | .install_packages(WriteToDefaultProfile::WriteToDefault) 127 | .await 128 | .map_err(SetupDefaultProfileError::NixProfile) 129 | .map_err(Self::error)?; 130 | 131 | set_env( 132 | "NIX_SSL_CERT_FILE", 133 | "/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt", 134 | ); 135 | 136 | Ok(()) 137 | } 138 | 139 | fn revert_description(&self) -> Vec { 140 | vec![ActionDescription::new( 141 | "Unset the default Nix profile".to_string(), 142 | vec![], 143 | )] 144 | } 145 | 146 | #[tracing::instrument(level = "debug", skip_all)] 147 | async fn revert(&mut self) -> Result<(), ActionError> { 148 | std::env::remove_var("NIX_SSL_CERT_FILE"); 149 | 150 | Ok(()) 151 | } 152 | } 153 | 154 | #[non_exhaustive] 155 | #[derive(Debug, thiserror::Error)] 156 | pub enum SetupDefaultProfileError { 157 | #[error("No root home found to place channel configuration in")] 158 | NoRootHome, 159 | 160 | #[error(transparent)] 161 | NixProfile(#[from] crate::profile::Error), 162 | } 163 | 164 | impl From for ActionErrorKind { 165 | fn from(val: SetupDefaultProfileError) -> Self { 166 | ActionErrorKind::Custom(Box::new(val)) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /assemble_installer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Assemble and release nix-installer binaries from Hydra builds.""" 3 | 4 | import argparse 5 | import json 6 | import shutil 7 | import subprocess 8 | import sys 9 | import tempfile 10 | import tomllib 11 | import urllib.request 12 | from string import Template 13 | from typing import Any 14 | 15 | 16 | def get_hydra_evals() -> list[dict[str, Any]]: 17 | """Fetch evaluations from Hydra jobset.""" 18 | url = "https://hydra.nixos.org/jobset/experimental-nix-installer/experimental-installer/evals" 19 | req = urllib.request.Request(url, headers={"Accept": "application/json"}) 20 | with urllib.request.urlopen(req) as response: 21 | data = json.loads(response.read().decode("utf-8")) 22 | return data["evals"] 23 | 24 | 25 | def find_eval(evals: list[dict[str, Any]], eval_id: str | None) -> dict[str, Any]: 26 | """Find the specified eval or return the latest one.""" 27 | if eval_id is not None and eval_id != "": 28 | eval_id_int = int(eval_id) 29 | return next(eval for eval in evals if eval["id"] == eval_id_int) 30 | else: 31 | # Use latest eval and verify it matches current HEAD 32 | hydra_eval = evals[0] 33 | result = subprocess.run( 34 | ["git", "rev-parse", "HEAD"], 35 | stdout=subprocess.PIPE, 36 | check=True, 37 | text=True, 38 | ) 39 | rev = result.stdout.strip() 40 | 41 | if rev not in hydra_eval["flake"]: 42 | raise RuntimeError( 43 | f"Expected flake with rev {rev} but found flake {hydra_eval['flake']}" 44 | ) 45 | 46 | return hydra_eval 47 | 48 | 49 | def get_build_info(build_id: int) -> dict[str, Any]: 50 | """Fetch build information from Hydra.""" 51 | url = f"https://hydra.nixos.org/build/{build_id}" 52 | req = urllib.request.Request(url, headers={"Accept": "application/json"}) 53 | with urllib.request.urlopen(req) as response: 54 | return json.loads(response.read().decode("utf-8")) 55 | 56 | 57 | def download_installer(installer_url: str) -> bool: 58 | """Download installer using nix-store, with retry logic.""" 59 | try: 60 | subprocess.run( 61 | f"nix-store -r {installer_url}", 62 | shell=True, 63 | check=True, 64 | ) 65 | return True 66 | except subprocess.CalledProcessError: 67 | # Retry once 68 | try: 69 | subprocess.run( 70 | f"nix-store -r {installer_url}", 71 | shell=True, 72 | check=True, 73 | ) 74 | return True 75 | except subprocess.CalledProcessError: 76 | return False 77 | 78 | 79 | def get_version() -> str: 80 | """Extract version from Cargo.toml.""" 81 | with open("Cargo.toml", "rb") as f: 82 | cargo_toml = tomllib.load(f) 83 | return cargo_toml["package"]["version"] 84 | 85 | 86 | def create_release(version: str, release_files: list[str]) -> None: 87 | """Create a draft GitHub release with the given files.""" 88 | subprocess.run( 89 | [ 90 | "gh", 91 | "release", 92 | "create", 93 | "--notes", 94 | f"Release experimental nix installer v{version}", 95 | "--title", 96 | f"v{version}", 97 | "--draft", 98 | version, 99 | *release_files, 100 | ], 101 | check=True, 102 | ) 103 | 104 | 105 | def main() -> None: 106 | """Main entry point for the installer assembly script.""" 107 | parser = argparse.ArgumentParser( 108 | description="Assemble and release nix-installer binaries from Hydra builds" 109 | ) 110 | parser.add_argument( 111 | "eval_id", 112 | nargs="?", 113 | default=None, 114 | help="Hydra evaluation ID to use (defaults to latest matching HEAD)", 115 | ) 116 | args = parser.parse_args() 117 | 118 | # Fetch and select the evaluation 119 | evals = get_hydra_evals() 120 | hydra_eval = find_eval(evals, args.eval_id) 121 | 122 | # Process all builds in the evaluation 123 | installers: list[tuple[str, str]] = [] 124 | for build_id in hydra_eval["builds"]: 125 | build = get_build_info(build_id) 126 | installer_url = build["buildoutputs"]["out"]["path"] 127 | system = build["system"] 128 | 129 | if build["finished"] == 1: 130 | if download_installer(installer_url): 131 | installers.append((installer_url, system)) 132 | else: 133 | print( 134 | f"Build {build_id} not finished. " 135 | f"Check status at https://hydra.nixos.org/eval/{hydra_eval['id']}#tabs-unfinished" 136 | ) 137 | sys.exit(0) 138 | 139 | # Get version from Cargo.toml 140 | version = get_version() 141 | 142 | # Create release with all installer binaries 143 | with tempfile.TemporaryDirectory() as tmpdirname: 144 | release_files: list[str] = [] 145 | 146 | # Copy installer binaries 147 | for installer_url, system in installers: 148 | installer_file = f"{tmpdirname}/nix-installer-{system}" 149 | release_files.append(installer_file) 150 | print(f"Copying {installer_url} to {installer_file}") 151 | shutil.copy(f"{installer_url}/bin/nix-installer", installer_file) 152 | 153 | # Substitute version in nix-installer.sh 154 | original_file = "nix-installer.sh" 155 | with open(original_file, "r") as nix_installer_sh: 156 | nix_installer_sh_contents = nix_installer_sh.read() 157 | 158 | template = Template(nix_installer_sh_contents) 159 | updated_content = template.safe_substitute( 160 | assemble_installer_templated_version=version 161 | ) 162 | 163 | # Write the modified content to the output file 164 | substituted_file = f"{tmpdirname}/nix-installer.sh" 165 | with open(substituted_file, "w", encoding="utf-8") as output_file: 166 | output_file.write(updated_content) 167 | release_files.append(substituted_file) 168 | 169 | # Create the GitHub release 170 | create_release(version, release_files) 171 | 172 | 173 | if __name__ == "__main__": 174 | main() 175 | -------------------------------------------------------------------------------- /src/action/macos/create_fstab_entry.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use tracing::{span, Span}; 4 | use uuid::Uuid; 5 | 6 | use super::get_disk_info_for_label; 7 | use crate::action::{ 8 | Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction, 9 | }; 10 | 11 | const FSTAB_PATH: &str = "/etc/fstab"; 12 | 13 | /** Create an `/etc/fstab` entry for the given volume 14 | 15 | This action queries `diskutil info` on the volume to fetch it's UUID and 16 | add the relevant information to `/etc/fstab`. 17 | */ 18 | // Initially, a `NAME` was used, however in https://github.com/DeterminateSystems/nix-installer/issues/212 19 | // several users reported issues. Using a UUID resolved the issue for them. 20 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 21 | #[serde(tag = "action_name", rename = "create_fstab_entry")] 22 | pub struct CreateFstabEntry { 23 | apfs_volume_label: String, 24 | } 25 | 26 | impl CreateFstabEntry { 27 | #[tracing::instrument(level = "debug", skip_all)] 28 | pub async fn plan(apfs_volume_label: String) -> Result, ActionError> { 29 | Ok(StatefulAction::uncompleted(Self { apfs_volume_label })) 30 | } 31 | } 32 | 33 | #[async_trait::async_trait] 34 | #[typetag::serde(name = "create_fstab_entry")] 35 | impl Action for CreateFstabEntry { 36 | fn action_tag() -> ActionTag { 37 | ActionTag("create_fstab_entry") 38 | } 39 | fn tracing_synopsis(&self) -> String { 40 | format!( 41 | "Update `{FSTAB_PATH}` to mount the APFS volume `{}`", 42 | self.apfs_volume_label 43 | ) 44 | } 45 | 46 | fn tracing_span(&self) -> Span { 47 | let span = span!( 48 | tracing::Level::DEBUG, 49 | "create_fstab_entry", 50 | apfs_volume_label = self.apfs_volume_label, 51 | ); 52 | 53 | span 54 | } 55 | 56 | fn execute_description(&self) -> Vec { 57 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 58 | } 59 | 60 | #[tracing::instrument(level = "debug", skip_all)] 61 | async fn execute(&mut self) -> Result<(), ActionError> { 62 | let fstab_path = Path::new(FSTAB_PATH); 63 | let uuid = match get_disk_info_for_label(&self.apfs_volume_label) 64 | .await 65 | .map_err(Self::error)? 66 | { 67 | Some(diskutil_info) => diskutil_info.volume_uuid, 68 | None => { 69 | return Err(Self::error(CreateFstabEntryError::CannotDetermineUuid( 70 | self.apfs_volume_label.clone(), 71 | )))? 72 | }, 73 | }; 74 | 75 | let fstab_buf = tokio::fs::read_to_string(FSTAB_PATH) 76 | .await 77 | .or_else(|e| match e.kind() { 78 | std::io::ErrorKind::NotFound => Ok(String::new()), 79 | _ => Err(e), 80 | }) 81 | .map_err(|e| Self::error(ActionErrorKind::Read(fstab_path.to_owned(), e)))?; 82 | 83 | let mut current_fstab_lines = fstab_buf 84 | .lines() 85 | .filter(|line| { 86 | // Remove nix-installer entries with a "prelude" comment 87 | if line.starts_with("# nix-installer created volume labelled") { 88 | return false; 89 | } 90 | // Remove any existing /nix mount point entries 91 | if line.split(&[' ', '\t']).nth(1) == Some("/nix") { 92 | return false; 93 | } 94 | true 95 | }) 96 | .map(|line| line.to_owned()) 97 | .collect::>(); 98 | 99 | // Always append exactly one new /nix entry 100 | current_fstab_lines.push(fstab_entry(&uuid)); 101 | 102 | if current_fstab_lines.last().map(|s| s.as_ref()) != Some("") { 103 | // Don't leave the file without a trailing newline 104 | current_fstab_lines.push("".into()); 105 | } 106 | 107 | let updated_buf = current_fstab_lines.join("\n"); 108 | 109 | crate::util::write_atomic(fstab_path, &updated_buf) 110 | .await 111 | .map_err(Self::error)?; 112 | Ok(()) 113 | } 114 | 115 | fn revert_description(&self) -> Vec { 116 | let Self { apfs_volume_label } = &self; 117 | vec![ActionDescription::new( 118 | format!( 119 | "Remove the UUID based entry for the APFS volume `{}` in `/etc/fstab`", 120 | apfs_volume_label 121 | ), 122 | vec![], 123 | )] 124 | } 125 | 126 | #[tracing::instrument(level = "debug", skip_all)] 127 | async fn revert(&mut self) -> Result<(), ActionError> { 128 | let fstab_path = Path::new(FSTAB_PATH); 129 | 130 | let fstab_buf = tokio::fs::read_to_string(FSTAB_PATH) 131 | .await 132 | .or_else(|e| match e.kind() { 133 | std::io::ErrorKind::NotFound => Ok(String::new()), 134 | _ => Err(e), 135 | }) 136 | .map_err(|e| Self::error(ActionErrorKind::Read(fstab_path.to_owned(), e)))?; 137 | 138 | let mut current_fstab_lines = fstab_buf 139 | .lines() 140 | .filter_map(|line| { 141 | // Delete nix-installer entries with a "prelude" comment 142 | if line.starts_with("# nix-installer created volume labelled") { 143 | None 144 | } else { 145 | Some(line) 146 | } 147 | }) 148 | .filter_map(|line| { 149 | if line.split(&[' ', '\t']).nth(1) == Some("/nix") { 150 | // Delete the mount line for /nix 151 | None 152 | } else { 153 | Some(line) 154 | } 155 | }) 156 | .collect::>(); 157 | 158 | if current_fstab_lines.last() != Some(&"") { 159 | // Don't leave the file without a trailing newline 160 | current_fstab_lines.push(""); 161 | } 162 | 163 | crate::util::write_atomic(fstab_path, ¤t_fstab_lines.join("\n")) 164 | .await 165 | .map_err(Self::error)?; 166 | 167 | Ok(()) 168 | } 169 | } 170 | 171 | fn fstab_entry(uuid: &Uuid) -> String { 172 | format!("UUID={uuid} /nix apfs rw,noatime,noauto,nobrowse,nosuid,owners # Added by the Determinate Nix Installer") 173 | } 174 | 175 | #[non_exhaustive] 176 | #[derive(thiserror::Error, Debug)] 177 | pub enum CreateFstabEntryError { 178 | #[error("Unable to determine how to add APFS volume `{0}` the `/etc/fstab` line, likely the volume is not yet created or there is some synchronization issue, please report this")] 179 | CannotDetermineUuid(String), 180 | } 181 | 182 | impl From for ActionErrorKind { 183 | fn from(val: CreateFstabEntryError) -> Self { 184 | ActionErrorKind::Custom(Box::new(val)) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/profile/nixenv/tests.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix::ffi::OsStringExt; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use tokio::io::AsyncWriteExt; 5 | 6 | use super::super::WriteToDefaultProfile; 7 | use super::NixCommandExt; 8 | use super::NixEnv; 9 | 10 | async fn should_skip() -> bool { 11 | let cmdret = tokio::process::Command::new("nix") 12 | .set_nix_options(Path::new("/dev/null")) 13 | .unwrap() 14 | .arg("--version") 15 | .output() 16 | .await; 17 | 18 | if cmdret.is_ok() { 19 | return false; 20 | } else { 21 | println!("Skipping this test because nix isn't in PATH"); 22 | return true; 23 | } 24 | } 25 | 26 | async fn sample_tree(dirname: &str, filename: &str, content: &str) -> PathBuf { 27 | let temp_dir = tempfile::tempdir().unwrap(); 28 | 29 | let sub_dir = temp_dir.path().join(dirname); 30 | tokio::fs::create_dir(&sub_dir).await.unwrap(); 31 | 32 | let file = sub_dir.join(filename); 33 | 34 | let mut f = tokio::fs::File::options() 35 | .create(true) 36 | .write(true) 37 | .open(&file) 38 | .await 39 | .unwrap(); 40 | 41 | f.write_all(content.as_bytes()).await.unwrap(); 42 | 43 | let mut cmdret = tokio::process::Command::new("nix") 44 | .set_nix_options(Path::new("/dev/null")) 45 | .unwrap() 46 | .args(&["store", "add"]) 47 | .arg(&sub_dir) 48 | .output() 49 | .await 50 | .unwrap(); 51 | 52 | assert!( 53 | cmdret.status.success(), 54 | "Running nix-store add failed: {:#?}", 55 | cmdret, 56 | ); 57 | 58 | if cmdret.stdout.last() == Some(&b'\n') { 59 | cmdret.stdout.remove(cmdret.stdout.len() - 1); 60 | } 61 | 62 | let p = PathBuf::from(std::ffi::OsString::from_vec(cmdret.stdout)); 63 | 64 | assert!( 65 | p.exists(), 66 | "Adding a path to the Nix store failed...: {:#?}", 67 | cmdret.stderr 68 | ); 69 | 70 | p 71 | } 72 | 73 | #[tokio::test] 74 | async fn test_detect_intersection() { 75 | if should_skip().await { 76 | return; 77 | } 78 | 79 | let profile = tempfile::tempdir().unwrap(); 80 | let profile_path = profile.path().join("profile"); 81 | 82 | let tree_1 = sample_tree("foo", "foo", "a").await; 83 | let tree_2 = sample_tree("bar", "foo", "b").await; 84 | 85 | (NixEnv { 86 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 87 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 88 | profile: &profile_path, 89 | pkgs: &[&tree_1, &tree_2], 90 | }) 91 | .install_packages(WriteToDefaultProfile::Isolated) 92 | .await 93 | .unwrap_err(); 94 | } 95 | 96 | #[tokio::test] 97 | async fn test_no_intersection() { 98 | if should_skip().await { 99 | return; 100 | } 101 | 102 | let profile = tempfile::tempdir().unwrap(); 103 | let profile_path = profile.path().join("profile"); 104 | 105 | let tree_1 = sample_tree("foo", "foo", "a").await; 106 | let tree_2 = sample_tree("bar", "bar", "b").await; 107 | 108 | (NixEnv { 109 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 110 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 111 | profile: &profile_path, 112 | pkgs: &[&tree_1, &tree_2], 113 | }) 114 | .install_packages(WriteToDefaultProfile::Isolated) 115 | .await 116 | .unwrap(); 117 | 118 | assert_eq!( 119 | tokio::fs::read_to_string(profile_path.join("foo")) 120 | .await 121 | .unwrap(), 122 | "a" 123 | ); 124 | assert_eq!( 125 | tokio::fs::read_to_string(profile_path.join("bar")) 126 | .await 127 | .unwrap(), 128 | "b" 129 | ); 130 | 131 | let tree_3 = sample_tree("baz", "baz", "c").await; 132 | let tree_4 = sample_tree("tux", "tux", "d").await; 133 | 134 | (NixEnv { 135 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 136 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 137 | profile: &profile_path, 138 | pkgs: &[&tree_3, &tree_4], 139 | }) 140 | .install_packages(WriteToDefaultProfile::Isolated) 141 | .await 142 | .unwrap(); 143 | 144 | assert_eq!( 145 | tokio::fs::read_to_string(profile_path.join("baz")) 146 | .await 147 | .unwrap(), 148 | "c" 149 | ); 150 | assert_eq!( 151 | tokio::fs::read_to_string(profile_path.join("tux")) 152 | .await 153 | .unwrap(), 154 | "d" 155 | ); 156 | } 157 | 158 | #[tokio::test] 159 | async fn test_overlap_replaces() { 160 | if should_skip().await { 161 | return; 162 | } 163 | 164 | let profile = tempfile::tempdir().unwrap(); 165 | let profile_path = profile.path().join("profile"); 166 | 167 | let tree_base = sample_tree("fizz", "fizz", "fizz").await; 168 | let tree_1 = sample_tree("foo", "foo", "a").await; 169 | (NixEnv { 170 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 171 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 172 | profile: &profile_path, 173 | pkgs: &[&tree_base, &tree_1], 174 | }) 175 | .install_packages(WriteToDefaultProfile::Isolated) 176 | .await 177 | .unwrap(); 178 | 179 | assert_eq!( 180 | tokio::fs::read_to_string(profile_path.join("fizz")) 181 | .await 182 | .unwrap(), 183 | "fizz" 184 | ); 185 | assert_eq!( 186 | tokio::fs::read_to_string(profile_path.join("foo")) 187 | .await 188 | .unwrap(), 189 | "a" 190 | ); 191 | 192 | let tree_2 = sample_tree("foo", "foo", "b").await; 193 | (NixEnv { 194 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 195 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 196 | profile: &profile_path, 197 | pkgs: &[&tree_2], 198 | }) 199 | .install_packages(WriteToDefaultProfile::Isolated) 200 | .await 201 | .unwrap(); 202 | 203 | assert_eq!( 204 | tokio::fs::read_to_string(profile_path.join("foo")) 205 | .await 206 | .unwrap(), 207 | "b" 208 | ); 209 | 210 | let tree_3 = sample_tree("bar", "foo", "c").await; 211 | (NixEnv { 212 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 213 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 214 | profile: &profile_path, 215 | pkgs: &[&tree_3], 216 | }) 217 | .install_packages(WriteToDefaultProfile::Isolated) 218 | .await 219 | .unwrap(); 220 | 221 | assert_eq!( 222 | tokio::fs::read_to_string(profile_path.join("foo")) 223 | .await 224 | .unwrap(), 225 | "c" 226 | ); 227 | 228 | assert_eq!( 229 | tokio::fs::read_to_string(profile_path.join("fizz")) 230 | .await 231 | .unwrap(), 232 | "fizz" 233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/profile/nixprofile/tests.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix::ffi::OsStringExt; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use tokio::io::AsyncWriteExt; 5 | 6 | use super::super::WriteToDefaultProfile; 7 | use super::NixCommandExt; 8 | use super::NixProfile; 9 | 10 | async fn should_skip() -> bool { 11 | let cmdret = tokio::process::Command::new("nix") 12 | .set_nix_options(Path::new("/dev/null")) 13 | .unwrap() 14 | .arg("--version") 15 | .output() 16 | .await; 17 | 18 | if cmdret.is_ok() { 19 | return false; 20 | } else { 21 | println!("Skipping this test because nix isn't in PATH"); 22 | return true; 23 | } 24 | } 25 | 26 | async fn sample_tree(dirname: &str, filename: &str, content: &str) -> PathBuf { 27 | let temp_dir = tempfile::tempdir().unwrap(); 28 | 29 | let sub_dir = temp_dir.path().join(dirname); 30 | tokio::fs::create_dir(&sub_dir).await.unwrap(); 31 | 32 | let file = sub_dir.join(filename); 33 | 34 | let mut f = tokio::fs::File::options() 35 | .create(true) 36 | .write(true) 37 | .open(&file) 38 | .await 39 | .unwrap(); 40 | 41 | f.write_all(content.as_bytes()).await.unwrap(); 42 | 43 | let mut cmdret = tokio::process::Command::new("nix") 44 | .set_nix_options(Path::new("/dev/null")) 45 | .unwrap() 46 | .args(&["store", "add"]) 47 | .arg(&sub_dir) 48 | .output() 49 | .await 50 | .unwrap(); 51 | 52 | assert!( 53 | cmdret.status.success(), 54 | "Running nix-store add failed: {:#?}", 55 | cmdret, 56 | ); 57 | 58 | if cmdret.stdout.last() == Some(&b'\n') { 59 | cmdret.stdout.remove(cmdret.stdout.len() - 1); 60 | } 61 | 62 | let p = PathBuf::from(std::ffi::OsString::from_vec(cmdret.stdout)); 63 | 64 | assert!( 65 | p.exists(), 66 | "Adding a path to the Nix store failed...: {:#?}", 67 | cmdret.stderr 68 | ); 69 | 70 | p 71 | } 72 | 73 | #[tokio::test] 74 | async fn test_detect_intersection() { 75 | if should_skip().await { 76 | return; 77 | } 78 | 79 | let profile = tempfile::tempdir().unwrap(); 80 | let profile_path = profile.path().join("profile"); 81 | 82 | let tree_1 = sample_tree("foo", "foo", "a").await; 83 | let tree_2 = sample_tree("bar", "foo", "b").await; 84 | 85 | (NixProfile { 86 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 87 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 88 | profile: &profile_path, 89 | pkgs: &[&tree_1, &tree_2], 90 | }) 91 | .install_packages(WriteToDefaultProfile::Isolated) 92 | .await 93 | .unwrap_err(); 94 | } 95 | 96 | #[tokio::test] 97 | async fn test_no_intersection() { 98 | if should_skip().await { 99 | return; 100 | } 101 | 102 | let profile = tempfile::tempdir().unwrap(); 103 | let profile_path = profile.path().join("profile"); 104 | 105 | let tree_1 = sample_tree("foo", "foo", "a").await; 106 | let tree_2 = sample_tree("bar", "bar", "b").await; 107 | 108 | (NixProfile { 109 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 110 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 111 | profile: &profile_path, 112 | pkgs: &[&tree_1, &tree_2], 113 | }) 114 | .install_packages(WriteToDefaultProfile::Isolated) 115 | .await 116 | .unwrap(); 117 | 118 | assert_eq!( 119 | tokio::fs::read_to_string(profile_path.join("foo")) 120 | .await 121 | .unwrap(), 122 | "a" 123 | ); 124 | assert_eq!( 125 | tokio::fs::read_to_string(profile_path.join("bar")) 126 | .await 127 | .unwrap(), 128 | "b" 129 | ); 130 | 131 | let tree_3 = sample_tree("baz", "baz", "c").await; 132 | let tree_4 = sample_tree("tux", "tux", "d").await; 133 | 134 | (NixProfile { 135 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 136 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 137 | profile: &profile_path, 138 | pkgs: &[&tree_3, &tree_4], 139 | }) 140 | .install_packages(WriteToDefaultProfile::Isolated) 141 | .await 142 | .unwrap(); 143 | 144 | assert_eq!( 145 | tokio::fs::read_to_string(profile_path.join("baz")) 146 | .await 147 | .unwrap(), 148 | "c" 149 | ); 150 | assert_eq!( 151 | tokio::fs::read_to_string(profile_path.join("tux")) 152 | .await 153 | .unwrap(), 154 | "d" 155 | ); 156 | } 157 | 158 | #[tokio::test] 159 | async fn test_overlap_replaces() { 160 | if should_skip().await { 161 | return; 162 | } 163 | 164 | let profile = tempfile::tempdir().unwrap(); 165 | let profile_path = profile.path().join("profile"); 166 | 167 | let tree_base = sample_tree("fizz", "fizz", "fizz").await; 168 | let tree_1 = sample_tree("foo", "foo", "a").await; 169 | (NixProfile { 170 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 171 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 172 | profile: &profile_path, 173 | pkgs: &[&tree_base, &tree_1], 174 | }) 175 | .install_packages(WriteToDefaultProfile::Isolated) 176 | .await 177 | .unwrap(); 178 | 179 | assert_eq!( 180 | tokio::fs::read_to_string(profile_path.join("fizz")) 181 | .await 182 | .unwrap(), 183 | "fizz" 184 | ); 185 | assert_eq!( 186 | tokio::fs::read_to_string(profile_path.join("foo")) 187 | .await 188 | .unwrap(), 189 | "a" 190 | ); 191 | 192 | let tree_2 = sample_tree("foo", "foo", "b").await; 193 | (NixProfile { 194 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 195 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 196 | profile: &profile_path, 197 | pkgs: &[&tree_2], 198 | }) 199 | .install_packages(WriteToDefaultProfile::Isolated) 200 | .await 201 | .unwrap(); 202 | 203 | assert_eq!( 204 | tokio::fs::read_to_string(profile_path.join("foo")) 205 | .await 206 | .unwrap(), 207 | "b" 208 | ); 209 | 210 | let tree_3 = sample_tree("bar", "foo", "c").await; 211 | (NixProfile { 212 | nix_store_path: Path::new("/nix/var/nix/profiles/default/"), 213 | nss_ca_cert_path: Path::new("/nix/var/nix/profiles/default/"), 214 | profile: &profile_path, 215 | pkgs: &[&tree_3], 216 | }) 217 | .install_packages(WriteToDefaultProfile::Isolated) 218 | .await 219 | .unwrap(); 220 | 221 | assert_eq!( 222 | tokio::fs::read_to_string(profile_path.join("foo")) 223 | .await 224 | .unwrap(), 225 | "c" 226 | ); 227 | 228 | assert_eq!( 229 | tokio::fs::read_to_string(profile_path.join("fizz")) 230 | .await 231 | .unwrap(), 232 | "fizz" 233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/action/macos/create_apfs_volume.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::time::Duration; 3 | 4 | use tokio::process::Command; 5 | use tracing::{span, Span}; 6 | 7 | use crate::action::{ActionError, ActionErrorKind, ActionTag, StatefulAction}; 8 | use crate::execute_command; 9 | 10 | use crate::action::{Action, ActionDescription}; 11 | use crate::os::darwin::{DiskUtilApfsListOutput, DiskUtilInfoOutput}; 12 | 13 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 14 | #[serde(tag = "action_name", rename = "create_apfs_volume")] 15 | pub struct CreateApfsVolume { 16 | disk: PathBuf, 17 | name: String, 18 | case_sensitive: bool, 19 | } 20 | 21 | impl CreateApfsVolume { 22 | #[tracing::instrument(level = "debug", skip_all)] 23 | pub async fn plan( 24 | disk: impl AsRef, 25 | name: String, 26 | case_sensitive: bool, 27 | ) -> Result, ActionError> { 28 | let output = 29 | execute_command(Command::new("/usr/sbin/diskutil").args(["apfs", "list", "-plist"])) 30 | .await 31 | .map_err(Self::error)?; 32 | 33 | let parsed: DiskUtilApfsListOutput = 34 | plist::from_bytes(&output.stdout).map_err(Self::error)?; 35 | for container in parsed.containers { 36 | for volume in container.volumes { 37 | if volume.name.as_ref() == Some(&name) { 38 | return Ok(StatefulAction::completed(Self { 39 | disk: disk.as_ref().to_path_buf(), 40 | name, 41 | case_sensitive, 42 | })); 43 | } 44 | } 45 | } 46 | 47 | Ok(StatefulAction::uncompleted(Self { 48 | disk: disk.as_ref().to_path_buf(), 49 | name, 50 | case_sensitive, 51 | })) 52 | } 53 | } 54 | 55 | #[async_trait::async_trait] 56 | #[typetag::serde(name = "create_apfs_volume")] 57 | impl Action for CreateApfsVolume { 58 | fn action_tag() -> ActionTag { 59 | ActionTag("create_apfs_volume") 60 | } 61 | fn tracing_synopsis(&self) -> String { 62 | format!( 63 | "Create an APFS volume on `{}` named `{}`", 64 | self.disk.display(), 65 | self.name 66 | ) 67 | } 68 | 69 | fn tracing_span(&self) -> Span { 70 | span!( 71 | tracing::Level::DEBUG, 72 | "create_volume", 73 | disk = %self.disk.display(), 74 | name = %self.name, 75 | case_sensitive = %self.case_sensitive, 76 | ) 77 | } 78 | 79 | fn execute_description(&self) -> Vec { 80 | vec![ActionDescription::new(self.tracing_synopsis(), vec![])] 81 | } 82 | 83 | #[tracing::instrument(level = "debug", skip_all)] 84 | async fn execute(&mut self) -> Result<(), ActionError> { 85 | let Self { 86 | disk, 87 | name, 88 | case_sensitive, 89 | } = self; 90 | 91 | execute_command( 92 | Command::new("/usr/sbin/diskutil") 93 | .process_group(0) 94 | .args([ 95 | "apfs", 96 | "addVolume", 97 | &format!("{}", disk.display()), 98 | if !*case_sensitive { 99 | "APFS" 100 | } else { 101 | "Case-sensitive APFS" 102 | }, 103 | name, 104 | "-nomount", 105 | ]) 106 | .stdin(std::process::Stdio::null()), 107 | ) 108 | .await 109 | .map_err(Self::error)?; 110 | 111 | Ok(()) 112 | } 113 | 114 | fn revert_description(&self) -> Vec { 115 | vec![ActionDescription::new( 116 | format!( 117 | "Remove the volume on `{}` named `{}`", 118 | self.disk.display(), 119 | self.name 120 | ), 121 | vec![], 122 | )] 123 | } 124 | 125 | #[tracing::instrument(level = "debug", skip_all)] 126 | async fn revert(&mut self) -> Result<(), ActionError> { 127 | let currently_mounted = { 128 | let the_plist = DiskUtilInfoOutput::for_volume_name(&self.name) 129 | .await 130 | .map_err(Self::error)?; 131 | the_plist.is_mounted() 132 | }; 133 | 134 | // Unmounts the volume before attempting to remove it, avoiding 'in use' errors 135 | // https://github.com/DeterminateSystems/nix-installer/issues/647 136 | if currently_mounted { 137 | execute_command( 138 | Command::new("/usr/sbin/diskutil") 139 | .process_group(0) 140 | .args(["unmount", "force", &self.name]) 141 | .stdin(std::process::Stdio::null()), 142 | ) 143 | .await 144 | .map_err(Self::error)?; 145 | } else { 146 | tracing::debug!("Volume was already unmounted, can skip unmounting") 147 | } 148 | 149 | // NOTE(cole-h): We believe that, because we're running the unmount force -> deleteVolume 150 | // commands in an automated fashion, there's a race condition where we're running them too 151 | // close to each other, so the OS doesn't notice the volume has been unmounted / hasn't 152 | // completed its "unmount the volume" tasks by the time we try to delete it. If that is the 153 | // case (unfortunately, we have been unable to reproduce this issue on the machines we have 154 | // access to!), then trying to delete the volume 10 times -- with 500ms of time between 155 | // attempts -- should alleviate this. 156 | // https://github.com/DeterminateSystems/nix-installer/issues/1303 157 | // https://github.com/DeterminateSystems/nix-installer/issues/1267 158 | // https://github.com/DeterminateSystems/nix-installer/issues/1085 159 | let mut retry_tokens: usize = 10; 160 | loop { 161 | let mut command = Command::new("/usr/sbin/diskutil"); 162 | command.process_group(0); 163 | command.args(["apfs", "deleteVolume", &self.name]); 164 | command.stdin(std::process::Stdio::null()); 165 | tracing::debug!(%retry_tokens, command = ?command.as_std(), "Waiting for volume deletion to succeed"); 166 | 167 | let output = command 168 | .output() 169 | .await 170 | .map_err(|e| ActionErrorKind::command(&command, e)) 171 | .map_err(Self::error)?; 172 | 173 | if output.status.success() { 174 | break; 175 | } else if retry_tokens == 0 { 176 | return Err(Self::error(ActionErrorKind::command_output( 177 | &command, output, 178 | )))?; 179 | } else { 180 | retry_tokens = retry_tokens.saturating_sub(1); 181 | } 182 | 183 | tokio::time::sleep(Duration::from_millis(500)).await; 184 | } 185 | 186 | Ok(()) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/action/common/create_nix_tree.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix::fs::MetadataExt; 2 | 3 | use tracing::{span, Span}; 4 | 5 | use crate::action::base::CreateDirectory; 6 | use crate::action::{ 7 | Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction, 8 | }; 9 | 10 | const PATHS: &[&str] = &[ 11 | "/nix/var", 12 | "/nix/var/log", 13 | "/nix/var/log/nix", 14 | "/nix/var/log/nix/drvs", 15 | "/nix/var/nix", 16 | "/nix/var/nix/db", 17 | "/nix/var/nix/gcroots", 18 | "/nix/var/nix/gcroots/per-user", 19 | "/nix/var/nix/profiles", 20 | "/nix/var/nix/profiles/per-user", 21 | "/nix/var/nix/temproots", 22 | "/nix/var/nix/userpool", 23 | "/nix/var/nix/daemon-socket", 24 | ]; 25 | 26 | /** 27 | Create the `/nix` tree 28 | */ 29 | #[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] 30 | #[serde(tag = "action_name", rename = "create_nix_tree")] 31 | pub struct CreateNixTree { 32 | create_directories: Vec>, 33 | } 34 | 35 | impl CreateNixTree { 36 | #[tracing::instrument(level = "debug", skip_all)] 37 | pub async fn plan() -> Result, ActionError> { 38 | let mut create_directories = Vec::default(); 39 | for path in PATHS { 40 | // We use `create_dir` over `create_dir_all` to ensure we always set permissions right 41 | create_directories.push( 42 | CreateDirectory::plan(path, None, None, 0o0755, true) 43 | .await 44 | .map_err(Self::error)?, 45 | ) 46 | } 47 | 48 | Ok(Self { create_directories }.into()) 49 | } 50 | } 51 | 52 | #[async_trait::async_trait] 53 | #[typetag::serde(name = "create_nix_tree")] 54 | impl Action for CreateNixTree { 55 | fn action_tag() -> ActionTag { 56 | ActionTag("create_nix_tree") 57 | } 58 | fn tracing_synopsis(&self) -> String { 59 | "Create a directory tree in `/nix`".to_string() 60 | } 61 | 62 | fn tracing_span(&self) -> Span { 63 | span!(tracing::Level::DEBUG, "create_nix_tree",) 64 | } 65 | 66 | fn execute_description(&self) -> Vec { 67 | let Self { create_directories } = &self; 68 | 69 | let mut create_directory_descriptions = Vec::new(); 70 | for create_directory in create_directories { 71 | if let Some(val) = create_directory.describe_execute().first() { 72 | create_directory_descriptions.push(val.description.clone()) 73 | } 74 | } 75 | vec![ 76 | ActionDescription::new(self.tracing_synopsis(), create_directory_descriptions), 77 | ActionDescription::new( 78 | "Synchronize /nix/var ownership".to_string(), 79 | vec![format!( 80 | "Will update existing files in /nix/var to be owned by User ID 0, Group ID 0" 81 | )], 82 | ), 83 | ] 84 | } 85 | 86 | #[tracing::instrument(level = "debug", skip_all)] 87 | async fn execute(&mut self) -> Result<(), ActionError> { 88 | // Just do sequential since parallelizing this will have little benefit 89 | for create_directory in self.create_directories.iter_mut() { 90 | create_directory.try_execute().await.map_err(Self::error)?; 91 | } 92 | 93 | ensure_nix_var_ownership().await.map_err(Self::error)?; 94 | 95 | Ok(()) 96 | } 97 | 98 | fn revert_description(&self) -> Vec { 99 | vec![ActionDescription::new( 100 | "Remove the directory tree in `/nix`".to_string(), 101 | vec![ 102 | format!( 103 | "Nix and the Nix daemon require a Nix Store, which will be stored at `/nix`" 104 | ), 105 | format!( 106 | "Removes: {}", 107 | PATHS 108 | .iter() 109 | .rev() 110 | .map(|v| format!("`{v}`")) 111 | .collect::>() 112 | .join(", ") 113 | ), 114 | ], 115 | )] 116 | } 117 | 118 | #[tracing::instrument(level = "debug", skip_all)] 119 | async fn revert(&mut self) -> Result<(), ActionError> { 120 | let mut errors = vec![]; 121 | // Just do sequential since parallelizing this will have little benefit 122 | for create_directory in self.create_directories.iter_mut().rev() { 123 | if let Err(err) = create_directory.try_revert().await { 124 | errors.push(err); 125 | } 126 | } 127 | 128 | if errors.is_empty() { 129 | Ok(()) 130 | } else if errors.len() == 1 { 131 | Err(errors 132 | .into_iter() 133 | .next() 134 | .expect("Expected 1 len Vec to have at least 1 item")) 135 | } else { 136 | Err(Self::error(ActionErrorKind::MultipleChildren(errors))) 137 | } 138 | } 139 | } 140 | 141 | /// Everything under /nix/var (with two deprecated exceptions below) should be owned by 0:0. 142 | /// 143 | /// * /nix/var/nix/profiles/per-user/* 144 | /// * /nix/var/nix/gcroots/per-user/* 145 | /// 146 | /// This function walks /nix/var and makes sure that is true. 147 | async fn ensure_nix_var_ownership() -> Result<(), ActionErrorKind> { 148 | let entryiter = walkdir::WalkDir::new("/nix/var") 149 | .follow_links(false) 150 | .same_file_system(true) 151 | .contents_first(true) 152 | .into_iter() 153 | .filter_entry(|entry| { 154 | let parent = entry.path().parent(); 155 | 156 | if parent == Some(std::path::Path::new("/nix/var/nix/profiles/per-user")) 157 | || parent == Some(std::path::Path::new("/nix/var/nix/gcroots/per-user")) 158 | { 159 | // False means do *not* descend into this directory 160 | // ...which we don't want to do, because the per-user subdirectories are usually owned by that user. 161 | return false; 162 | } 163 | 164 | true 165 | }) 166 | .filter_map(|entry| match entry { 167 | Ok(entry) => Some(entry), 168 | Err(e) => { 169 | tracing::warn!(%e, "Failed to get entry in /nix/var"); 170 | None 171 | }, 172 | }) 173 | .filter_map(|entry| match entry.metadata() { 174 | Ok(metadata) => Some((entry, metadata)), 175 | Err(e) => { 176 | tracing::warn!( 177 | path = %entry.path().to_string_lossy(), 178 | %e, 179 | "Failed to read ownership and mode data" 180 | ); 181 | None 182 | }, 183 | }) 184 | .filter_map(|(entry, metadata)| { 185 | // Dirents that are already 0:0 are to be skipped 186 | if metadata.uid() == 0 && metadata.gid() == 0 { 187 | return None; 188 | } 189 | 190 | Some((entry, metadata)) 191 | }); 192 | for (entry, _metadata) in entryiter { 193 | tracing::debug!( 194 | path = %entry.path().to_string_lossy(), 195 | "Re-owning path to 0:0" 196 | ); 197 | 198 | if let Err(e) = std::os::unix::fs::lchown(entry.path(), Some(0), Some(0)) { 199 | tracing::warn!( 200 | path = %entry.path().to_string_lossy(), 201 | %e, 202 | "Failed to set the owner:group to 0:0" 203 | ); 204 | } 205 | } 206 | Ok(()) 207 | } 208 | --------------------------------------------------------------------------------