sunox 0.0.10

Generate AI music from your terminal via direct Suno web workflows
use std::collections::HashSet;

use serde::Serialize;

use crate::auth::is_suno_auth_cookie_domain;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(in crate::captcha) struct CdpCookie {
    name: String,
    value: String,
    domain: String,
    path: String,
    secure: bool,
    http_only: bool,
    same_site: &'static str,
}

pub(super) fn add_minimal_cookies_from_header(
    cookie_header: &str,
    out: &mut Vec<CdpCookie>,
    seen: &mut HashSet<(String, String)>,
) {
    for part in cookie_header.split(';') {
        let Some((name, value)) = part.trim().split_once('=') else {
            continue;
        };
        add_minimal_cookie(name.trim(), value.trim(), ".suno.com", false, out, seen);
    }
}

pub(super) fn add_minimal_cookie(
    name: &str,
    value: &str,
    domain: &str,
    http_only: bool,
    out: &mut Vec<CdpCookie>,
    seen: &mut HashSet<(String, String)>,
) {
    if name.is_empty() || value.is_empty() || !is_captcha_cookie(name) {
        return;
    }

    if name == "__client" || name.starts_with("__client_") {
        push_cookie(out, seen, name, value, "auth.suno.com", true);
        push_cookie(out, seen, name, value, ".suno.com", true);
        return;
    }

    let cookie_domain = if is_suno_auth_cookie_domain(domain) {
        "auth.suno.com"
    } else {
        ".suno.com"
    };
    push_cookie(out, seen, name, value, cookie_domain, http_only);
}

pub(super) fn push_cookie(
    out: &mut Vec<CdpCookie>,
    seen: &mut HashSet<(String, String)>,
    name: &str,
    value: &str,
    domain: &str,
    http_only: bool,
) {
    let key = (name.to_string(), domain.to_string());
    if !seen.insert(key) {
        return;
    }
    out.push(CdpCookie {
        name: name.to_string(),
        value: value.to_string(),
        domain: domain.to_string(),
        path: "/".to_string(),
        secure: true,
        http_only,
        same_site: "Lax",
    });
}

fn is_captcha_cookie(name: &str) -> bool {
    matches!(
        name,
        "__client"
            | "__session"
            | "clerk_active_context"
            | "ajs_anonymous_id"
            | "suno_device_id"
            | "statsig_stable_id"
            | "ssr_bucket"
            | "has_logged_in_before"
    ) || name.starts_with("__client_")
        || name.starts_with("__session_")
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use super::add_minimal_cookie;

    #[test]
    fn bare_clerk_client_cookie_is_injected_for_auth_and_suno_domains() {
        let mut cookies = Vec::new();
        let mut seen = HashSet::new();

        add_minimal_cookie(
            "__client",
            "client-token",
            ".suno.com",
            true,
            &mut cookies,
            &mut seen,
        );

        assert_eq!(cookies.len(), 2);
        assert!(cookies.iter().any(|cookie| {
            cookie.name == "__client"
                && cookie.value == "client-token"
                && cookie.domain == "auth.suno.com"
        }));
        assert!(cookies.iter().any(|cookie| {
            cookie.name == "__client"
                && cookie.value == "client-token"
                && cookie.domain == ".suno.com"
        }));
    }
}