about summary refs log tree commit diff
path: root/nixpkgs/pkgs/test/nixpkgs-check-by-name/src
diff options
context:
space:
mode:
Diffstat (limited to 'nixpkgs/pkgs/test/nixpkgs-check-by-name/src')
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.nix59
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.rs124
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/main.rs171
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/references.rs184
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/structure.rs152
-rw-r--r--nixpkgs/pkgs/test/nixpkgs-check-by-name/src/utils.rs72
6 files changed, 762 insertions, 0 deletions
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.nix b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.nix
new file mode 100644
index 000000000000..7c0ae755215a
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.nix
@@ -0,0 +1,59 @@
+# Takes a path to nixpkgs and a path to the json-encoded list of attributes to check.
+# Returns an attribute set containing information on each requested attribute.
+# If the attribute is missing from Nixpkgs it's also missing from the result.
+#
+# The returned information is an attribute set with:
+# - call_package_path: The <path> from `<attr> = callPackage <path> { ... }`,
+#   or null if it's not defined as with callPackage, or if the <path> is not a path
+# - is_derivation: The result of `lib.isDerivation <attr>`
+{
+  attrsPath,
+  nixpkgsPath,
+}:
+let
+  attrs = builtins.fromJSON (builtins.readFile attrsPath);
+
+  # This overlay mocks callPackage to persist the path of the first argument
+  callPackageOverlay = self: super: {
+    callPackage = fn: args:
+      let
+        result = super.callPackage fn args;
+      in
+      if builtins.isAttrs result then
+        # If this was the last overlay to be applied, we could just only return the `_callPackagePath`,
+        # but that's not the case because stdenv has another overlays on top of user-provided ones.
+        # So to not break the stdenv build we need to return the mostly proper result here
+        result // {
+          _callPackagePath = fn;
+        }
+      else
+        # It's very rare that callPackage doesn't return an attribute set, but it can occur.
+        {
+          _callPackagePath = fn;
+        };
+  };
+
+  pkgs = import nixpkgsPath {
+    # Don't let the users home directory influence this result
+    config = { };
+    overlays = [ callPackageOverlay ];
+  };
+
+  attrInfo = attr: {
+    # These names are used by the deserializer on the Rust side
+    call_package_path =
+      if pkgs.${attr} ? _callPackagePath && builtins.isPath pkgs.${attr}._callPackagePath then
+        toString pkgs.${attr}._callPackagePath
+      else
+        null;
+    is_derivation = pkgs.lib.isDerivation pkgs.${attr};
+  };
+
+  attrInfos = builtins.listToAttrs (map (name: {
+    inherit name;
+    value = attrInfo name;
+  }) attrs);
+
+in
+# Filter out attributes not in Nixpkgs
+builtins.intersectAttrs pkgs attrInfos
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.rs b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.rs
new file mode 100644
index 000000000000..d084642ffe7e
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/eval.rs
@@ -0,0 +1,124 @@
+use crate::structure;
+use crate::utils::ErrorWriter;
+use std::path::Path;
+
+use anyhow::Context;
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::io;
+use std::path::PathBuf;
+use std::process;
+use tempfile::NamedTempFile;
+
+/// Attribute set of this structure is returned by eval.nix
+#[derive(Deserialize)]
+struct AttributeInfo {
+    call_package_path: Option<PathBuf>,
+    is_derivation: bool,
+}
+
+const EXPR: &str = include_str!("eval.nix");
+
+/// Check that the Nixpkgs attribute values corresponding to the packages in pkgs/by-name are
+/// of the form `callPackage <package_file> { ... }`.
+/// See the `eval.nix` file for how this is achieved on the Nix side
+pub fn check_values<W: io::Write>(
+    error_writer: &mut ErrorWriter<W>,
+    nixpkgs: &structure::Nixpkgs,
+    eval_accessible_paths: Vec<&Path>,
+) -> anyhow::Result<()> {
+    // Write the list of packages we need to check into a temporary JSON file.
+    // This can then get read by the Nix evaluation.
+    let attrs_file = NamedTempFile::new().context("Failed to create a temporary file")?;
+    serde_json::to_writer(&attrs_file, &nixpkgs.package_names).context(format!(
+        "Failed to serialise the package names to the temporary path {}",
+        attrs_file.path().display()
+    ))?;
+
+    // With restrict-eval, only paths in NIX_PATH can be accessed, so we explicitly specify the
+    // ones needed needed
+
+    let mut command = process::Command::new("nix-instantiate");
+    command
+        // Inherit stderr so that error messages always get shown
+        .stderr(process::Stdio::inherit())
+        // Clear NIX_PATH to be sure it doesn't influence the result
+        .env_remove("NIX_PATH")
+        .args([
+            "--eval",
+            "--json",
+            "--strict",
+            "--readonly-mode",
+            "--restrict-eval",
+            "--show-trace",
+            "--expr",
+            EXPR,
+        ])
+        // Pass the path to the attrs_file as an argument and add it to the NIX_PATH so it can be
+        // accessed in restrict-eval mode
+        .args(["--arg", "attrsPath"])
+        .arg(attrs_file.path())
+        .arg("-I")
+        .arg(attrs_file.path())
+        // Same for the nixpkgs to test
+        .args(["--arg", "nixpkgsPath"])
+        .arg(&nixpkgs.path)
+        .arg("-I")
+        .arg(&nixpkgs.path);
+
+    // Also add extra paths that need to be accessible
+    for path in eval_accessible_paths {
+        command.arg("-I");
+        command.arg(path);
+    }
+
+    let result = command
+        .output()
+        .context(format!("Failed to run command {command:?}"))?;
+
+    if !result.status.success() {
+        anyhow::bail!("Failed to run command {command:?}");
+    }
+    // Parse the resulting JSON value
+    let actual_files: HashMap<String, AttributeInfo> = serde_json::from_slice(&result.stdout)
+        .context(format!(
+            "Failed to deserialise {}",
+            String::from_utf8_lossy(&result.stdout)
+        ))?;
+
+    for package_name in &nixpkgs.package_names {
+        let relative_package_file = structure::Nixpkgs::relative_file_for_package(package_name);
+        let absolute_package_file = nixpkgs.path.join(&relative_package_file);
+
+        if let Some(attribute_info) = actual_files.get(package_name) {
+            let is_expected_file =
+                if let Some(call_package_path) = &attribute_info.call_package_path {
+                    absolute_package_file == *call_package_path
+                } else {
+                    false
+                };
+
+            if !is_expected_file {
+                error_writer.write(&format!(
+                    "pkgs.{package_name}: This attribute is not defined as `pkgs.callPackage {} {{ ... }}`.",
+                    relative_package_file.display()
+                ))?;
+                continue;
+            }
+
+            if !attribute_info.is_derivation {
+                error_writer.write(&format!(
+                    "pkgs.{package_name}: This attribute defined by {} is not a derivation",
+                    relative_package_file.display()
+                ))?;
+            }
+        } else {
+            error_writer.write(&format!(
+                "pkgs.{package_name}: This attribute is not defined but it should be defined automatically as {}",
+                relative_package_file.display()
+            ))?;
+            continue;
+        }
+    }
+    Ok(())
+}
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/main.rs b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/main.rs
new file mode 100644
index 000000000000..db22e524553b
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/main.rs
@@ -0,0 +1,171 @@
+mod eval;
+mod references;
+mod structure;
+mod utils;
+
+use anyhow::Context;
+use clap::Parser;
+use colored::Colorize;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+use structure::Nixpkgs;
+use utils::ErrorWriter;
+
+/// Program to check the validity of pkgs/by-name
+#[derive(Parser, Debug)]
+#[command(about)]
+struct Args {
+    /// Path to nixpkgs
+    nixpkgs: PathBuf,
+}
+
+fn main() -> ExitCode {
+    let args = Args::parse();
+    match check_nixpkgs(&args.nixpkgs, vec![], &mut io::stderr()) {
+        Ok(true) => {
+            eprintln!("{}", "Validated successfully".green());
+            ExitCode::SUCCESS
+        }
+        Ok(false) => {
+            eprintln!("{}", "Validation failed, see above errors".yellow());
+            ExitCode::from(1)
+        }
+        Err(e) => {
+            eprintln!("{} {:#}", "I/O error: ".yellow(), e);
+            ExitCode::from(2)
+        }
+    }
+}
+
+/// Checks whether the pkgs/by-name structure in Nixpkgs is valid.
+///
+/// # Arguments
+/// - `nixpkgs_path`: The path to the Nixpkgs to check
+/// - `eval_accessible_paths`:
+///   Extra paths that need to be accessible to evaluate Nixpkgs using `restrict-eval`.
+///   This is used to allow the tests to access the mock-nixpkgs.nix file
+/// - `error_writer`: An `io::Write` value to write validation errors to, if any.
+///
+/// # Return value
+/// - `Err(e)` if an I/O-related error `e` occurred.
+/// - `Ok(false)` if the structure is invalid, all the structural errors have been written to `error_writer`.
+/// - `Ok(true)` if the structure is valid, nothing will have been written to `error_writer`.
+pub fn check_nixpkgs<W: io::Write>(
+    nixpkgs_path: &Path,
+    eval_accessible_paths: Vec<&Path>,
+    error_writer: &mut W,
+) -> anyhow::Result<bool> {
+    let nixpkgs_path = nixpkgs_path.canonicalize().context(format!(
+        "Nixpkgs path {} could not be resolved",
+        nixpkgs_path.display()
+    ))?;
+
+    // Wraps the error_writer to print everything in red, and tracks whether anything was printed
+    // at all. Later used to figure out if the structure was valid or not.
+    let mut error_writer = ErrorWriter::new(error_writer);
+
+    if !nixpkgs_path.join(structure::BASE_SUBPATH).exists() {
+        eprintln!(
+            "Given Nixpkgs path does not contain a {} subdirectory, no check necessary.",
+            structure::BASE_SUBPATH
+        );
+    } else {
+        let nixpkgs = Nixpkgs::new(&nixpkgs_path, &mut error_writer)?;
+
+        if error_writer.empty {
+            // Only if we could successfully parse the structure, we do the semantic checks
+            eval::check_values(&mut error_writer, &nixpkgs, eval_accessible_paths)?;
+            references::check_references(&mut error_writer, &nixpkgs)?;
+        }
+    }
+    Ok(error_writer.empty)
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::check_nixpkgs;
+    use crate::structure;
+    use anyhow::Context;
+    use std::env;
+    use std::fs;
+    use std::path::Path;
+    use tempfile::{tempdir, tempdir_in};
+
+    #[test]
+    fn tests_dir() -> anyhow::Result<()> {
+        for entry in Path::new("tests").read_dir()? {
+            let entry = entry?;
+            let path = entry.path();
+            let name = entry.file_name().to_string_lossy().into_owned();
+
+            if !path.is_dir() {
+                continue;
+            }
+
+            let expected_errors =
+                fs::read_to_string(path.join("expected")).unwrap_or(String::new());
+
+            test_nixpkgs(&name, &path, &expected_errors)?;
+        }
+        Ok(())
+    }
+
+    // We cannot check case-conflicting files into Nixpkgs (the channel would fail to
+    // build), so we generate the case-conflicting file instead.
+    #[test]
+    fn test_case_sensitive() -> anyhow::Result<()> {
+        let temp_nixpkgs = tempdir()?;
+        let path = temp_nixpkgs.path();
+
+        if is_case_insensitive_fs(&path)? {
+            eprintln!("We're on a case-insensitive filesystem, skipping case-sensitivity test");
+            return Ok(());
+        }
+
+        let base = path.join(structure::BASE_SUBPATH);
+
+        fs::create_dir_all(base.join("fo/foo"))?;
+        fs::write(base.join("fo/foo/package.nix"), "{ someDrv }: someDrv")?;
+
+        fs::create_dir_all(base.join("fo/foO"))?;
+        fs::write(base.join("fo/foO/package.nix"), "{ someDrv }: someDrv")?;
+
+        test_nixpkgs(
+            "case_sensitive",
+            &path,
+            "pkgs/by-name/fo: Duplicate case-sensitive package directories \"foO\" and \"foo\".\n",
+        )?;
+
+        Ok(())
+    }
+
+    fn test_nixpkgs(name: &str, path: &Path, expected_errors: &str) -> anyhow::Result<()> {
+        let extra_nix_path = Path::new("tests/mock-nixpkgs.nix");
+
+        // We don't want coloring to mess up the tests
+        env::set_var("NO_COLOR", "1");
+
+        let mut writer = vec![];
+        check_nixpkgs(&path, vec![&extra_nix_path], &mut writer)
+            .context(format!("Failed test case {name}"))?;
+
+        let actual_errors = String::from_utf8_lossy(&writer);
+
+        if actual_errors != expected_errors {
+            panic!(
+                "Failed test case {name}, expected these errors:\n\n{}\n\nbut got these:\n\n{}",
+                expected_errors, actual_errors
+            );
+        }
+        Ok(())
+    }
+
+    /// Check whether a path is in a case-insensitive filesystem
+    fn is_case_insensitive_fs(path: &Path) -> anyhow::Result<bool> {
+        let dir = tempdir_in(path)?;
+        let base = dir.path();
+        fs::write(base.join("aaa"), "")?;
+        Ok(base.join("AAA").exists())
+    }
+}
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/references.rs b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/references.rs
new file mode 100644
index 000000000000..16dc60729c42
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/references.rs
@@ -0,0 +1,184 @@
+use crate::structure::Nixpkgs;
+use crate::utils;
+use crate::utils::{ErrorWriter, LineIndex};
+
+use anyhow::Context;
+use rnix::{Root, SyntaxKind::NODE_PATH};
+use std::ffi::OsStr;
+use std::fs::read_to_string;
+use std::io;
+use std::path::{Path, PathBuf};
+
+/// Small helper so we don't need to pass in the same arguments to all functions
+struct PackageContext<'a, W: io::Write> {
+    error_writer: &'a mut ErrorWriter<W>,
+    /// The package directory relative to Nixpkgs, such as `pkgs/by-name/fo/foo`
+    relative_package_dir: &'a PathBuf,
+    /// The absolute package directory
+    absolute_package_dir: &'a PathBuf,
+}
+
+/// Check that every package directory in pkgs/by-name doesn't link to outside that directory.
+/// Both symlinks and Nix path expressions are checked.
+pub fn check_references<W: io::Write>(
+    error_writer: &mut ErrorWriter<W>,
+    nixpkgs: &Nixpkgs,
+) -> anyhow::Result<()> {
+    // Check the directories for each package separately
+    for package_name in &nixpkgs.package_names {
+        let relative_package_dir = Nixpkgs::relative_dir_for_package(package_name);
+        let mut context = PackageContext {
+            error_writer,
+            relative_package_dir: &relative_package_dir,
+            absolute_package_dir: &nixpkgs.path.join(&relative_package_dir),
+        };
+
+        // The empty argument here is the subpath under the package directory to check
+        // An empty one means the package directory itself
+        check_path(&mut context, Path::new("")).context(format!(
+            "While checking the references in package directory {}",
+            relative_package_dir.display()
+        ))?;
+    }
+    Ok(())
+}
+
+/// Checks for a specific path to not have references outside
+fn check_path<W: io::Write>(context: &mut PackageContext<W>, subpath: &Path) -> anyhow::Result<()> {
+    let path = context.absolute_package_dir.join(subpath);
+
+    if path.is_symlink() {
+        // Check whether the symlink resolves to outside the package directory
+        match path.canonicalize() {
+            Ok(target) => {
+                // No need to handle the case of it being inside the directory, since we scan through the
+                // entire directory recursively anyways
+                if let Err(_prefix_error) = target.strip_prefix(context.absolute_package_dir) {
+                    context.error_writer.write(&format!(
+                        "{}: Path {} is a symlink pointing to a path outside the directory of that package.",
+                        context.relative_package_dir.display(),
+                        subpath.display(),
+                    ))?;
+                }
+            }
+            Err(e) => {
+                context.error_writer.write(&format!(
+                    "{}: Path {} is a symlink which cannot be resolved: {e}.",
+                    context.relative_package_dir.display(),
+                    subpath.display(),
+                ))?;
+            }
+        }
+    } else if path.is_dir() {
+        // Recursively check each entry
+        for entry in utils::read_dir_sorted(&path)? {
+            let entry_subpath = subpath.join(entry.file_name());
+            check_path(context, &entry_subpath)
+                .context(format!("Error while recursing into {}", subpath.display()))?
+        }
+    } else if path.is_file() {
+        // Only check Nix files
+        if let Some(ext) = path.extension() {
+            if ext == OsStr::new("nix") {
+                check_nix_file(context, subpath).context(format!(
+                    "Error while checking Nix file {}",
+                    subpath.display()
+                ))?
+            }
+        }
+    } else {
+        // This should never happen, git doesn't support other file types
+        anyhow::bail!("Unsupported file type for path {}", subpath.display());
+    }
+    Ok(())
+}
+
+/// Check whether a nix file contains path expression references pointing outside the package
+/// directory
+fn check_nix_file<W: io::Write>(
+    context: &mut PackageContext<W>,
+    subpath: &Path,
+) -> anyhow::Result<()> {
+    let path = context.absolute_package_dir.join(subpath);
+    let parent_dir = path.parent().context(format!(
+        "Could not get parent of path {}",
+        subpath.display()
+    ))?;
+
+    let contents =
+        read_to_string(&path).context(format!("Could not read file {}", subpath.display()))?;
+
+    let root = Root::parse(&contents);
+    if let Some(error) = root.errors().first() {
+        context.error_writer.write(&format!(
+            "{}: File {} could not be parsed by rnix: {}",
+            context.relative_package_dir.display(),
+            subpath.display(),
+            error,
+        ))?;
+        return Ok(());
+    }
+
+    let line_index = LineIndex::new(&contents);
+
+    for node in root.syntax().descendants() {
+        // We're only interested in Path expressions
+        if node.kind() != NODE_PATH {
+            continue;
+        }
+
+        let text = node.text().to_string();
+        let line = line_index.line(node.text_range().start().into());
+
+        // Filters out ./foo/${bar}/baz
+        // TODO: We can just check ./foo
+        if node.children().count() != 0 {
+            context.error_writer.write(&format!(
+                "{}: File {} at line {line} contains the path expression \"{}\", which is not yet supported and may point outside the directory of that package.",
+                context.relative_package_dir.display(),
+                subpath.display(),
+                text
+            ))?;
+            continue;
+        }
+
+        // Filters out search paths like <nixpkgs>
+        if text.starts_with('<') {
+            context.error_writer.write(&format!(
+                "{}: File {} at line {line} contains the nix search path expression \"{}\" which may point outside the directory of that package.",
+                context.relative_package_dir.display(),
+                subpath.display(),
+                text
+            ))?;
+            continue;
+        }
+
+        // Resolves the reference of the Nix path
+        // turning `../baz` inside `/foo/bar/default.nix` to `/foo/baz`
+        match parent_dir.join(Path::new(&text)).canonicalize() {
+            Ok(target) => {
+                // Then checking if it's still in the package directory
+                // No need to handle the case of it being inside the directory, since we scan through the
+                // entire directory recursively anyways
+                if let Err(_prefix_error) = target.strip_prefix(context.absolute_package_dir) {
+                    context.error_writer.write(&format!(
+                        "{}: File {} at line {line} contains the path expression \"{}\" which may point outside the directory of that package.",
+                        context.relative_package_dir.display(),
+                        subpath.display(),
+                        text,
+                    ))?;
+                }
+            }
+            Err(e) => {
+                context.error_writer.write(&format!(
+                    "{}: File {} at line {line} contains the path expression \"{}\" which cannot be resolved: {e}.",
+                    context.relative_package_dir.display(),
+                    subpath.display(),
+                    text,
+                ))?;
+            }
+        };
+    }
+
+    Ok(())
+}
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/structure.rs b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/structure.rs
new file mode 100644
index 000000000000..ea80128e487a
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/structure.rs
@@ -0,0 +1,152 @@
+use crate::utils;
+use crate::utils::ErrorWriter;
+use lazy_static::lazy_static;
+use regex::Regex;
+use std::collections::HashMap;
+use std::io;
+use std::path::{Path, PathBuf};
+
+pub const BASE_SUBPATH: &str = "pkgs/by-name";
+pub const PACKAGE_NIX_FILENAME: &str = "package.nix";
+
+lazy_static! {
+    static ref SHARD_NAME_REGEX: Regex = Regex::new(r"^[a-z0-9_-]{1,2}$").unwrap();
+    static ref PACKAGE_NAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
+}
+
+/// Contains information about the structure of the pkgs/by-name directory of a Nixpkgs
+pub struct Nixpkgs {
+    /// The path to nixpkgs
+    pub path: PathBuf,
+    /// The names of all packages declared in pkgs/by-name
+    pub package_names: Vec<String>,
+}
+
+impl Nixpkgs {
+    // Some utility functions for the basic structure
+
+    pub fn shard_for_package(package_name: &str) -> String {
+        package_name.to_lowercase().chars().take(2).collect()
+    }
+
+    pub fn relative_dir_for_shard(shard_name: &str) -> PathBuf {
+        PathBuf::from(format!("{BASE_SUBPATH}/{shard_name}"))
+    }
+
+    pub fn relative_dir_for_package(package_name: &str) -> PathBuf {
+        Nixpkgs::relative_dir_for_shard(&Nixpkgs::shard_for_package(package_name))
+            .join(package_name)
+    }
+
+    pub fn relative_file_for_package(package_name: &str) -> PathBuf {
+        Nixpkgs::relative_dir_for_package(package_name).join(PACKAGE_NIX_FILENAME)
+    }
+}
+
+impl Nixpkgs {
+    /// Read the structure of a Nixpkgs directory, displaying errors on the writer.
+    /// May return early with I/O errors.
+    pub fn new<W: io::Write>(
+        path: &Path,
+        error_writer: &mut ErrorWriter<W>,
+    ) -> anyhow::Result<Nixpkgs> {
+        let base_dir = path.join(BASE_SUBPATH);
+
+        let mut package_names = Vec::new();
+
+        for shard_entry in utils::read_dir_sorted(&base_dir)? {
+            let shard_path = shard_entry.path();
+            let shard_name = shard_entry.file_name().to_string_lossy().into_owned();
+            let relative_shard_path = Nixpkgs::relative_dir_for_shard(&shard_name);
+
+            if shard_name == "README.md" {
+                // README.md is allowed to be a file and not checked
+                continue;
+            }
+
+            if !shard_path.is_dir() {
+                error_writer.write(&format!(
+                    "{}: This is a file, but it should be a directory.",
+                    relative_shard_path.display(),
+                ))?;
+                // we can't check for any other errors if it's a file, since there's no subdirectories to check
+                continue;
+            }
+
+            let shard_name_valid = SHARD_NAME_REGEX.is_match(&shard_name);
+            if !shard_name_valid {
+                error_writer.write(&format!(
+                    "{}: Invalid directory name \"{shard_name}\", must be at most 2 ASCII characters consisting of a-z, 0-9, \"-\" or \"_\".",
+                    relative_shard_path.display()
+                ))?;
+            }
+
+            let mut unique_package_names = HashMap::new();
+
+            for package_entry in utils::read_dir_sorted(&shard_path)? {
+                let package_path = package_entry.path();
+                let package_name = package_entry.file_name().to_string_lossy().into_owned();
+                let relative_package_dir =
+                    PathBuf::from(format!("{BASE_SUBPATH}/{shard_name}/{package_name}"));
+
+                if !package_path.is_dir() {
+                    error_writer.write(&format!(
+                        "{}: This path is a file, but it should be a directory.",
+                        relative_package_dir.display(),
+                    ))?;
+                    continue;
+                }
+
+                if let Some(duplicate_package_name) =
+                    unique_package_names.insert(package_name.to_lowercase(), package_name.clone())
+                {
+                    error_writer.write(&format!(
+                        "{}: Duplicate case-sensitive package directories \"{duplicate_package_name}\" and \"{package_name}\".",
+                        relative_shard_path.display(),
+                    ))?;
+                }
+
+                let package_name_valid = PACKAGE_NAME_REGEX.is_match(&package_name);
+                if !package_name_valid {
+                    error_writer.write(&format!(
+                        "{}: Invalid package directory name \"{package_name}\", must be ASCII characters consisting of a-z, A-Z, 0-9, \"-\" or \"_\".",
+                        relative_package_dir.display(),
+                    ))?;
+                }
+
+                let correct_relative_package_dir = Nixpkgs::relative_dir_for_package(&package_name);
+                if relative_package_dir != correct_relative_package_dir {
+                    // Only show this error if we have a valid shard and package name
+                    // Because if one of those is wrong, you should fix that first
+                    if shard_name_valid && package_name_valid {
+                        error_writer.write(&format!(
+                            "{}: Incorrect directory location, should be {} instead.",
+                            relative_package_dir.display(),
+                            correct_relative_package_dir.display(),
+                        ))?;
+                    }
+                }
+
+                let package_nix_path = package_path.join(PACKAGE_NIX_FILENAME);
+                if !package_nix_path.exists() {
+                    error_writer.write(&format!(
+                        "{}: Missing required \"{PACKAGE_NIX_FILENAME}\" file.",
+                        relative_package_dir.display(),
+                    ))?;
+                } else if package_nix_path.is_dir() {
+                    error_writer.write(&format!(
+                        "{}: \"{PACKAGE_NIX_FILENAME}\" must be a file.",
+                        relative_package_dir.display(),
+                    ))?;
+                }
+
+                package_names.push(package_name.clone());
+            }
+        }
+
+        Ok(Nixpkgs {
+            path: path.to_owned(),
+            package_names,
+        })
+    }
+}
diff --git a/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/utils.rs b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/utils.rs
new file mode 100644
index 000000000000..325c736eca98
--- /dev/null
+++ b/nixpkgs/pkgs/test/nixpkgs-check-by-name/src/utils.rs
@@ -0,0 +1,72 @@
+use anyhow::Context;
+use colored::Colorize;
+use std::fs;
+use std::io;
+use std::path::Path;
+
+/// Deterministic file listing so that tests are reproducible
+pub fn read_dir_sorted(base_dir: &Path) -> anyhow::Result<Vec<fs::DirEntry>> {
+    let listing = base_dir
+        .read_dir()
+        .context(format!("Could not list directory {}", base_dir.display()))?;
+    let mut shard_entries = listing
+        .collect::<io::Result<Vec<_>>>()
+        .context(format!("Could not list directory {}", base_dir.display()))?;
+    shard_entries.sort_by_key(|entry| entry.file_name());
+    Ok(shard_entries)
+}
+
+/// A simple utility for calculating the line for a string offset.
+/// This doesn't do any Unicode handling, though that probably doesn't matter
+/// because newlines can't split up Unicode characters. Also this is only used
+/// for error reporting
+pub struct LineIndex {
+    /// Stores the indices of newlines
+    newlines: Vec<usize>,
+}
+
+impl LineIndex {
+    pub fn new(s: &str) -> LineIndex {
+        let mut newlines = vec![];
+        let mut index = 0;
+        // Iterates over all newline-split parts of the string, adding the index of the newline to
+        // the vec
+        for split in s.split_inclusive('\n') {
+            index += split.len();
+            newlines.push(index);
+        }
+        LineIndex { newlines }
+    }
+
+    /// Returns the line number for a string index
+    pub fn line(&self, index: usize) -> usize {
+        match self.newlines.binary_search(&index) {
+            // +1 because lines are 1-indexed
+            Ok(x) => x + 1,
+            Err(x) => x + 1,
+        }
+    }
+}
+
+/// A small wrapper around a generic io::Write specifically for errors:
+/// - Print everything in red to signal it's an error
+/// - Keep track of whether anything was printed at all, so that
+///   it can be queried whether any errors were encountered at all
+pub struct ErrorWriter<W> {
+    pub writer: W,
+    pub empty: bool,
+}
+
+impl<W: io::Write> ErrorWriter<W> {
+    pub fn new(writer: W) -> ErrorWriter<W> {
+        ErrorWriter {
+            writer,
+            empty: true,
+        }
+    }
+
+    pub fn write(&mut self, string: &str) -> io::Result<()> {
+        self.empty = false;
+        writeln!(self.writer, "{}", string.red())
+    }
+}