guix-scheme 0.2.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
use guix_scheme::validate;

#[test]
fn url_accepts_known_schemes() {
    for u in [
        "https://example.org/foo.git",
        "http://example.org/foo.git",
        "git://example.org/foo.git",
        "ssh://git@example.org/foo.git",
        "file:///srv/repos/foo.git",
    ] {
        assert!(validate::url(u).is_ok(), "{u}");
    }
}

#[test]
fn url_rejects_unknown_scheme_control_deceptive() {
    assert!(validate::url("javascript:alert(1)").is_err());
    assert!(validate::url("https://example.org/foo\nbar").is_err());
    assert!(validate::url("https://example.org/\u{202E}tig.git").is_err());
    assert!(validate::url(&format!("https://e.org/{}", "a".repeat(2048))).is_err());
}

#[test]
fn branch_charset_and_length() {
    assert!(validate::branch("feature/foo-1.2+x_y").is_ok());
    assert!(validate::branch("").is_err());
    assert!(validate::branch(&"a".repeat(201)).is_err());
    assert!(validate::branch("no spaces").is_err());
}

#[test]
fn commit_and_fingerprint_rules() {
    assert!(validate::introduction_commit("9edb3f66fd807b096b48283debdcddccfea34bad").is_ok());
    assert!(validate::introduction_commit("abc").is_err()); // < 7
    assert!(validate::introduction_commit("xyz4567").is_err()); // non-hex
    assert!(validate::introduction_fingerprint(
        "BBB0 2DDF 2CEA F6A8 0D1D  E643 A2A0 6DF2 A33A 54FA"
    )
    .is_ok());
    assert!(validate::introduction_fingerprint("short").is_err());
}

#[test]
fn channel_name_accepts_scheme_safe() {
    assert!(validate::channel_name("good-name_1.2").is_ok());
    assert!(validate::channel_name("guix").is_ok());
    assert!(validate::channel_name("non+guix").is_ok());
}

#[test]
fn channel_name_rejects_pathological() {
    assert!(validate::channel_name("").is_err());
    assert!(validate::channel_name("bad name").is_err());
    assert!(validate::channel_name("nope;(drop)").is_err());
    assert!(validate::channel_name("with/slash").is_err());
}

/// Regression parity: pins guix-scheme's validators to libguix's rules
/// (verified against `guix-rs/libguix/src/channels.rs`) so the channels
/// migration stays behavior-neutral.
mod libguix_parity {
    use guix_scheme::{validate, Channel, Error};

    #[test]
    fn url_scheme_length_and_deceptive() {
        assert!(validate::url("https://git.example/x").is_ok());
        assert!(validate::url("ssh://h/x").is_ok());
        assert!(validate::url("file:///srv/x").is_ok());

        assert!(validate::url("ftp://x").is_err());
        assert!(validate::url("https://git.example/\u{202E}x").is_err());
        assert!(validate::url("https://git.example/\u{200B}x").is_err());
        assert!(validate::url("https://git.example/\u{7}x").is_err());
        let too_long = format!("https://e/{}", "a".repeat(2049 - "https://e/".len()));
        assert_eq!(too_long.len(), 2049);
        assert!(validate::url(&too_long).is_err());
    }

    // parity delta: libguix warns on http/git schemes; guix-scheme accepts
    // silently (no tracing). Acceptance itself is identical.
    #[test]
    fn insecure_schemes_accepted_silently() {
        assert!(validate::url("http://x/y").is_ok());
        assert!(validate::url("git://x/y").is_ok());
    }

    #[test]
    fn branch_charset_and_length() {
        assert!(validate::branch("main").is_ok());
        assert!(validate::branch("feature/x-1.2").is_ok());

        assert!(validate::branch("").is_err());
        assert!(validate::branch("has space").is_err());
        assert!(validate::branch(&"a".repeat(201)).is_err());
    }

    #[test]
    fn commit_hex_and_length() {
        assert!(validate::introduction_commit("9edb3f66fd807b096b48283debdcddccfea34bad").is_ok());
        assert!(validate::introduction_commit("abc").is_err());
        assert!(validate::introduction_commit("9edb3f66fd807b096b48283debdcddccfea34baz").is_err());
        assert!(validate::introduction_commit(&"a".repeat(65)).is_err());
    }

    #[test]
    fn fingerprint_hexspace_and_length() {
        assert!(validate::introduction_fingerprint("AB CD 12 34 56 78").is_ok());
        assert!(
            validate::introduction_fingerprint("9edb3f66fd807b096b48283debdcddccfea34bad").is_ok()
        );
        assert!(validate::introduction_fingerprint("short").is_err());
        assert!(validate::introduction_fingerprint("AB CD 12 3g 56 78").is_err());
    }

    #[test]
    fn channel_name_scheme_symbol() {
        assert!(validate::channel_name("nonguix").is_ok());
        assert!(validate::channel_name("my-channel.1").is_ok());

        assert!(validate::channel_name("").is_err());
        assert!(validate::channel_name("bad name").is_err());
        assert!(validate::channel_name("emoji-\u{1F600}").is_err());
    }

    fn field_of(err: Error) -> String {
        match err {
            Error::Invalid { field, .. } => field,
            other => panic!("expected Error::Invalid, got {other:?}"),
        }
    }

    fn valid_channel() -> Channel {
        Channel {
            name: "nonguix".into(),
            url: "https://gitlab.com/nonguix/nonguix.git".into(),
            branch: Some("master".into()),
            commit: None,
            introduction_commit: Some("897c1a470da759236cc11798f4e0a5f7d4d59fbc".into()),
            introduction_fingerprint: Some(
                "2A39 3FFF 68F4 EF7A 3D29  12AF 6F51 20A0 22FB B2D5".into(),
            ),
        }
    }

    #[test]
    fn channel_fields_ok_and_field_names() {
        assert!(validate::channel_fields(&valid_channel()).is_ok());

        let mut bad_url = valid_channel();
        bad_url.url = "ftp://x".into();
        assert_eq!(
            field_of(validate::channel_fields(&bad_url).unwrap_err()),
            "url"
        );

        let mut bad_name = valid_channel();
        bad_name.name = "bad name".into();
        assert_eq!(
            field_of(validate::channel_fields(&bad_name).unwrap_err()),
            "name"
        );

        let mut bad_branch = valid_channel();
        bad_branch.branch = Some("has space".into());
        assert_eq!(
            field_of(validate::channel_fields(&bad_branch).unwrap_err()),
            "branch"
        );

        let mut bad_commit = valid_channel();
        bad_commit.introduction_commit = Some("abc".into());
        assert_eq!(
            field_of(validate::channel_fields(&bad_commit).unwrap_err()),
            "introduction.commit"
        );

        let mut bad_fpr = valid_channel();
        bad_fpr.introduction_fingerprint = Some("short".into());
        assert_eq!(
            field_of(validate::channel_fields(&bad_fpr).unwrap_err()),
            "introduction.fingerprint"
        );
    }
}