Commit a182a532 authored by K900's avatar K900
Browse files

buildFHSEnv: rewrite env building

This replaces the mess of buildEnvs with a single Rust binary that
spits out a mostly-complete root filesystem for an fhsenv.

The main goal is to have includeClosures, as we want all of the
dependencies to be in the fhsenv to avoid Steam's (and others')
LD_LIBRARY_PATH shenanigans, but without 32-bit libraries leaking
into lib64 when a 64-bit package like mangohud depends on a 32-bit
version of itself.

We "fix" this by actually looking at the files and explicitly moving
32-bit stuff to $out/lib32. This could be avoided if we had recursive
Nix, or at least system info in exportReferencesGraph, but alas.

For some reason this also shrinks the fhsenvs massively, even though
there's currently no layout optimization (e.g. a package with paths
like lib/foo/{bar,baz} will produce two symlinks in the output, even
when it's more optimal to symlink lib/foo to $out/lib/foo directly).
parent ccac709d
Loading
Loading
Loading
Loading
+128 −182
Original line number Diff line number Diff line
@@ -33,18 +33,15 @@
# follows:
# /lib32 will include 32bit libraries from multiPkgs
# /lib64 will include 64bit libraries from multiPkgs and targetPkgs
# /lib will link to /lib32
# /lib will link to /lib64

let
  inherit (stdenv.hostPlatform) is64bit;

  name = if (args ? pname && args ? version)
    then "${args.pname}-${args.version}"
    else args.name;

  # "use of glibc_multi is only supported on x86_64-linux"
  isMultiBuild = multiArch && stdenv.system == "x86_64-linux";
  isTargetBuild = !isMultiBuild;

  # list of packages (usually programs) which match the host's architecture
  # (which includes stuff from multiPkgs)
@@ -60,7 +57,7 @@ let
  baseTargetPaths = with pkgs; [
    glibcLocales
    (if isMultiBuild then glibc_multi else glibc)
    (toString gcc.cc.lib)
    gcc.cc.lib
    bashInteractiveFHS
    coreutils
    less
@@ -77,7 +74,7 @@ let
    xz
  ];
  baseMultiPaths = with pkgsi686Linux; [
    (toString gcc.cc.lib)
    gcc.cc.lib
  ];

  ldconfig = writeShellScriptBin "ldconfig" ''
@@ -85,7 +82,10 @@ let
    exec ${if isMultiBuild then pkgsi686Linux.glibc.bin else pkgs.glibc.bin}/bin/ldconfig -f /etc/ld.so.conf -C /etc/ld.so.cache "$@"
  '';

  etcProfile = writeText "profile" ''
  etcProfile = pkgs.writeTextFile {
    name = "${name}-fhsenv-profile";
    destination = "/etc/profile";
    text = ''
      export PS1='${name}-fhsenv:\u@\h:\w\$ '
      export LOCALE_ARCHIVE='/usr/lib/locale/locale-archive'
      export PATH="/run/wrappers/bin:/usr/bin:/usr/sbin:$PATH"
@@ -123,152 +123,98 @@ let

      ${profile}
    '';
  };

  # Compose /etc for the fhs environment
  etcPkg = runCommandLocal "${name}-fhs-etc" { } ''
    mkdir -p $out/etc
    pushd $out/etc

    # environment variables
    ln -s ${etcProfile} profile

    # symlink /etc/mtab -> /proc/mounts (compat for old userspace progs)
    ln -s /proc/mounts mtab
  '';

  # Composes a /usr-like directory structure
  staticUsrProfileTarget = buildEnv {
    name = "${name}-usr-target";
    # ldconfig wrapper must come first so it overrides the original ldconfig
    paths = [ etcPkg ldconfig ] ++ baseTargetPaths ++ targetPaths;
    extraOutputsToInstall = [ "out" "lib" "bin" ] ++ extraOutputsToInstall;
    ignoreCollisions = true;
    postBuild = ''
      if [[ -d  $out/share/gsettings-schemas/ ]]; then
          # Recreate the standard schemas directory if its a symlink to make it writable
          if [[ -L $out/share/glib-2.0 ]]; then
              target=$(readlink $out/share/glib-2.0)
              rm $out/share/glib-2.0
              mkdir $out/share/glib-2.0
              ln -fsr $target/* $out/share/glib-2.0
          fi

          if [[ -L $out/share/glib-2.0/schemas ]]; then
              target=$(readlink $out/share/glib-2.0/schemas)
              rm $out/share/glib-2.0/schemas
              mkdir $out/share/glib-2.0/schemas
              ln -fsr $target/* $out/share/glib-2.0/schemas
          fi

  ensureGsettingsSchemasIsDirectory = runCommandLocal "fhsenv-ensure-gsettings-schemas-directory" {} ''
    mkdir -p $out/share/glib-2.0/schemas

          for d in $out/share/gsettings-schemas/*; do
              # Force symlink, in case there are duplicates
              ln -fsr $d/glib-2.0/schemas/*.xml $out/share/glib-2.0/schemas
              ln -fsr $d/glib-2.0/schemas/*.gschema.override $out/share/glib-2.0/schemas
          done

          # and compile them
          ${pkgs.glib.dev}/bin/glib-compile-schemas $out/share/glib-2.0/schemas
      fi
    touch $out/share/glib-2.0/schemas/.keep
  '';
    inherit includeClosures;
  };

  staticUsrProfileMulti = buildEnv {
    name = "${name}-usr-multi";
    paths = baseMultiPaths ++ multiPaths;
    extraOutputsToInstall = [ "out" "lib" ] ++ extraOutputsToInstall;
    ignoreCollisions = true;
    inherit includeClosures;
  # Shamelessly stolen (and cleaned up) from original buildEnv.
  # Should be semantically equivalent, except we also take
  # a list of default extra outputs that will be installed
  # for derivations that don't explicitly specify one.
  # Note that this is not the same as `extraOutputsToInstall`,
  # as that will apply even to derivations with an output
  # explicitly specified, so this does change the behavior
  # very slightly for that particular edge case.
  pickOutputs = let
    pickOutputsOne = outputs: drv:
      let
        isSpecifiedOutput = drv.outputSpecified or false;
        outputsToInstall = drv.meta.outputsToInstall or null;
        pickedOutputs = if isSpecifiedOutput || outputsToInstall == null
          then [ drv ]
          else map (out: drv.${out} or null) (outputsToInstall ++ outputs);
        extraOutputs = map (out: drv.${out} or null) extraOutputsToInstall;
        cleanOutputs = lib.filter (o: o != null) (pickedOutputs ++ extraOutputs);
      in {
        paths = cleanOutputs;
        priority = drv.meta.priority or lib.meta.defaultPriority;
      };
  in paths: outputs: map (pickOutputsOne outputs) paths;

  # setup library paths only for the targeted architecture
  setupLibDirsTarget = ''
    # link content of targetPaths
    cp -rsHf ${staticUsrProfileTarget}/lib lib
    ln -s lib lib${if is64bit then "64" else "32"}
  '';
  paths = let
    basePaths = [
      etcProfile
      # ldconfig wrapper must come first so it overrides the original ldconfig
      ldconfig
      # magic package that just creates a directory, to ensure that
      # the entire directory can't be a symlink, as we will write
      # compiled schemas to it
      ensureGsettingsSchemasIsDirectory
    ] ++ baseTargetPaths ++ targetPaths;
  in pickOutputs basePaths ["out" "lib" "bin"];

  paths32 = lib.optionals isMultiBuild (
    let
      basePaths = baseMultiPaths ++ multiPaths;
    in pickOutputs basePaths ["out" "lib"]
  );

  # setup /lib, /lib32 and /lib64
  setupLibDirsMulti = ''
    mkdir -m0755 lib32
    mkdir -m0755 lib64
    ln -s lib64 lib
  allPaths = paths ++ paths32;

    # copy glibc stuff
    cp -rsHf ${staticUsrProfileTarget}/lib/32/* lib32/
    chmod u+w -R lib32/
  rootfs-builder = pkgs.rustPlatform.buildRustPackage {
    name = "fhs-rootfs-bulder";
    src = ./rootfs-builder;
    cargoLock.lockFile = ./rootfs-builder/Cargo.lock;
    doCheck = false;
  };

    # copy content of multiPaths (32bit libs)
    if [ -d ${staticUsrProfileMulti}/lib ]; then
      cp -rsHf ${staticUsrProfileMulti}/lib/* lib32/
      chmod u+w -R lib32/
  rootfs = pkgs.runCommand "${name}-fhsenv-rootfs" {
    __structuredAttrs = true;
    exportReferencesGraph.graph = lib.concatMap (p: p.paths) allPaths;
    inherit paths paths32 isMultiBuild includeClosures;
    nativeBuildInputs = [ pkgs.jq ];
  } ''
    ${rootfs-builder}/bin/rootfs-builder

    # create a bunch of symlinks for usrmerge
    ln -s /usr/bin $out/bin
    ln -s /usr/sbin $out/sbin
    ln -s /usr/lib $out/lib
    ln -s /usr/lib32 $out/lib32
    ln -s /usr/lib64 $out/lib64
    ln -s /usr/lib64 $out/usr/lib

    # symlink 32-bit ld-linux so it's visible in /lib
    if [ -e $out/usr/lib32/ld-linux.so.2 ]; then
      ln -s /usr/lib32/ld-linux.so.2 $out/usr/lib64/ld-linux.so.2
    fi

    # copy content of targetPaths (64bit libs)
    cp -rsHf ${staticUsrProfileTarget}/lib/* lib64/
    chmod u+w -R lib64/

    # symlink 32-bit ld-linux.so
    ln -Lsf ${staticUsrProfileTarget}/lib/32/ld-linux.so.2 lib/
  '';

  setupLibDirs = if isTargetBuild
                 then setupLibDirsTarget
                 else setupLibDirsMulti;

  # the target profile is the actual profile that will be used for the fhs
  setupTargetProfile = ''
    mkdir -m0755 usr
    pushd usr

    ${setupLibDirs}

    '' + lib.optionalString isMultiBuild ''
    if [ -d "${staticUsrProfileMulti}/share" ]; then
      cp -rLf ${staticUsrProfileMulti}/share share
    fi
    '' + ''
    if [ -d "${staticUsrProfileTarget}/share" ]; then
      if [ -d share ]; then
        chmod -R 755 share
        cp -rLTf ${staticUsrProfileTarget}/share share
      else
        cp -rsHf ${staticUsrProfileTarget}/share share
      fi
    fi
    for i in bin sbin include; do
      if [ -d "${staticUsrProfileTarget}/$i" ]; then
        cp -rsHf "${staticUsrProfileTarget}/$i" "$i"
      fi
    done
    cd ..
    # symlink /etc/mtab -> /proc/mounts (compat for old userspace progs)
    ln -s /proc/mounts $out/etc/mtab

    for i in etc opt; do
      if [ -d "${staticUsrProfileTarget}/$i" ]; then
        cp -rsHf "${staticUsrProfileTarget}/$i" "$i"
      fi
    if [[ -d $out/usr/share/gsettings-schemas/ ]]; then
      for d in $out/usr/share/gsettings-schemas/*; do
        # Force symlink, in case there are duplicates
        ln -fsr $d/glib-2.0/schemas/*.xml $out/usr/share/glib-2.0/schemas
        ln -fsr $d/glib-2.0/schemas/*.gschema.override $out/usr/share/glib-2.0/schemas
      done
    for i in usr/{bin,sbin,lib,lib32,lib64}; do
      if [ -d "$i" ]; then
        ln -s "$i"
      ${pkgs.glib.dev}/bin/glib-compile-schemas $out/usr/share/glib-2.0/schemas
    fi
    done

    popd
  '';

in runCommandLocal "${name}-fhs" {
  inherit nativeBuildInputs;
  passthru = {
    inherit args baseTargetPaths targetPaths baseMultiPaths ldconfig isMultiBuild;
  };
} ''
  mkdir -p $out
  pushd $out

  ${setupTargetProfile}
    ${extraBuildCommands}
    ${lib.optionalString isMultiBuild extraBuildCommandsMulti}
''
  '';
in rootfs
+249 −0
Original line number Diff line number Diff line
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3

[[package]]
name = "anyhow"
version = "1.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8"

[[package]]
name = "goblin"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53ab3f32d1d77146981dea5d6b1e8fe31eedcb7013e5e00d6ccd1259a4b4d923"
dependencies = [
 "log",
 "plain",
 "scroll",
]

[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"

[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"

[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"

[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"

[[package]]
name = "proc-macro2"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "quote"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
 "proc-macro2",
]

[[package]]
name = "rootfs-builder"
version = "0.1.0"
dependencies = [
 "anyhow",
 "goblin",
 "serde",
 "serde_json",
 "walkdir",
]

[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"

[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
 "winapi-util",
]

[[package]]
name = "scroll"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
dependencies = [
 "scroll_derive",
]

[[package]]
name = "scroll_derive"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f81c2fde025af7e69b1d1420531c8a8811ca898919db177141a85313b1cb932"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde"
version = "1.0.213"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.213"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde_json"
version = "1.0.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
dependencies = [
 "itoa",
 "memchr",
 "ryu",
 "serde",
]

[[package]]
name = "syn"
version = "2.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"

[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
 "same-file",
 "winapi-util",
]

[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
 "windows-sys",
]

[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
 "windows-targets",
]

[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
 "windows_aarch64_gnullvm",
 "windows_aarch64_msvc",
 "windows_i686_gnu",
 "windows_i686_gnullvm",
 "windows_i686_msvc",
 "windows_x86_64_gnu",
 "windows_x86_64_gnullvm",
 "windows_x86_64_msvc",
]

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"

[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"

[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"

[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"

[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+11 −0
Original line number Diff line number Diff line
[package]
name = "rootfs-builder"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "*"
serde = { version = "*", features = ["derive"] }
serde_json = "*"
walkdir = "*"
goblin = "*"
+293 −0
Original line number Diff line number Diff line
#![deny(clippy::pedantic)]

use std::{
    collections::{hash_map::Entry, HashMap},
    env,
    fs::{self, File},
    io::{BufReader, Read},
    os::unix::fs as ufs,
    path::{Path, PathBuf},
};

use anyhow::{anyhow, Context};
use goblin::{Hint, Object};
use serde::Deserialize;
use walkdir::WalkDir;

#[derive(Debug, Deserialize)]
struct RefGraphNode {
    path: PathBuf,
    references: Vec<PathBuf>,
}

#[derive(Debug, Deserialize)]
struct InputDrv {
    paths: Vec<PathBuf>,
    priority: i64,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StructuredAttrsRoot {
    graph: Vec<RefGraphNode>,
    paths: Vec<InputDrv>,
    paths32: Vec<InputDrv>,
    include_closures: bool,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct PriorityKey {
    implicit: bool,
    group_priority: i64,
    priority: i64,
    root_index: usize,
}

fn build_reference_map(refs: Vec<RefGraphNode>) -> HashMap<PathBuf, Vec<PathBuf>> {
    refs.into_iter()
        .map(|mut gn| {
            gn.references.retain_mut(|x| x != &gn.path);
            (gn.path, gn.references)
        })
        .collect()
}

fn build_priority_keys(roots: &[InputDrv], group_priority: i64) -> HashMap<PathBuf, PriorityKey> {
    let mut roots_map = HashMap::new();

    for (idx, path) in roots.iter().enumerate() {
        for subpath in &path.paths {
            let priority = PriorityKey {
                group_priority,
                priority: path.priority,
                root_index: idx,
                implicit: false,
            };

            roots_map.entry(subpath.clone()).or_insert(priority);
        }
    }

    roots_map
}

fn extend_to_closure(
    roots: HashMap<PathBuf, PriorityKey>,
    refs: &HashMap<PathBuf, Vec<PathBuf>>,
) -> anyhow::Result<HashMap<PathBuf, PriorityKey>> {
    let mut path_map = HashMap::new();

    for (root, priority) in roots {
        path_map.insert(root.clone(), priority);

        let mut priority = priority;
        priority.implicit = true;

        let mut stack = vec![root.clone()];

        while let Some(next) = stack.pop() {
            match path_map.entry(next.clone()) {
                Entry::Occupied(mut occupied_entry) => {
                    let old_priority: &PriorityKey = occupied_entry.get();
                    if old_priority > &priority {
                        occupied_entry.insert(priority);
                    }
                }
                Entry::Vacant(vacant_entry) => {
                    vacant_entry.insert(priority);
                }
            }

            stack.extend_from_slice(refs.get(&next).ok_or(anyhow!("encountered unknown path"))?);
        }
    }

    Ok(path_map)
}

#[derive(Clone, Debug)]
struct CandidatePath {
    root: PathBuf,
    relative: PathBuf,
    priority: PriorityKey,
}

fn collect_candidate_paths(
    paths: HashMap<PathBuf, PriorityKey>,
    mapper: impl Fn(&Path, &Path) -> Option<PathBuf>,
) -> anyhow::Result<HashMap<PathBuf, Vec<CandidatePath>>> {
    let mut candidates: HashMap<_, Vec<_>> = HashMap::new();

    for (path, priority) in paths {
        for entry in WalkDir::new(&path).follow_links(true) {
            let entry: PathBuf = match entry {
                Ok(ent) => {
                    // we don't care about directory structure
                    if ent.file_type().is_dir() {
                        continue;
                    }

                    ent.path().into()
                }
                Err(e) => {
                    match e.io_error() {
                        // could be a broken symlink, that's fine, we still want to handle those
                        Some(_) => e
                            .path()
                            .ok_or_else(|| anyhow!("I/O error when walking {path:?}"))?
                            .into(),
                        None => {
                            // symlink loop
                            continue;
                        }
                    }
                }
            };

            let relative = entry.strip_prefix(&path)?.to_owned();
            if let Some(mapped) = mapper(&path, &relative) {
                candidates.entry(mapped).or_default().push(CandidatePath {
                    root: path.clone(),
                    relative,
                    priority,
                });
            }
        }
    }

    Ok(candidates)
}

fn remap_native_path(root: &Path, p: &Path) -> Option<PathBuf> {
    if p.starts_with("bin/") || p.starts_with("sbin/") {
        return Some(PathBuf::from("usr/").join(p));
    }

    // glibc-multilib special case
    if let Ok(no_lib32) = p.strip_prefix("lib/32/") {
        return Some(PathBuf::from("usr/lib32/").join(no_lib32));
    }

    remap_multilib_path(root, p)
}

fn is_32_bit(path: &Path) -> bool {
    // Be as pessimistic as possible, at least for now.
    let Ok(mut f) = File::open(path) else {
        return false;
    };

    let Ok(Hint::Elf(hint)) = goblin::peek(&mut f) else {
        return false;
    };

    if let Some(is64) = hint.is_64 {
        return !is64;
    }

    let mut buf = vec![];
    let Ok(_) = f.read_to_end(&mut buf) else {
        return false;
    };

    let Ok(Object::Elf(e)) = goblin::Object::parse(&buf) else {
        return false;
    };

    !e.is_64
}

fn remap_multilib_path(root: &Path, p: &Path) -> Option<PathBuf> {
    if let Ok(no_lib) = p.strip_prefix("lib/") {
        let full = root.join(p);

        let libdir = if is_32_bit(&full) { "lib32" } else { "lib64" };

        return Some(PathBuf::from("usr/").join(libdir).join(no_lib));
    }

    if p.starts_with("etc/") || p.starts_with("opt/") {
        return Some(p.into());
    }

    if p.starts_with("share/") || p.starts_with("include/") {
        return Some(PathBuf::from("usr/").join(p));
    }

    None
}

fn build_plan(
    paths: HashMap<PathBuf, PriorityKey>,
    paths32: HashMap<PathBuf, PriorityKey>,
) -> anyhow::Result<HashMap<PathBuf, PathBuf>> {
    let candidates_native = collect_candidate_paths(paths, remap_native_path)?;
    let candidates_32 = collect_candidate_paths(paths32, remap_multilib_path)?;

    let mut all_candidates: HashMap<_, Vec<_>> = HashMap::new();

    for map in [candidates_native, candidates_32] {
        for (path, candidates) in map {
            all_candidates
                .entry(path)
                .or_default()
                .extend_from_slice(&candidates);
        }
    }

    let mut final_plan: HashMap<PathBuf, PathBuf> = HashMap::new();
    for (path, candidates) in all_candidates {
        let best = candidates
            .into_iter()
            .min_by_key(|&CandidatePath { priority, .. }| priority)
            .ok_or(anyhow!("candidate list empty"))?;
        final_plan.insert(path, best.root.join(best.relative));
    }

    Ok(final_plan)
}

fn build_env(out: &Path, plan: HashMap<PathBuf, PathBuf>) -> anyhow::Result<()> {
    fs::create_dir_all(out)?;

    for (dest, src) in plan {
        let full_dest = out.join(&dest);
        let dest_dir = full_dest
            .parent()
            .ok_or(anyhow!("destination directory is root"))
            .with_context(|| {
                format!("When trying to determine destination directory for {full_dest:?}")
            })?;
        fs::create_dir_all(dest_dir)
            .with_context(|| format!("When trying to create directory {dest_dir:?}"))?;
        ufs::symlink(&src, &full_dest)
            .with_context(|| format!("When symlinking {src:?} to {full_dest:?}"))?;
    }

    Ok(())
}

fn main() -> anyhow::Result<()> {
    let filename = env::var("NIX_ATTRS_JSON_FILE")?;

    let reader = File::open(filename)?;
    let buf_reader = BufReader::new(reader);

    let attrs: StructuredAttrsRoot = serde_json::from_reader(buf_reader)?;

    let mut paths = build_priority_keys(&attrs.paths, 1);
    let mut paths32 = build_priority_keys(&attrs.paths32, 2);

    if attrs.include_closures {
        let refs = build_reference_map(attrs.graph);

        paths = extend_to_closure(paths, &refs)?;
        paths32 = extend_to_closure(paths32, &refs)?;
    };

    let plan = build_plan(paths, paths32)?;

    let out_dir = env::var("out")?;

    build_env(&PathBuf::from(out_dir), plan)
}