Skip to content

nixos: improve predictability and consistency in directory creation and permissions-setting#97

Open
tomeon wants to merge 25 commits intonix-community:masterfrom
tomeon:toposort-dirs
Open

nixos: improve predictability and consistency in directory creation and permissions-setting#97
tomeon wants to merge 25 commits intonix-community:masterfrom
tomeon:toposort-dirs

Conversation

@tomeon
Copy link

@tomeon tomeon commented Jun 29, 2022

Purpose

This PR is intended to make Impermanence's directory creation and permissions-setting logic more predictable and consistent. In brief, the main change consists in topologically sorting directory specifications according to reasonably straightforward and (hopefully) sensible rules, such that users can have a pretty good idea of the order in which Impermanence will create the directories and be able to predict what permissions Impermanence will assign them.

Summary of changes

Topological sorting rules

Please see the comments to the dirBefore function for a description of the rules used for ordering directory and parentDirectory specifications.

The main rule is that parent paths come before child paths. This applies to both "source" paths (i.e., child paths of persistent storage directories) and "destination" paths (the paths where things will be linked or bind-mounted).

The root option

This PR adds a root attribute to directory and file specifications that indicates the base output path. By default, this is /. This change is not essential to the rest of this PR and could be handled in a separate PR if you would prefer that.

Directory creation and permissions-setting

Implicit versus explicit directories

This changeset introduces the notion of an "implicit" directory -- a kind of hidden entry in the environment.persistence.${persistentStoragePath}.directories list. Such hidden entries represent the parent directories (and grandparent directories, etc., all the way back to the persistentStoragePath itself) of explicitly-specified files and directories.

Implicit directories differ from explicit directories in that their associated mode, user, and group settings are advisory, in the following senses:

  1. If the source path (path under persistent storage) of an implicit directory needs to be created, and the destination path already exists, the source path will be created using the mode, user and owner of the destination path, and
  2. If an explicitly-specified directory and an implicit directory have the same destination path or source path, the settings associated with the explicitly-specified directory take precedence with respect to the identical path or paths.

Among its other effects, the logic described above addresses the fact that, previously, configurations like:

{
  environment.persistence.users.me = {
    home = "/home/me";
    files = [ ".somerc" ];
  };
}

resulted in setting the mode on /home/me to 0755.

Atomicity

Given the configuration:

{
  environment.persistence."/state".directories = [ "/foo/bar/baz" ];
}

Each component of the source and destination paths (relative to the persistent storage directory and root directory, respectively) is now created atomically.

That is, /state/foo, /state/foo/bar, and /state/foo/bar/baz are created atomically, as are /foo, /foo/bar, and /foo/bar/baz (note that /state is not necessarily created atomically).

These directories are also effectively chowned and chmodded atomically, using the trick of creating a temporary directory at a sibling path of the "final" directory, chowning and chmodding that directory, then rename-ing it to the final path.

Additional validations and assertions

Inability to topologically sort directories

If sort order is undecidable, the NixOS module will raise an error mentioning the problem and common causes thereof.

"Recursive" bind mounts and symlinks

The NixOS module now forbids having files and directories bind mounted or symlinked into persistent storage directories. Please see this section of the README for details.

Path traversal

As described in the README, the NixOS module will raise an error if provided any path specifications that contain .. elements that cannot be resolved/eliminated lexically. For instance, foo/../bar is fine (it can be simplified to just bar without looking at the filesystem and without "escaping" the persistent storage path), but ../foo/bar is not.

Inconsistent permissions settings

This changeset updates the NixOS module to forbid inconsistent permissions settings; please see this bit of the README.

Backward (in)compatibility

Loud breakage

This changeset narrows the space of valid Impermanence configurations. It's likely that the new validations and assertions described above will break someone's NixOS system config. I've tried to make newly-introduced error messages informative enough that users should be able to track down problems in short-ish order, but naturally that's not as nice as being backward-compatible (even if the compatibility is with arguably-subtly-broken configs).

Quiet breakage

It's possible that some users rely on Impermanence's current de facto directory sorting logic, or permissions-setting scheme. The changes in this PR could break such users' setups in ways that may not be immediately apparent. However, because this changeset retains the "don't touch permissions on existing source directories" and "always set the permissions on destination directories to the permissions on the corresponding source directories" logic, my hope is that the surface area for this kind of breakage is fairly small -- for instance, it may only be an issue if standing up a new system where persistent storage directories don't already exist.

Test results

Failing GitHub Actions build with new flake checks, but without changes to the nixos.nix module or create-directories.bash script: here.

Passing GitHub Actions build with changes to the nixos.nix module and create-directories.bash script: here.

Other considerations

Licensing

The topological sorting implementation is inspired and informed by fsBefore from <nixpkgs>/nixos/lib/utils.nix, which is used for topologically sorting config.fileSystems. Though this changeset does not copy code from fsBefore, the domains and even implementations are similar enough that it may be prudent to cite fsBefore and embed a copy of the MIT license. WDYT?

Possible improvements

Add an explicit createDirectories list

This would allow callers to control directory permissions at any and all levels of a persistent storage hierarchy. For instance:

{
  environment.persistence."/abc" = {
    directories = [
      { directory = "/foo/bar"; mode = "0750"; }
    ];

    files = [
      { file = "/bleep/bloop/.blarprc"; parentDirectory.mode = "0700"; }
    ];

    createDirectories = [
      { directory = "/foo"; mode = "0755"; }
      { directory = "/bleep"; mode = "0750"; }
    ];
  };
}

Every entry in directories would have a corresponding (implicit) entry in createDirectories for the directory in question, and every entry files would have a corresponding (implicit) entry in createDirectories for the parent directory of the file in question.

Thus the final directory scheme for the above would be:

[drwxr-xr-x]  /abc
├── [drwxr-x---]  bleep
│   └── [drwx------]  bloop
└── [drwxr-xr-x]  foo
    └── [drwxr-x---]  bar

Allow callers to directly define sorting priority

Perhaps it would make sense to add a priority attribute to directory specifications that allows callers to tell Impermenance whether a given directory should be created earlier or later than others, along the lines of lib.mkForce, lib.mkDefault, etc.?

For instance:

{
  environment.persistence."/abc".directories = [
    { directory = "/foo"; priority = 1000; }
    { directory = "/bar"; priority = 50; }
  ];
}

And perhaps, in addition, support lib.mkForce, etc., directly?:

{ lib, ... }:

{
  environment.persistence."/abc".directories = [
    (lib.mkDefault "/foo")
    (lib.mkForce "/bar")
  ];
}

This may be most useful as a tiebreaker in case the directory-sorting comparator cannot otherwise determine which operand comes "before" the other.

Final thoughts

I realize and regret that this PR is not really "first-time contribution to a project" material. It's a big bunch of changes that maintains syntactic compatibility with the existing NixOS module, but definitely not semantic compatibility.

On the other hand, I think the motivation for and results of these changes are sensible and defensible. The directory permissions that lead to testing errors here strike me as surprising and, in some cases, wrong -- particularly the user's home directory having mode 0755.

If you are open to working with me to get this over the finish line, I would welcome any and all feedback.

Thank you very much for your consideration.

@tomeon tomeon changed the title Improve predictability and consistency in directory creation and permissions-setting nixos: improve predictability and consistency in directory creation and permissions-setting Jun 29, 2022
@talyz
Copy link
Collaborator

talyz commented Aug 22, 2022

Hi tomeon! Thank you for putting in all this work - it looks really impressive! It's also a huge changeset, almost all of it in one commit, which makes it harder to review and pinpoint bugs caused by it. Can you please split it up? I would expect one commit per change, e.g: sorting, root option, implicit directories, atomicity and each assertion which isn't directly related to a change.

@rehno-lindeque
Copy link

rehno-lindeque commented Sep 12, 2022

I came to this PR wondering about permissions myself.

Are folks convinced that

{ file = "..."; parentDirectory = { user = "..."; group = "..."; }; } # implicitly bubble up permissions to parent paths

and

{ directory = "..."; user = "..."; group = "..."; }; # implicitly bubble up permissions to parent paths

Is actually a good API with the complexity involved in sorting and then bubbling up permissions behind the scenes? I'm not personally too convinced it's adding much to the ergonomics to be honest.

Instead of...

environment.persistence = {
    users.me = {
      directories = [
      
        # ~/.local is created with ownership implicitly set to me:users
        # ~/.local/share is created with ownership implicitly set to me:users
        { directory = ".local/share/nix"; user = "me"; group = "users; } 
        
        # ~/.local is not created because "nix" comes before "nvim"
        # ~/.local/share is not created because "nix" comes before "nvim"
        # (If nix and nvim were swapped, you need to learn about setting ordering priorities)
        { directory = ".local/share/nvim"; user = "nvim"; group = "nvim"; } 
        
      ];
    };
};

...maybe it would be more clear for everyone involved if the API did not bubble up permissions? (That is always be explicit, never implicit.)

environment.persistence = {
    users.me = {
      directories = [
        ".local/share/nix"
        
        # (optional) shorthand for setting permissions inline
        { directory = ".local/share/nvim"; user = "nvim"; group = "nvim"; }
      ];

      permissions = {
      
        # ~/.local is created with default ownership root:root (entirely unaffected by permissions of its children - so files and subdirectories)     
        # ~/.local/share is created with ownership me:users 
        ".local/share" = { user  = "me"; group = "users; };
        
      };
    };
};

This is perhaps similar to the createDirectories idea that @tomeon mentioned.

Alternative naming ideas for permissions: extraPermissions / overridePermissions / ephemeralPermissions

Additionally one could potentially have

environment.persistence = {
    users.me = {
      directories = [
        ".local/share/nix"

        # (optional) shorthand for setting permissions inline
        { directory = ".local/share/nvim"; user = "nvim"; group = "nvim"; }
      ];
      
      # default ownership for ~/.local/share, ~/.local/share/nix is me:users
      defaultPermissions = { user = "me"; group = "users"; }; 
      
      permissions = {
      
        # protect ~/.local (set ownership to root:root)
        ".local" = { user = "root"; group = "root"; }; 
        
      };
    };
};

This is just a thought though, don't let me hold up the PR which already sounds like a nice improvement.

@rehno-lindeque
Copy link

rehno-lindeque commented Sep 12, 2022

(Alternative naming ideas for createDirectories: paths / unmanagedPaths / ephemeralPaths / implicitDirectories / implicitPaths)

@tomeon tomeon force-pushed the toposort-dirs branch 2 times, most recently from 9553b3b to 48d5bf0 Compare November 10, 2022 13:02
@tomeon
Copy link
Author

tomeon commented Nov 10, 2022

@talyz -- I've split up the changes into what I hope is a reasonably granular sequence. Found and fixed a few typos and other issues on the way, too :). Thank you for prompting me to disaggregate the much-too-big initial commits.

There are some commits that might still encompass too much. Examples:

  1. d0741c4 - this changeset includes the addition of files and directories (internal) submodule options like prefix, source, destination, etcetera. It also includes the switch from mkOption's apply to lib.types.coercedTo when converting stringy files and directories specifications into attrsets. The commit message attempts to explain why both changes are included (basically, continuing to use the apply approach would have meant manually filling out values for the prefix, source, destination, ..., values when converting a string to an attrset, whereas using lib.types.coercedTo makes the NixOS module system do that work for us).
  2. fff1226: has a high incidental-changes quotient.

Please LMK if you'd like me to tackle further breaking up these or other commits.

Please also note that I've moved the NixOS VM test into a fairly early position in the commit list, but with the bits that check mode and ownership disabled. This is to help ensure that no commits in between the introduction of the test and the commit that enables mode and ownership assertions break directory creation (even though they may break or otherwise alter the directory-permissions-setting logic). The moral of the story: nix flake check now passes for for all commits in this branch (tested with git rebase -i exec 'nix flake check -L').

@talyz
Copy link
Collaborator

talyz commented Nov 13, 2022

I also started working on this issue a little while back, since I found the issue interesting, not knowing you had picked it back up. I have now pushed my changes to https://github.com/nix-community/impermanence/tree/dir-creation-order. There is some overlap between our solutions, but the main issue is solved differently. I'm creating directory entries for all parent directories in nix, simplifying the shell code significantly.

I'm obviously biased here and would prefer my solution, but there are still things from this PR I would very much like to merge - the tests, extended assertions, fixes, etc, which all look amazing! Maybe we should also sync our efforts going forward - I've created a matrix channel for the project at https://matrix.to/#/#impermanence:nixos.org.

@tomeon
Copy link
Author

tomeon commented Dec 20, 2024

@talyz -- I have some free time coming up and may finally be able to work on rebasing "[t]he tests, extended assertions, fixes, etc." on top of the tip of this project. Not sure how much of it is still relevant; we shall see :).

Re the complexity of the shell code in this PR: yes, it is indeed complex, though to some extent I think that's a reflection of the complexity of the problem space. Keeping the path munging in Nix is definitely preferable, though Nix's built-in path handling and the functions available from the nixpkgs standard library aren't quite (AFAICT) up to the task of dealing with all of the cases handled by the shell script in this PR. Please rest assured that this is not at all meant to be a sales pitch, but I've taken a crack at adding a richer set of path-handling capabilities to Nix over at https://git.sr.ht/~tomeon/pathname.nix.

@talyz
Copy link
Collaborator

talyz commented Jan 25, 2025

Sorry, I thought I had responded to this already, but apparently not!

Anyway, that sounds great! I bet there's still a fair bit of it that's relevant.

Yep, that's definitely true. I just prefer to keep the bash complexity to a minimum, even if that means the nix code will be more complex as a result. Complex runtime code just means more work to debug runtime issues users run into.

I think your pathname project looks really interesting! I'm all for powerful path handling in nix :)

tomeon and others added 12 commits August 14, 2025 13:01
rather than individual jobs for each linter
That is, add check that detect that the `nixos.nix` module catches
attempts to specify the same output path for multiple directories or
files under existing storage (e.g., trying to put both "/abc/same" and
"/def/same" into "/same").
That is, test the the `nixos.nix` module catches failure to mark a
filesystem as needed for boot when attempting to use it as a persistent
storage path.
That is, add a check that verifies that, for every user `<user>` that
has files or directories in persistent storage path `<path>`,
`config.users.users.<user>.home` matches
`environment.persistence.<path>.users.<user>.home`.
This test sets up multiple users and groups, multiple persistent storage
directories, and multiple files and directories under those storage
directories (some with overlapping destination paths), and then makes
assertions about the permissions on the source and destination paths.

NOTE that the permissions-related assertions are currently disabled.
This is an expedient to allow us to run the check before the intended
updates to the directory creation ordering and permissions-setting logic
are in place.
tomeon added 8 commits August 14, 2025 13:23
that removes repeated directory separators, removes "." components,
and attempts to resolve ".." (and throws if it cannot).
Previous behavior:

    splitPath [ "/foo/./bar/." "./bazz/quux/./." ]
    #=> [ "foo" "." "bar" "." "." "bazz" "quux" "." "." ]

New behavior:

    splitPath [ "/foo/./bar/." "./bazz/quux/./." ]
    #=> [ "foo" "bar" "bazz" "quux" ]
As follows:

    1. Clean the concatenated paths in `dirListToPath` with `cleanPath`,
    2. Make `concatPaths` an alias of `dirListToPath`,
    3. Make `splitPath` a small wrapper over `concatPaths` (namely,
       split the result of `concatPaths [ list of paths ]` on the
       directory separator).

BREAKING CHANGE: all three of `dirListToPath`, `concatPaths`, and
`splitPath` are now liable to throw on ".." elements that cannot be
eliminated lexically.
Adding the internal attributes:

    1. `prefix` - a path fragment prepended to `<file>.file` or
       `<directory>.dir` when expanding it relative to the persistent
       storage directory (exists to support prepending the implicit home
       directory in `users.<user>.files` and `users.<user>.directories`
       specifications),
    2. `relpath` - the file or directory path relative to the persistent
       storage path and `/`.
    3. `source` - the fully-qualified and normalized path rooted in the
       persistent storage path.
    4. `destination` - the fully-qualified and normalized path rooted in
        `/`.

This consolidates path-expansion logic into the file and directory
specifications themselves, and permits other parts of the Impermanence
module to refer to the expanded paths directly (most frequently,
`destination` and `source`).
to be created/chowned/chmodded using `create-directories.bash`.

The directories are sorted such that parent *source* paths (that is,
paths under persistent storage directories) are processed prior to child
source paths, and parent *destination* paths (that is, paths under the
target installation root) are processed prior to child destination
paths.
That is, forbid mounts and links whose paths lie under an
Impermanence-managed persistent storage path.
BREAKING CHANGE: replace `lib.types.str` with `lib.types.nonEmptyStr` in
the type definitions for the `files.<name>.file` and
`directories.<name>.directory` options and their corresponding options
under `users.<user>`.

This prevents specifying the persistent storage directory itself (or
`<persistent-storage-directory>/<user-home>`) as a file or directory to
be linked or mounted into the installation target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants