polyvoice 0.18.0

Speaker diarization for Rust — who spoke when. ONNX path optional: default features are empty (ort-free BYO-embedder core); enable onnx for Silero VAD, WeSpeaker embeddings, and Pyannote segmentation.
Documentation
//! Minisign signature verification for model artifacts.
//!
//! Uses `minisign-verify` (zero dependencies, pure Rust) to stream-verify
//! downloaded ONNX files against an embedded project public key.

use minisign_verify::{PublicKey, Signature};
use std::io::{BufReader, Read};
use std::path::Path;

/// The base64 public key baked into the binary at compile time.
pub(crate) const SIGNING_PUBKEY_BASE64: &str = include_str!("signing.pub.base64");

/// Errors from the signature verification step.
#[derive(Debug, thiserror::Error)]
pub enum SignatureError {
    #[error("invalid public key: {0}")]
    BadPublicKey(String),
    #[error("invalid signature text: {0}")]
    BadSignature(String),
    #[error("signature verification failed: {0}")]
    VerificationFailed(String),
    #[error("io error reading {path}: {source}")]
    Io {
        path: std::path::PathBuf,
        #[source]
        source: std::io::Error,
    },
}

/// { !sig_text.is_empty() }
/// `pub fn verify_minisign(path: &Path, sig_text: &str) -> Result<(), SignatureError>`
/// { true }
/// Verify `path` against a Minisign signature string (the raw `.minisig` text).
///
/// Streams the file in 64 KiB chunks; does not load the whole model into memory.
pub fn verify_minisign(path: &Path, sig_text: &str) -> Result<(), SignatureError> {
    let public_key = PublicKey::from_base64(SIGNING_PUBKEY_BASE64)
        .map_err(|e| SignatureError::BadPublicKey(format!("{e:?}")))?;

    let signature =
        Signature::decode(sig_text).map_err(|e| SignatureError::BadSignature(format!("{e:?}")))?;

    let mut verifier = public_key
        .verify_stream(&signature)
        .map_err(|e| SignatureError::VerificationFailed(format!("{e:?}")))?;

    let file = std::fs::File::open(path).map_err(|source| SignatureError::Io {
        path: path.to_path_buf(),
        source,
    })?;
    let mut reader = BufReader::new(file);
    let mut buf = [0u8; 64 * 1024];

    loop {
        let n = reader.read(&mut buf).map_err(|source| SignatureError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        if n == 0 {
            break;
        }
        verifier.update(&buf[..n]);
    }

    verifier
        .finalize()
        .map_err(|e| SignatureError::VerificationFailed(format!("{e:?}")))?;

    Ok(())
}

#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Helper: generate a temporary file with known content and sign it with the
    /// project's signing key, returning (temp_dir, path, signature_text).
    /// The TempDir must be kept alive so the file isn't deleted.
    /// Returns `None` when `minisign` CLI or `models/signing.key` are missing
    /// so tests gracefully skip on CI runners that lack them.
    fn sign_temp_file(content: &[u8]) -> Option<(TempDir, std::path::PathBuf, String)> {
        if std::process::Command::new("minisign")
            .arg("--version")
            .output()
            .is_err()
        {
            return None;
        }
        if !std::path::Path::new("models/signing.key").exists() {
            return None;
        }

        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test.bin");
        fs::write(&path, content).unwrap();

        let sig_path = dir.path().join("test.bin.minisig");
        let status = std::process::Command::new("minisign")
            .args([
                "-S",
                "-s",
                "models/signing.key",
                "-m",
                path.to_str().unwrap(),
                "-x",
                sig_path.to_str().unwrap(),
                "-t",
                "test signature",
            ])
            .status()
            .expect("minisign spawn failed");
        assert!(status.success(), "minisign signing failed");

        let sig_text = fs::read_to_string(&sig_path).expect("signature file must exist");
        Some((dir, path, sig_text))
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn valid_signature_passes() {
        let Some((_dir, path, sig_text)) = sign_temp_file(b"polyvoice test content") else {
            return;
        };
        verify_minisign(&path, &sig_text).expect("valid signature must verify");
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn tampered_signature_fails() {
        let Some((_dir, path, mut sig_text)) = sign_temp_file(b"polyvoice test content") else {
            return;
        };
        // Corrupt one character in the base64 signature line (second line).
        let lines: Vec<&str> = sig_text.lines().collect();
        assert!(lines.len() >= 2);
        let mut chars: Vec<char> = lines[1].chars().collect();
        if chars[0] == 'A' {
            chars[0] = 'B';
        } else {
            chars[0] = 'A';
        }
        let corrupted: String = chars.into_iter().collect();
        sig_text = sig_text
            .lines()
            .enumerate()
            .map(|(i, l)| if i == 1 { &corrupted[..] } else { l })
            .collect::<Vec<_>>()
            .join("\n");
        let err = verify_minisign(&path, &sig_text).expect_err("tampered signature must fail");
        let msg = format!("{err}").to_lowercase();
        assert!(
            msg.contains("verification failed")
                || msg.contains("bad signature")
                || msg.contains("invalid signature text"),
            "error should indicate signature problem, got: {err}"
        );
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn tampered_file_fails() {
        let Some((_dir, path, sig_text)) = sign_temp_file(b"polyvoice test content") else {
            return;
        };
        // Mutate one byte in the file.
        let mut bytes = fs::read(&path).unwrap();
        bytes[0] = bytes[0].wrapping_add(1);
        fs::write(&path, &bytes).unwrap();
        let err = verify_minisign(&path, &sig_text).expect_err("tampered file must fail");
        assert!(
            format!("{err}")
                .to_lowercase()
                .contains("verification failed"),
            "error should indicate verification failure, got: {err}"
        );
    }

    #[test]
    fn malformed_signature_text_fails() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test.bin");
        fs::write(&path, b"irrelevant").unwrap();
        let err = verify_minisign(&path, "totally not a signature").expect_err("must fail");
        let msg = format!("{err}").to_lowercase();
        assert!(
            msg.contains("bad signature") || msg.contains("invalid signature text"),
            "error should indicate bad signature, got: {err}"
        );
    }

    #[test]
    fn empty_signature_text_fails() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test.bin");
        fs::write(&path, b"irrelevant").unwrap();
        let err = verify_minisign(&path, "").expect_err("must fail");
        let msg = format!("{err}").to_lowercase();
        assert!(
            msg.contains("bad signature") || msg.contains("invalid signature text"),
            "error should indicate bad signature, got: {err}"
        );
    }

    /// Absolute path to a checked-in model fixture under `models/`.
    fn fixture_path(name: &str) -> std::path::PathBuf {
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("models")
            .join(name)
    }

    #[test]
    fn embedded_public_key_parses() {
        PublicKey::from_base64(SIGNING_PUBKEY_BASE64)
            .expect("the baked-in signing key must be valid base64");
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn checked_in_small_model_verifies_against_its_signature() {
        // The repo ships model signatures; the gitignored blob is present
        // only after a local model download, so skip when it is absent.
        let model = fixture_path("ecapa_tdnn_mel.onnx");
        if !model.exists() {
            eprintln!("skip: models/ecapa_tdnn_mel.onnx missing");
            return;
        }
        let sig = fs::read_to_string(fixture_path("ecapa_tdnn_mel.onnx.minisig"))
            .expect("signature fixture must be checked in");
        verify_minisign(&model, &sig)
            .expect("checked-in model must verify against its checked-in signature");
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn checked_in_large_model_verifies_across_many_chunks() {
        // 6 MB spans many 64 KiB streaming reads, exercising the update loop.
        let model = fixture_path("powerset_fp32.onnx");
        if !model.exists() {
            eprintln!("skip: models/powerset_fp32.onnx missing");
            return;
        }
        let sig = fs::read_to_string(fixture_path("powerset_fp32.onnx.minisig"))
            .expect("signature fixture must be checked in");
        verify_minisign(&model, &sig)
            .expect("checked-in model must verify against its checked-in signature");
    }

    #[test]
    #[cfg_attr(miri, ignore)]
    fn missing_file_is_io_error() {
        // A valid signature gets past decoding, so the file-open failure
        // surfaces as the Io variant rather than a signature error.
        let sig = fs::read_to_string(fixture_path("ecapa_tdnn_mel.onnx.minisig"))
            .expect("signature fixture must be checked in");
        let missing = fixture_path("definitely-not-present.onnx");
        let err = verify_minisign(&missing, &sig).expect_err("missing file must fail");
        assert!(matches!(err, SignatureError::Io { .. }), "got {err:?}");
    }

    #[test]
    fn signature_error_display_mentions_context() {
        let bad_key = SignatureError::BadPublicKey("k".to_owned());
        assert!(format!("{bad_key}").contains('k'));

        let bad_sig = SignatureError::BadSignature("s".to_owned());
        assert!(format!("{bad_sig}").contains('s'));

        let failed = SignatureError::VerificationFailed("v".to_owned());
        assert!(format!("{failed}").contains('v'));

        let io_err = SignatureError::Io {
            path: std::path::PathBuf::from("/tmp/missing.bin"),
            source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
        };
        assert!(format!("{io_err}").contains("/tmp/missing.bin"));
    }
}