use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::error::RuntimeError;
use crate::profile_archive::{
    ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
    VerifiedProfileSourceArchive,
};
use crate::resource::{BackendResourceHandle, validate_resource_handle_text};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";

/// Backend-synced Profile/config bundle stored by a Runtime.
///
/// The bundle is intentionally an intent/declaration boundary: it contains
/// profile selectors plus refs/grants/policies, never secret values, direct
/// Runtime endpoints, raw socket/session paths, runtime-local mount actual
/// paths, host-local cache paths, or fully resolved WorkerSpec content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundle {
    pub metadata: ConfigBundleMetadata,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub profiles: Vec<ConfigProfileDescriptor>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub declarations: Vec<ConfigDeclaration>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt_catalog: Option<worker::EffectivePromptCatalog>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile_source_archive: Option<ProfileSourceArchive>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile_source_archive_handle: Option<BackendResourceHandle>,
}

impl ConfigBundle {
    pub fn computed_digest(&self) -> String {
        let mut lines = Vec::new();
        lines.push(format!("id\0{}", self.metadata.id));
        lines.push(format!("revision\0{}", self.metadata.revision));
        lines.push(format!("workspace_id\0{}", self.metadata.workspace_id));
        lines.push(format!("created_at\0{}", self.metadata.created_at));
        lines.push(format!(
            "provenance.source\0{}",
            self.metadata.provenance.source
        ));
        lines.push(format!(
            "provenance.detail\0{}",
            self.metadata.provenance.detail.as_deref().unwrap_or("")
        ));

        let mut profiles = self.profiles.clone();
        profiles.sort_by(|left, right| {
            profile_sort_key(&left.selector).cmp(&profile_sort_key(&right.selector))
        });
        for profile in profiles {
            lines.push(format!(
                "profile\0{}\0{}",
                profile_sort_key(&profile.selector),
                profile.label.unwrap_or_default()
            ));
        }

        let mut declarations = self.declarations.clone();
        declarations
            .sort_by(|left, right| declaration_sort_key(left).cmp(&declaration_sort_key(right)));
        for declaration in declarations {
            lines.push(format!(
                "declaration\0{}\0{}\0{}",
                declaration.kind.canonical_name(),
                declaration.name,
                declaration.reference
            ));
        }

        if let Some(prompt_catalog) = &self.prompt_catalog {
            lines.push(format!(
                "prompt_catalog\0{}\0{}\0{}\0{}",
                prompt_catalog.config_revision,
                prompt_catalog.schema_fingerprint,
                prompt_catalog.toolchain_fingerprint,
                prompt_catalog.catalog_digest
            ));
        }

        if let Some(archive) = &self.profile_source_archive {
            lines.push(format!(
                "profile_archive\0{}\0{}\0{}",
                archive.reference.id, archive.reference.digest, archive.reference.size_bytes
            ));
            for (selector, path) in &archive.reference.source_graph.entrypoints {
                lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}"));
            }
        }
        if let Some(handle) = &self.profile_source_archive_handle {
            lines.push(format!(
                "profile_archive_handle\0{}\0{}\0{}\0{}\0{}",
                handle.workspace_id,
                handle.resource_id,
                handle.digest,
                handle.revision,
                handle.max_bytes
            ));
            for (selector, path) in handle
                .profile_source_graph
                .as_ref()
                .map(|graph| &graph.entrypoints)
                .into_iter()
                .flatten()
            {
                lines.push(format!(
                    "profile_archive_handle_entrypoint\0{selector}\0{path}"
                ));
            }
        }

        lines.sort();
        let mut hasher = Sha256::new();
        for line in lines {
            hasher.update(line.as_bytes());
            hasher.update(b"\n");
        }
        let digest = hasher.finalize();
        hex_digest(&digest)
    }

    pub fn with_computed_digest(mut self) -> Self {
        self.metadata.digest = self.computed_digest();
        self
    }

    pub fn summary(&self) -> ConfigBundleSummary {
        ConfigBundleSummary {
            id: self.metadata.id.clone(),
            digest: self.metadata.digest.clone(),
            digest_algorithm: CONFIG_BUNDLE_DIGEST_ALGORITHM.to_string(),
            revision: self.metadata.revision.clone(),
            workspace_id: self.metadata.workspace_id.clone(),
            created_at: self.metadata.created_at.clone(),
            provenance: self.metadata.provenance.clone(),
            profile_count: self.profiles.len(),
            declaration_count: self.declarations.len(),
            profile_source_archive: self
                .profile_source_archive
                .as_ref()
                .map(|archive| archive.reference.source_graph.clone())
                .or_else(|| {
                    self.profile_source_archive_handle
                        .as_ref()
                        .and_then(|handle| handle.profile_source_graph.clone())
                }),
        }
    }

    pub fn contains_profile(&self, selector: &ProfileSelector) -> bool {
        self.profiles
            .iter()
            .any(|profile| profile.selector == *selector)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleMetadata {
    pub id: String,
    pub digest: String,
    pub revision: String,
    pub workspace_id: String,
    pub created_at: String,
    pub provenance: ConfigBundleProvenance,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleProvenance {
    pub source: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigProfileDescriptor {
    pub selector: ProfileSelector,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigDeclaration {
    pub kind: ConfigDeclarationKind,
    pub name: String,
    pub reference: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigDeclarationKind {
    SecretRef,
    MountGrant,
    NetworkPolicy,
    ShellPolicy,
    GitPolicy,
    CapabilityGrant,
    Unsupported,
}

impl ConfigDeclarationKind {
    pub fn canonical_name(&self) -> &'static str {
        match self {
            Self::SecretRef => "secret_ref",
            Self::MountGrant => "mount_grant",
            Self::NetworkPolicy => "network_policy",
            Self::ShellPolicy => "shell_policy",
            Self::GitPolicy => "git_policy",
            Self::CapabilityGrant => "capability_grant",
            Self::Unsupported => "unsupported",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleSummary {
    pub id: String,
    pub digest: String,
    pub digest_algorithm: String,
    pub revision: String,
    pub workspace_id: String,
    pub created_at: String,
    pub provenance: ConfigBundleProvenance,
    pub profile_count: usize,
    pub declaration_count: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile_source_archive: Option<crate::profile_archive::ProfileSourceGraphSummary>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigBundleAvailability {
    pub reference: ConfigBundleRef,
    pub summary: ConfigBundleSummary,
}

pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), RuntimeError> {
    validate_config_bundle_id(&bundle.metadata.id)?;
    validate_non_empty("config bundle digest", &bundle.metadata.digest)?;
    validate_digest("config bundle digest", &bundle.metadata.digest)?;
    validate_non_empty("config bundle revision", &bundle.metadata.revision)?;
    validate_non_empty("config bundle workspace id", &bundle.metadata.workspace_id)?;
    validate_non_empty("config bundle created_at", &bundle.metadata.created_at)?;
    validate_non_empty(
        "config bundle provenance source",
        &bundle.metadata.provenance.source,
    )?;
    validate_boundary_text("config bundle id", &bundle.metadata.id)?;
    validate_boundary_text("config bundle revision", &bundle.metadata.revision)?;
    validate_boundary_text("config bundle workspace id", &bundle.metadata.workspace_id)?;
    validate_boundary_text(
        "config bundle provenance source",
        &bundle.metadata.provenance.source,
    )?;
    if let Some(detail) = &bundle.metadata.provenance.detail {
        validate_boundary_text("config bundle provenance detail", detail)?;
    }

    let computed = bundle.computed_digest();
    if computed != bundle.metadata.digest {
        return Err(RuntimeError::ConfigBundleDigestMismatch {
            bundle_id: bundle.metadata.id.clone(),
            expected_digest: bundle.metadata.digest.clone(),
            actual_digest: computed,
        });
    }

    for profile in &bundle.profiles {
        validate_profile_selector(profile.selector.clone(), Some(&bundle.metadata.id))?;
        if let Some(label) = &profile.label {
            validate_boundary_text("profile label", label)?;
        }
    }

    for declaration in &bundle.declarations {
        validate_non_empty("config declaration name", &declaration.name)?;
        validate_boundary_text("config declaration name", &declaration.name)?;
        if declaration.kind == ConfigDeclarationKind::Unsupported {
            return Err(RuntimeError::UnsupportedConfigDeclaration {
                bundle_id: bundle.metadata.id.clone(),
                declaration_kind: declaration.kind.canonical_name().to_string(),
                name: declaration.name.clone(),
            });
        }
        validate_declaration_reference(&bundle.metadata.id, declaration)?;
    }

    if let Some(prompt_catalog) = &bundle.prompt_catalog {
        prompt_catalog.verify_digest().map_err(|error| {
            RuntimeError::InvalidRequest(format!("invalid Prompt catalog projection: {error}"))
        })?;
    }

    if let Some(archive) = &bundle.profile_source_archive {
        validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
            RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
        })?;
        archive.verify().map_err(|err| {
            RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
        })?;
    }
    if let Some(handle) = &bundle.profile_source_archive_handle {
        for (label, value) in [
            ("resource handle workspace id", handle.workspace_id.as_str()),
            ("resource handle resource id", handle.resource_id.as_str()),
            ("resource handle digest", handle.digest.as_str()),
            ("resource handle nonce", handle.nonce.as_str()),
            ("resource handle revision", handle.revision.as_str()),
            ("resource handle content type", handle.content_type.as_str()),
            (
                "resource handle audit correlation id",
                handle.audit_correlation_id.as_str(),
            ),
        ] {
            validate_resource_handle_text(label, value).map_err(RuntimeError::InvalidRequest)?;
        }
        if !handle.digest.starts_with("sha256:") {
            return Err(RuntimeError::InvalidRequest(
                "resource handle digest must use sha256:<hex>".to_string(),
            ));
        }
        if handle.profile_source_graph.is_none() {
            return Err(RuntimeError::InvalidRequest(
                "profile source archive resource handle must include source graph summary"
                    .to_string(),
            ));
        }
    }

    Ok(())
}

pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<(), RuntimeError> {
    validate_config_bundle_id(&reference.id)?;
    validate_non_empty("config bundle reference digest", &reference.digest)?;
    validate_digest("config bundle reference digest", &reference.digest)?;
    Ok(())
}

pub fn verified_profile_source_archive(
    bundle: &ConfigBundle,
) -> Result<Option<VerifiedProfileSourceArchive>, ProfileArchiveError> {
    bundle
        .profile_source_archive
        .as_ref()
        .map(ProfileSourceArchive::verify)
        .transpose()
}

fn validate_profile_source_archive_ref(
    reference: &ProfileSourceArchiveRef,
) -> Result<(), ProfileArchiveError> {
    if reference.id.trim().is_empty() {
        return Err(ProfileArchiveError::MissingEntrypoint(
            "archive id".to_string(),
        ));
    }
    if !reference.digest.starts_with("sha256:") {
        return Err(ProfileArchiveError::ArchiveDigestMismatch {
            id: reference.id.clone(),
            expected: "sha256:<hex>".to_string(),
            actual: reference.digest.clone(),
        });
    }
    Ok(())
}

pub(crate) fn validate_profile_selector(
    selector: ProfileSelector,
    bundle_id: Option<&str>,
) -> Result<(), RuntimeError> {
    match selector {
        ProfileSelector::Builtin(value) | ProfileSelector::Named(value) => {
            if value.trim().is_empty() {
                Err(RuntimeError::InvalidProfileSelector {
                    profile: value,
                    bundle_id: bundle_id.map(ToOwned::to_owned),
                    message: "profile selector must not be empty".to_string(),
                })
            } else {
                validate_boundary_text("profile selector", &value).map_err(|err| match err {
                    RuntimeError::InvalidRequest(message) => RuntimeError::InvalidProfileSelector {
                        profile: value,
                        bundle_id: bundle_id.map(ToOwned::to_owned),
                        message,
                    },
                    other => other,
                })
            }
        }
    }
}

fn validate_non_empty(label: &'static str, value: &str) -> Result<(), RuntimeError> {
    if value.trim().is_empty() {
        Err(RuntimeError::InvalidRequest(format!(
            "{label} must not be empty"
        )))
    } else {
        Ok(())
    }
}

fn validate_config_bundle_id(value: &str) -> Result<(), RuntimeError> {
    validate_non_empty("config bundle id", value)?;
    let trimmed = value.trim();
    if trimmed.len() > 128 {
        return Err(RuntimeError::InvalidRequest(
            "config bundle id is too large".to_string(),
        ));
    }
    if trimmed != value {
        return Err(RuntimeError::InvalidRequest(
            "config bundle id must not contain surrounding whitespace".to_string(),
        ));
    }
    if !trimmed
        .bytes()
        .next()
        .is_some_and(|byte| byte.is_ascii_alphanumeric())
        || !trimmed
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
    {
        return Err(RuntimeError::InvalidRequest(
            "config bundle id must be a path-safe stable identifier".to_string(),
        ));
    }
    Ok(())
}

fn validate_digest(label: &'static str, value: &str) -> Result<(), RuntimeError> {
    let trimmed = value.trim();
    if trimmed != value
        || trimmed.len() != 64
        || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit())
    {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must be a 64-character lowercase sha256 hex digest"
        )));
    }
    if !trimmed
        .bytes()
        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
    {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must be a 64-character lowercase sha256 hex digest"
        )));
    }
    Ok(())
}

fn validate_declaration_reference(
    bundle_id: &str,
    declaration: &ConfigDeclaration,
) -> Result<(), RuntimeError> {
    validate_non_empty("config declaration reference", &declaration.reference)?;
    validate_ref_boundary_text("config declaration reference", &declaration.reference)?;
    let allowed_prefixes: &[&str] = match declaration.kind {
        ConfigDeclarationKind::SecretRef => &["secret:", "secret-ref:", "vault:", "keyring:"],
        ConfigDeclarationKind::MountGrant => &["mount:", "mount-grant:"],
        ConfigDeclarationKind::NetworkPolicy => &["network:", "network-policy:"],
        ConfigDeclarationKind::ShellPolicy => &["shell:", "shell-policy:"],
        ConfigDeclarationKind::GitPolicy => &["git:", "git-policy:"],
        ConfigDeclarationKind::CapabilityGrant => &["capability:", "capability-grant:"],
        ConfigDeclarationKind::Unsupported => &[],
    };
    if !allowed_prefixes.iter().any(|prefix| {
        declaration.reference.starts_with(prefix) && declaration.reference.len() > prefix.len()
    }) {
        return Err(RuntimeError::UnsupportedConfigDeclaration {
            bundle_id: bundle_id.to_string(),
            declaration_kind: declaration.kind.canonical_name().to_string(),
            name: declaration.name.clone(),
        });
    }
    Ok(())
}

fn validate_ref_boundary_text(label: &'static str, value: &str) -> Result<(), RuntimeError> {
    let trimmed = value.trim();
    validate_boundary_text(label, trimmed)?;
    if trimmed != value
        || trimmed.contains('/')
        || trimmed.contains('\\')
        || trimmed.contains('?')
        || trimmed.contains('&')
        || trimmed.contains('#')
        || trimmed.contains('%')
        || trimmed.contains('=')
        || trimmed.chars().any(char::is_whitespace)
        || !trimmed.bytes().all(|byte| {
            byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'_' | b'.' | b'@' | b'+')
        })
    {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must be a typed ref/grant/policy token, not a secret value or path"
        )));
    }
    let lower = trimmed.to_ascii_lowercase();
    if lower.contains(".cache")
        || lower.contains(".yoi")
        || lower.contains(".sock")
        || lower.contains("socket=")
        || lower.contains("session_path")
        || lower.contains("cache_path")
    {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must not contain host-local cache/session/socket material"
        )));
    }
    Ok(())
}

fn validate_boundary_text(label: &'static str, value: &str) -> Result<(), RuntimeError> {
    let trimmed = value.trim();
    if trimmed.len() > 2048 {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} is too large"
        )));
    }
    if trimmed.chars().any(char::is_control) {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must not contain control characters"
        )));
    }
    let lower = trimmed.to_ascii_lowercase();
    if trimmed.starts_with('/')
        || trimmed.starts_with('~')
        || trimmed.contains(":\\")
        || lower.contains(".cache")
        || lower.contains(".yoi/sessions")
        || lower.contains(".yoi\\sessions")
        || lower.contains("/sessions/")
        || lower.contains("\\sessions\\")
        || lower.contains("/run/")
        || lower.contains("\\run\\")
        || lower.contains(".sock")
        || lower.contains("/sock")
        || lower.contains("\\sock")
        || lower.contains("socket=")
        || lower.contains("session_path")
        || lower.contains("cache_path")
    {
        return Err(RuntimeError::InvalidRequest(format!(
            "{label} must be a stable ref/grant/policy declaration, not a host-local path"
        )));
    }
    Ok(())
}

fn declaration_sort_key(declaration: &ConfigDeclaration) -> String {
    format!(
        "{}\0{}\0{}",
        declaration.kind.canonical_name(),
        declaration.name,
        declaration.reference
    )
}

fn profile_sort_key(selector: &ProfileSelector) -> String {
    match selector {
        ProfileSelector::Builtin(value) => format!("builtin\0{value}"),
        ProfileSelector::Named(value) => format!("named\0{value}"),
    }
}

fn hex_digest(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}

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

    fn bundle_with_declaration(reference: &str) -> ConfigBundle {
        ConfigBundle {
            metadata: ConfigBundleMetadata {
                id: "bundle-1".to_string(),
                digest: String::new(),
                revision: "rev-1".to_string(),
                workspace_id: "workspace-1".to_string(),
                created_at: "2026-06-26T00:00:00Z".to_string(),
                provenance: ConfigBundleProvenance {
                    source: "test".to_string(),
                    detail: None,
                },
            },
            profiles: vec![ConfigProfileDescriptor {
                selector: ProfileSelector::Builtin("builtin:coder".to_string()),
                label: None,
            }],
            declarations: vec![ConfigDeclaration {
                kind: ConfigDeclarationKind::SecretRef,
                name: "credential".to_string(),
                reference: reference.to_string(),
            }],
            prompt_catalog: None,
            profile_source_archive: None,
            profile_source_archive_handle: None,
        }
        .with_computed_digest()
    }

    #[test]
    fn rejects_host_local_cache_session_socket_and_plaintext_secret_refs() {
        for reference in [
            ".cache/yoi",
            ".yoi/sessions/foo.jsonl",
            "pods/foo/sock",
            "password=hunter2",
            "hunter2-secret-value",
        ] {
            let error = validate_config_bundle(&bundle_with_declaration(reference)).unwrap_err();
            assert!(
                matches!(
                    error,
                    RuntimeError::InvalidRequest(_)
                        | RuntimeError::UnsupportedConfigDeclaration { .. }
                ),
                "unexpected error for {reference}: {error:?}"
            );
        }
    }

    #[test]
    fn accepts_typed_secret_refs() {
        validate_config_bundle(&bundle_with_declaration("secret:github-token")).unwrap();
        validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
    }

    #[test]
    fn validates_immutable_prompt_catalog_projection() {
        let mut bundle = bundle_with_declaration("secret:github-token");
        bundle.prompt_catalog = Some(
            worker::EffectivePromptCatalog::new(
                std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
                7,
                "schema",
                "toolchain",
            )
            .unwrap(),
        );
        bundle = bundle.with_computed_digest();
        validate_config_bundle(&bundle).unwrap();

        bundle
            .prompt_catalog
            .as_mut()
            .unwrap()
            .templates
            .insert("default".into(), "tampered".into());
        bundle = bundle.with_computed_digest();
        let error = validate_config_bundle(&bundle).unwrap_err();
        assert!(error.to_string().contains("catalog digest mismatch"));
    }

    #[test]
    fn bundle_summary_redacts_runtime_internal_resource_handle() {
        let mut bundle = bundle_with_declaration("secret:github-token");
        let source_graph = crate::profile_archive::ProfileSourceGraphSummary {
            entrypoints: std::collections::BTreeMap::from([(
                "default".to_string(),
                "profiles/default.dcdl".to_string(),
            )]),
            source_count: 1,
            import_count: 0,
            total_source_bytes: 16,
        };
        bundle.profile_source_archive_handle = Some(crate::resource::BackendResourceHandle {
            kind: crate::resource::BackendResourceKind::ProfileSourceArchive,
            workspace_id: "workspace-1".to_string(),
            scope_id: Some("scope-1".to_string()),
            runtime_id: Some("runtime-1".to_string()),
            worker_id: Some("worker-1".to_string()),
            resource_id: "profile-source-archive:test".to_string(),
            digest: "sha256:0123456789abcdef".to_string(),
            operation: crate::resource::BackendResourceOperation::FetchArchive,
            expires_at_unix_seconds: 4_102_444_800,
            nonce: "nonce-not-for-browser".to_string(),
            revision: "rev-1".to_string(),
            generation: Some(1),
            max_bytes: 128,
            content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
            redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
            audit_correlation_id: "audit-1".to_string(),
            profile_source_graph: Some(source_graph),
        });
        let rendered = serde_json::to_string(&bundle.summary()).unwrap();
        assert!(rendered.contains("profile_source_archive"));
        for forbidden in [
            "nonce-not-for-browser",
            "profile-source-archive:test",
            "sha256:0123456789abcdef",
            "runtime-1",
            "worker-1",
        ] {
            assert!(
                !rendered.contains(forbidden),
                "leaked {forbidden}: {rendered}"
            );
        }
    }

    #[test]
    fn rejects_unsafe_bundle_ids_and_refs() {
        for id in ["bundle/1", "bundle?x", "bundle&x", "bundle#x", " bundle"] {
            let mut bundle = bundle_with_declaration("secret:github-token");
            bundle.metadata.id = id.to_string();
            bundle = bundle.with_computed_digest();
            assert!(validate_config_bundle(&bundle).is_err(), "accepted id {id}");
        }

        assert!(
            validate_config_bundle_ref(&ConfigBundleRef {
                id: "bundle/1".to_string(),
                digest: "0".repeat(64),
            })
            .is_err()
        );
    }
}
