guix-scheme 0.2.0

Read, edit, and generate Guix Scheme files (channels.scm, system config.scm) from Rust
Documentation
//! Field validation ported from libguix (`channels.rs`), same rules and
//! error wording so a migration is behavior-neutral.

use crate::{Channel, Error};

/// Invisible or direction-altering codepoints. The URL is shown to the
/// user verbatim before confirmation, so what they see must match what
/// `guix pull` resolves.
fn is_deceptive_unicode(c: char) -> bool {
    matches!(c,
        // Bidi controls (RTL override, embedding, isolates)
        '\u{200E}' | '\u{200F}'
        | '\u{202A}'..='\u{202E}'
        | '\u{2066}'..='\u{2069}'
        // Zero-width chars
        | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'
        // Line/paragraph separators
        | '\u{2028}' | '\u{2029}'
        // Soft hyphen, word joiner, invisible math operators
        | '\u{00AD}' | '\u{2060}' | '\u{2061}'..='\u{2064}'
    )
}

pub fn url(url: &str) -> Result<(), Error> {
    const SCHEMES: &[&str] = &["https://", "http://", "git://", "ssh://", "file://"];
    if !SCHEMES.iter().any(|s| url.starts_with(s)) {
        return Err(Error::Invalid {
            field: "url".into(),
            reason: "must start with https://, http://, git://, ssh:// or file://".into(),
        });
    }
    if url.len() > 2048 {
        return Err(Error::Invalid {
            field: "url".into(),
            reason: format!("length {} exceeds 2048", url.len()),
        });
    }
    for c in url.chars() {
        if c.is_control() {
            return Err(Error::Invalid {
                field: "url".into(),
                reason: format!("contains control char (U+{:04X})", c as u32),
            });
        }
        if is_deceptive_unicode(c) {
            return Err(Error::Invalid {
                field: "url".into(),
                reason: format!(
                    "contains deceptive Unicode codepoint (U+{:04X}) — bidi override, zero-width, or similar",
                    c as u32
                ),
            });
        }
    }
    Ok(())
}

pub fn branch(branch: &str) -> Result<(), Error> {
    if branch.is_empty() || branch.len() > 200 {
        return Err(Error::Invalid {
            field: "branch".into(),
            reason: format!("length {} out of range 1..=200", branch.len()),
        });
    }
    for c in branch.chars() {
        let ok = c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+');
        if !ok {
            return Err(Error::Invalid {
                field: "branch".into(),
                reason: format!("contains disallowed character `{c}`"),
            });
        }
    }
    Ok(())
}

pub fn introduction_commit(commit: &str) -> Result<(), Error> {
    if commit.len() < 7 || commit.len() > 64 {
        return Err(Error::Invalid {
            field: "introduction.commit".into(),
            reason: format!("length {} out of range 7..=64", commit.len()),
        });
    }
    if !commit.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err(Error::Invalid {
            field: "introduction.commit".into(),
            reason: "must be hex digits only".into(),
        });
    }
    Ok(())
}

pub fn introduction_fingerprint(fpr: &str) -> Result<(), Error> {
    if fpr.len() < 8 || fpr.len() > 128 {
        return Err(Error::Invalid {
            field: "introduction.fingerprint".into(),
            reason: format!("length {} out of range 8..=128", fpr.len()),
        });
    }
    for c in fpr.chars() {
        if !(c.is_ascii_hexdigit() || c == ' ') {
            return Err(Error::Invalid {
                field: "introduction.fingerprint".into(),
                reason: format!("contains disallowed character `{c}`"),
            });
        }
    }
    Ok(())
}

/// Channel names must shape into a Scheme symbol so the embedded
/// `(name 'foo)` form parses. Same rule as libguix `is_valid_channel_name`.
pub fn channel_name(name: &str) -> Result<(), Error> {
    let ok = !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.'));
    if ok {
        Ok(())
    } else {
        Err(Error::Invalid {
            field: "name".into(),
            reason: format!(
                "channel name `{name}` contains characters that aren't valid in a Scheme symbol"
            ),
        })
    }
}

pub fn channel_fields(ch: &Channel) -> Result<(), Error> {
    channel_name(&ch.name)?;
    url(&ch.url)?;
    if let Some(b) = &ch.branch {
        branch(b)?;
    }
    if let Some(c) = &ch.introduction_commit {
        introduction_commit(c)?;
    }
    if let Some(f) = &ch.introduction_fingerprint {
        introduction_fingerprint(f)?;
    }
    Ok(())
}