kellnr-common 6.12.0

Kellnr is a self-hosted registry for Rust crates with support for rustdocs and crates.io caching.
Documentation
use std::collections::{BTreeMap, HashMap};

use serde::{Deserialize, Serialize};
use thiserror::Error;
use url::Url;

use crate::index_metadata::IndexDep;

// The Metadata struct defined here is the one send by Cargo to the registry.
// It is different to the one saved in the index!
// See: https://doc.rust-lang.org/cargo/reference/registries.html#publish
// Crates.io implementation: https://github.com/rust-lang/crates.io/blob/5c7070829418f72639dd0b91993eaf91e516416b/src/views/krate_publish.rs

#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct PublishMetadata {
    // The name of the package.
    pub name: String,
    // The version of the package being published.
    pub vers: String,
    // Array of direct dependencies of the package.
    pub deps: Vec<RegistryDep>,
    // Set of features defined for the package.
    // Each feature maps to an array of features or dependencies it enables.
    // Cargo does not impose limitations on feature names, but crates.io
    // requires alphanumeric ASCII, `_` or `-` characters.
    pub features: BTreeMap<String, Vec<String>>,
    // List of strings of the authors.
    // May be empty.
    pub authors: Option<Vec<String>>,
    // Description field from the manifest.
    // May be null. crates.io requires at least some content.
    pub description: Option<String>,
    // String of the URL to the website for this package's documentation.
    // May be null.
    pub documentation: Option<String>,
    // String of the URL to the website for this package's home page.
    // May be null.
    pub homepage: Option<String>,
    // String of the content of the README file.
    // May be null.
    pub readme: Option<String>,
    // String of a relative path to a README file in the crate.
    // May be null.
    pub readme_file: Option<String>,
    // Array of strings of keywords for the package.
    #[serde(default)]
    pub keywords: Vec<String>,
    // Array of strings of categories for the package.
    #[serde(default)]
    pub categories: Vec<String>,
    // String of the license for the package.
    // May be null. crates.io requires either `license` or `license_file` to be set.
    pub license: Option<String>,
    // String of a relative path to a license file in the crate.
    // May be null.
    pub license_file: Option<String>,
    // String of the URL to the website for the source repository of this package.
    // May be null.
    pub repository: Option<String>,
    // Optional object of "status" badges. Each value is an object of
    // arbitrary string to string mappings.
    // crates.io has special interpretation of the format of the badges.
    pub badges: Option<HashMap<String, Option<HashMap<String, String>>>>,
    // The `links` string value from the package's manifest, or null if not
    // specified. This field is optional and defaults to null.
    #[serde(default)]
    pub links: Option<String>,
    // The minimal supported Rust version (optional)
    // This must be a valid version requirement without an operator (e.g. no `=`)
    #[serde(rename(serialize = "rust-version"))]
    pub rust_version: Option<String>,
}

#[derive(Debug, PartialEq, Eq, Error)]
#[error("URL for field `{field}` must begin with http:// or https:// (url: {url})")]
pub struct InvalidUrl {
    pub field: &'static str,
    pub url: String,
}

impl PublishMetadata {
    pub fn minimal(name: &str, vers: &str) -> Self {
        Self {
            name: name.to_string(),
            vers: vers.to_string(),
            ..Default::default()
        }
    }

    /// Ensure the link fields are absent, empty or valid http(s) URLs, so they
    /// are safe to render as links.
    pub fn validate_urls(&self) -> Result<(), InvalidUrl> {
        [
            ("documentation", &self.documentation),
            ("homepage", &self.homepage),
            ("repository", &self.repository),
        ]
        .into_iter()
        .try_for_each(|(field, value)| match value.as_deref() {
            None | Some("") => Ok(()),
            Some(url) if is_http_url(url) => Ok(()),
            Some(url) => Err(InvalidUrl {
                field,
                url: url.to_string(),
            }),
        })
    }
}

fn is_http_url(url: &str) -> bool {
    Url::parse(url).is_ok_and(|u| matches!(u.scheme(), "http" | "https"))
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct RegistryDep {
    // Name of the dependency.
    // If the dependency is renamed from the original package name,
    // this is the original name. The new package name is stored in
    // the `explicit_name_in_toml` field.
    pub name: String,
    // The semver requirement for this dependency.
    pub version_req: String,
    // Array of features (as strings) enabled for this dependency.
    pub features: Option<Vec<String>>,
    // Boolean of whether or not this is an optional dependency.
    pub optional: bool,
    // Boolean of whether or not default features are enabled.
    pub default_features: bool,
    // The target platform for the dependency.
    // null if not a target dependency.
    // Otherwise, a string such as "cfg(windows)".
    pub target: Option<String>,
    // The dependency kind.
    // "dev", "build", or "normal".
    pub kind: Option<String>,
    // The URL of the index of the registry where this dependency is
    // from as a string. If not specified or null, it is assumed the
    // dependency is in the current registry.
    pub registry: Option<String>,
    // If the dependency is renamed, this is a string of the new
    // package name. If not specified or null, this dependency is not
    // renamed.
    pub explicit_name_in_toml: Option<String>,
}

impl From<IndexDep> for RegistryDep {
    fn from(dep: IndexDep) -> Self {
        RegistryDep {
            name: match dep.package {
                Some(ref package) => package.clone(),
                None => dep.name.clone(),
            },
            version_req: dep.req,
            features: Some(dep.features),
            optional: dep.optional,
            default_features: dep.default_features,
            target: dep.target,
            kind: dep.kind.map(|k| k.to_string()),
            registry: dep.registry,
            explicit_name_in_toml: match dep.package {
                Some(_) => Some(dep.name),
                None => None,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn with_urls(url: &str) -> PublishMetadata {
        PublishMetadata {
            documentation: Some(url.to_string()),
            homepage: Some(url.to_string()),
            repository: Some(url.to_string()),
            ..PublishMetadata::minimal("test", "0.1.0")
        }
    }

    #[test]
    fn validate_urls_accepts_missing_and_empty() {
        assert!(
            PublishMetadata::minimal("test", "0.1.0")
                .validate_urls()
                .is_ok()
        );
        assert!(with_urls("").validate_urls().is_ok());
    }

    #[test]
    fn validate_urls_accepts_http_and_https() {
        assert!(
            with_urls("https://example.com/docs")
                .validate_urls()
                .is_ok()
        );
        assert!(with_urls("http://example.com").validate_urls().is_ok());
        assert!(with_urls("HTTPS://EXAMPLE.COM").validate_urls().is_ok());
    }

    #[test]
    fn validate_urls_rejects_other_schemes_and_garbage() {
        for url in [
            "javascript:alert(1)",
            "JavaScript:alert(1)",
            "data:text/html,alert",
            "ftp://example.com",
            "example.com",
            "//example.com",
        ] {
            assert!(with_urls(url).validate_urls().is_err(), "accepted {url}");
        }
    }

    #[test]
    fn validate_urls_reports_the_offending_field() {
        let metadata = PublishMetadata {
            repository: Some("javascript:alert(1)".to_string()),
            ..PublishMetadata::minimal("test", "0.1.0")
        };

        let err = metadata.validate_urls().unwrap_err();

        assert_eq!("repository", err.field);
        assert_eq!(
            "URL for field `repository` must begin with http:// or https:// (url: javascript:alert(1))",
            err.to_string()
        );
    }
}