diff --git a/Cargo.lock b/Cargo.lock index 707b913..78de2b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,7 +512,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flake-checker" -version = "0.2.14" +version = "0.2.15" dependencies = [ "cel", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d978f69..888d09f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flake-checker" -version = "0.2.14" +version = "0.2.15" edition = "2024" [workspace] diff --git a/src/flake.rs b/src/flake.rs index 53310c3..0ee19e0 100644 --- a/src/flake.rs +++ b/src/flake.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use crate::FlakeCheckerError; use crate::issue::{Disallowed, Issue, IssueKind, NonUpstream, Outdated}; +use crate::secure_packages::is_secure_packages_url; use chrono::{Duration, Utc}; use parse_flake_lock::{FlakeLock, Node}; @@ -86,20 +87,26 @@ pub(crate) fn check_flake_lock( let deps = nixpkgs_deps(flake_lock, &config.nixpkgs_keys)?; for (name, node) in deps { - let (git_ref, last_modified, owner) = match node { + let (git_ref, last_modified, owner, secure_packages) = match node { Node::Repo(repo) => ( repo.original.git_ref, Some(repo.locked.last_modified), Some(repo.original.owner), + false, ), - Node::Tarball(tarball) => (None, tarball.locked.last_modified, None), - _ => (None, None, None), + Node::Tarball(tarball) => { + let secure_packages = is_secure_packages_url(&tarball.original.url) + || is_secure_packages_url(&tarball.locked.url); + + (None, tarball.locked.last_modified, None, secure_packages) + } + _ => (None, None, None, false), }; - // Check if not explicitly supported + // Secure Packages flakes are exempt from the ref and owner checks, but not the age check if let Some(git_ref) = git_ref { // Check if not explicitly supported - if config.check_supported && !allowed_refs.contains(&git_ref) { + if config.check_supported && !secure_packages && !allowed_refs.contains(&git_ref) { issues.push(Issue { input: name.clone(), kind: IssueKind::Disallowed(Disallowed { @@ -125,7 +132,7 @@ pub(crate) fn check_flake_lock( if let Some(owner) = owner { // Check that the GitHub owner is NixOS - if config.check_owner && owner.to_lowercase() != "nixos" { + if config.check_owner && !secure_packages && owner.to_lowercase() != "nixos" { issues.push(Issue { input: name.clone(), kind: IssueKind::NonUpstream(NonUpstream { owner }), @@ -199,7 +206,7 @@ mod test { let ref_statuses: BTreeMap = serde_json::from_str(include_str!("../ref-statuses.json")).unwrap(); let allowed_refs = supported_refs(ref_statuses); - for n in 0..=7 { + for n in 0..=8 { let path = PathBuf::from(format!("tests/flake.clean.{n}.lock")); let flake_lock = FlakeLock::new(&path).unwrap(); let config = FlakeCheckConfig { @@ -255,6 +262,24 @@ mod test { }, ], ), + // Secure Packages is a FlakeHub-only product, so the GitHub form gets no exemption + ( + "flake.dirty.2.lock", + vec![ + Issue { + input: String::from("nixpkgs"), + kind: IssueKind::Disallowed(Disallowed { + reference: String::from("main"), + }), + }, + Issue { + input: String::from("nixpkgs"), + kind: IssueKind::NonUpstream(NonUpstream { + owner: String::from("DeterminateSystems"), + }), + }, + ], + ), ]; for (file, expected_issues) in cases { diff --git a/src/main.rs b/src/main.rs index 4cedde7..3b3661d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod condition; mod error; mod flake; mod issue; +mod secure_packages; mod summary; #[cfg(feature = "ref-statuses")] diff --git a/src/secure_packages.rs b/src/secure_packages.rs new file mode 100644 index 0000000..557e837 --- /dev/null +++ b/src/secure_packages.rs @@ -0,0 +1,129 @@ +//! Recognition of Determinate Secure Packages flakes as legitimate Nixpkgs inputs. +//! +//! These flakes are available only from FlakeHub, published under the `DeterminateSystems` org +//! with names beginning with `secure-packages` (`secure-packages-rolling`, +//! `secure-packages-26.05`, `secure-packages-rolling-fips`, `secure-packages-26.05-fips`, and so +//! on). + +const DETERMINATE_ORG: &str = "determinatesystems"; +const SECURE_PACKAGES_PREFIX: &str = "secure-packages"; +const FLAKEHUB_HOSTS: &[&str] = &["flakehub.com", "api.flakehub.com"]; + +/// Whether a tarball URL points to a Determinate Secure Packages flake on FlakeHub. Handles both +/// the user-supplied form (`https://flakehub.com/f/{org}/{name}/{version}`) and the locked form +/// (`https://api.flakehub.com/f/pinned/{org}/{name}/{version}/{uuid}/source.tar.gz`). +pub(crate) fn is_secure_packages_url(url: &str) -> bool { + let Some((_scheme, rest)) = url.split_once("://") else { + return false; + }; + + let mut segments = rest.split('/'); + + let Some(host) = segments.next() else { + return false; + }; + + if !FLAKEHUB_HOSTS.contains(&host.to_lowercase().as_str()) { + return false; + } + + if segments.next() != Some("f") { + return false; + } + + // Locked FlakeHub URLs insert a `pinned` segment before the org + let owner = match segments.next() { + Some("pinned") => segments.next(), + owner => owner, + }; + + let (Some(owner), Some(name)) = (owner, segments.next()) else { + return false; + }; + + is_secure_packages_flake(owner, name) +} + +fn is_secure_packages_flake(owner: &str, name: &str) -> bool { + owner.to_lowercase() == DETERMINATE_ORG && is_secure_packages_name(&name.to_lowercase()) +} + +fn is_secure_packages_name(name: &str) -> bool { + name == SECURE_PACKAGES_PREFIX || name.starts_with(&format!("{SECURE_PACKAGES_PREFIX}-")) +} + +#[cfg(test)] +mod test { + use super::{is_secure_packages_flake, is_secure_packages_url}; + + #[test] + fn secure_packages_flakes() { + let cases: Vec<(&str, &str, bool)> = vec![ + ("DeterminateSystems", "secure-packages-rolling", true), + ("DeterminateSystems", "secure-packages-rolling-fips", true), + ("DeterminateSystems", "secure-packages-25.11", true), + ("DeterminateSystems", "secure-packages-26.05", true), + ("DeterminateSystems", "secure-packages-26.05-fips", true), + // Channels that don't exist yet still match the prefix + ("DeterminateSystems", "secure-packages-26.11", true), + ("DeterminateSystems", "secure-packages", true), + ("determinatesystems", "secure-packages-rolling", true), + // Right name, wrong org + ("NotDeterminateSystems", "secure-packages-rolling", false), + ("someone-else", "secure-packages-rolling", false), + // Right org, wrong name + ("DeterminateSystems", "nixpkgs", false), + ("DeterminateSystems", "secure-packagesrolling", false), + ("DeterminateSystems", "not-secure-packages-rolling", false), + ]; + + for (owner, name, expected) in cases { + assert_eq!( + is_secure_packages_flake(owner, name), + expected, + "unexpected result for {owner}/{name}" + ); + } + } + + #[test] + fn secure_packages_urls() { + let cases: Vec<(&str, bool)> = vec![ + ( + "https://flakehub.com/f/DeterminateSystems/secure-packages-rolling/*", + true, + ), + ( + "https://flakehub.com/f/DeterminateSystems/secure-packages-26.05/0.1.tar.gz", + true, + ), + ( + "https://api.flakehub.com/f/pinned/DeterminateSystems/secure-packages-rolling/0.1.1%2Brev-6fb441f657e98f31024f19ca68b691c5b0c3fdd6/01978415-c996-7747-a25c-77ca3a4c5ed9/source.tar.gz", + true, + ), + // Right flake, wrong host + ( + "https://example.com/f/DeterminateSystems/secure-packages-rolling/*", + false, + ), + // Right host, wrong flake + ("https://flakehub.com/f/NixOS/nixpkgs/0.1", false), + ( + "https://flakehub.com/f/DeterminateSystems/easy-template/0", + false, + ), + // Not a FlakeHub URL at all + ("https://some-server.com/flake.tar.gz", false), + ("flakehub.com/f/DeterminateSystems/secure-packages", false), + ("https://flakehub.com/f/DeterminateSystems", false), + ]; + + for (url, expected) in cases { + assert_eq!( + is_secure_packages_url(url), + expected, + "unexpected result for {url}" + ); + } + } +} diff --git a/tests/flake.clean.8.lock b/tests/flake.clean.8.lock new file mode 100644 index 0000000..2b56b5e --- /dev/null +++ b/tests/flake.clean.8.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "revCount": 858, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/secure-packages-rolling/0.1.858%2Brev-10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b/019e3726-d9ea-7820-aece-59009301cab1/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/secure-packages-rolling/*" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/tests/flake.dirty.2.lock b/tests/flake.dirty.2.lock new file mode 100644 index 0000000..415efa9 --- /dev/null +++ b/tests/flake.dirty.2.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "DeterminateSystems", + "repo": "secure-packages-rolling-fips", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "DeterminateSystems", + "ref": "main", + "repo": "secure-packages-rolling-fips", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +}