a3s-use-core 0.2.9

Shared typed contracts for A3S Use domains
Documentation
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

mod filesystem;
pub mod okf;
pub mod plugin;
pub mod release;

pub use filesystem::metadata_is_link_or_reparse_point;
#[cfg(windows)]
#[doc(hidden)]
pub use filesystem::windows_extended_length_path;
pub use okf::{
    inspect_okf_bundle, inspect_okf_bundle_files, OkfBundleContract, OkfBundleDiagnostic,
    OkfBundleFile, OkfBundleInspection, OkfBundleLimits, OkfCapabilityProjection,
    OkfConceptSummary, OkfDiagnosticCode, OkfFormatVersion, OkfKnowledgeObservation,
    OkfKnowledgeObservedState, OkfProjectionReceipt, OkfSelectedGeneration,
    OKF_BUNDLE_CONTRACT_SCHEMA, OKF_CAPABILITY_PROJECTION_SCHEMA, OKF_KNOWLEDGE_OBSERVATION_SCHEMA,
    OKF_PROJECTION_RECEIPT_SCHEMA,
};
pub use plugin::{
    ArtifactRef, CapabilityConsumerExtension, CapabilityConsumerKind,
    CapabilityConsumerNegotiation, CapabilityConsumerProfile, CapabilityDescriptionProof,
    CapabilityDescriptor, CapabilityDescriptorKind, CapabilityGatewayCatalog,
    CapabilityMcpTransport, CapabilityPublicationEvidence, CapabilityToolAnnotations,
    CatalogArchive, CatalogAvailability, CatalogMcpTransport, CatalogPackage,
    CatalogPlanningTarget, CatalogSurface, EndpointRef, ExecutablePlanningSurface,
    FilesystemAccess, FilesystemPermission, FilesystemScope, HttpMethod, InstallationId,
    InstallationKind, InstallationPackageSelection, InstallationRootSelection,
    InstallationSnapshot, InstalledPluginPlanEvidence, InvocationRef, LockedPluginPackage,
    LockedPluginPackageDependency, NetworkEgressPermission, PlanActor, PlanAuthority,
    PlanEnforcementProfile, PlanPackageChangeKind, PlanPackageRole, PlanPolicyDecision,
    PlanQualifiedSurfaceRef, PlanScope, PlanScopeKind, PlannedOkfSurfaceChange,
    PlannedOperationImpact, PlannedPackageState, PlannedPackageTransition, PlannedPluginRelease,
    PlannedProviderEvidence, PlannedSecretChange, PlannedSecretChangeKind, PlannedStateEvidence,
    PlannedSurfaceChange, PlannedWorkspaceGrantChange, PlannedWorkspaceImpact, PlanningArtifactRef,
    PlanningSurfaceActivation, PluginCatalogRecord, PluginDesiredState, PluginGrantConfirmation,
    PluginHostApplyRequest, PluginHostApplyResult, PluginHostCancelRequest, PluginHostCancelResult,
    PluginHostCancellationStatus, PluginHostCapabilities, PluginHostEnablementPlanRequest,
    PluginHostEnablementPlanResult, PluginHostEnablementPlanStatus, PluginHostManager,
    PluginHostObservationRequest, PluginHostObservationResult, PluginHostObservationStatus,
    PluginHostOperationCancellability, PluginHostOperationObservationRequest,
    PluginHostOperationObservationResult, PluginHostOperationPhase, PluginHostOperationProgress,
    PluginHostOperationStatus, PluginHostOperationWatchRequest, PluginHostPackageState,
    PluginHostPlanRequest, PluginHostPlanResult, PluginHostUnavailableReason, PluginManagedScope,
    PluginManagerApplyPlanInput, PluginManagerInspectInput, PluginManagerInstallPlanInput,
    PluginManagerListInstalledInput, PluginManagerPackageScopeInput, PluginManagerSearchInput,
    PluginManagerToolAnnotations, PluginManagerToolDefinition, PluginManagerToolset,
    PluginManagerUpgradePlanInput, PluginObservedState, PluginOperationAction,
    PluginOperationConfirmation, PluginOperationPlan, PluginOperationPlanBinding,
    PluginOperationPlanDraft, PluginOperationPlanEnvelope, PluginPackageDependency,
    PluginPackageId, PluginPackageLock, PluginPackageLockHost, PluginPackageResolver,
    PluginPermissionCeiling, PluginPlanSource, PluginPlanningBundle, PluginReleaseChannel,
    PluginSurfaceKind, PluginSurfaceRef, PluginWorkspaceGrant, PluginWorkspaceGrantChangeSet,
    PluginWorkspaceGrantProposal, PluginWorkspaceGrantSnapshot, ResolvedWorkspaceGrant,
    ResolvedWorkspaceGrantChangeSet, ResourcePermissionCeiling, SurfaceChangeKind,
    SurfacePermissionCeiling, ToolWorkloadClass, UiHttpPermission, VerifiedCatalogProvenance,
    VerifiedPluginCatalogRecord, WorkspaceGrantAuthority, WorkspaceGrantEvidence,
    WorkspaceGrantProposalAuthority, CAPABILITY_CONSUMER_NEGOTIATION_SCHEMA_V1,
    CAPABILITY_CONSUMER_PROFILE_SCHEMA_V1, CAPABILITY_DESCRIPTION_PROOF_SCHEMA_V1,
    CAPABILITY_DESCRIPTOR_SCHEMA_V1, CAPABILITY_GATEWAY_CATALOG_SCHEMA_V1,
    INSTALLATION_SNAPSHOT_SCHEMA, INSTALLED_PLUGIN_PLAN_EVIDENCE_SCHEMA,
    MAX_CAPABILITY_CONSUMER_EXTENSIONS, MAX_INSTALLATION_PACKAGES, MAX_INSTALLATION_ROOTS,
    MAX_INSTALLATION_SNAPSHOT_BYTES, MAX_PLUGIN_CONTRACT_BYTES,
    MAX_PLUGIN_HOST_OPERATION_WATCH_TIMEOUT_MS, MAX_PLUGIN_PACKAGE_DEPENDENCIES,
    MAX_PLUGIN_PLAN_ITEMS, MAX_PLUGIN_RESOLUTION_CANDIDATES, PLUGIN_CATALOG_SCHEMA_V3,
    PLUGIN_GRANT_CONFIRMATION_SCHEMA, PLUGIN_HOST_APPLY_REQUEST_SCHEMA,
    PLUGIN_HOST_APPLY_RESULT_SCHEMA, PLUGIN_HOST_CANCEL_REQUEST_SCHEMA,
    PLUGIN_HOST_CANCEL_RESULT_SCHEMA, PLUGIN_HOST_CAPABILITIES_SCHEMA_V6,
    PLUGIN_HOST_ENABLEMENT_PLAN_REQUEST_SCHEMA, PLUGIN_HOST_ENABLEMENT_PLAN_RESULT_SCHEMA,
    PLUGIN_HOST_OBSERVATION_REQUEST_SCHEMA, PLUGIN_HOST_OBSERVATION_RESULT_SCHEMA,
    PLUGIN_HOST_OPERATION_OBSERVATION_REQUEST_SCHEMA,
    PLUGIN_HOST_OPERATION_OBSERVATION_RESULT_SCHEMA, PLUGIN_HOST_OPERATION_WATCH_REQUEST_SCHEMA,
    PLUGIN_HOST_PLAN_REQUEST_SCHEMA, PLUGIN_HOST_PLAN_RESULT_SCHEMA, PLUGIN_HOST_PROTOCOL_LEVEL_V6,
    PLUGIN_MANAGED_SCOPE_SCHEMA_V2, PLUGIN_MANAGER_TOOLSET_SCHEMA_V4,
    PLUGIN_OPERATION_CONFIRMATION_SCHEMA, PLUGIN_OPERATION_PLAN_DRAFT_SCHEMA_V3,
    PLUGIN_OPERATION_PLAN_SCHEMA_V4, PLUGIN_PACKAGE_LOCK_SCHEMA, PLUGIN_PERMISSION_SCHEMA,
    PLUGIN_PLANNING_BUNDLE_SCHEMA, PLUGIN_WORKSPACE_GRANT_CHANGE_SET_SCHEMA,
    PLUGIN_WORKSPACE_GRANT_PROPOSAL_SCHEMA, PLUGIN_WORKSPACE_GRANT_SCHEMA,
    PLUGIN_WORKSPACE_GRANT_SNAPSHOT_SCHEMA,
};
pub use release::{
    HttpHealthContract, McpReleaseDescriptor, McpServiceContract, McpServiceTransport,
    ReleaseArtifact, ReleaseCompatibility, ReleaseDependency, ReleaseKind, ReleaseProvenance,
    ReleaseResolution, SkillBindingContract, SkillBindingTarget, SkillContentContract,
    SkillReleaseDescriptor, ToolReleaseDescriptor, ToolServiceInterface, ToolServiceNetwork,
    ToolTaskInterface, ToolWorkloadContract, MAX_RELEASE_DESCRIPTOR_BYTES, MCP_RELEASE_SCHEMA,
    SKILL_RELEASE_SCHEMA, TOOL_RELEASE_SCHEMA,
};

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UseSessionId(String);

impl UseSessionId {
    pub fn parse(value: impl Into<String>) -> Result<Self, UseError> {
        let value = value.into();
        if value.is_empty()
            || !value.chars().all(|character| {
                character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
            })
        {
            return Err(UseError::new(
                "use.session.invalid_id",
                "Session IDs may contain only ASCII letters, digits, '-' and '_'.",
            ));
        }
        Ok(Self(value))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiskClass {
    Read,
    Navigate,
    Mutate,
    Submit,
    Download,
    Execute,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
    pub path: PathBuf,
    pub media_type: String,
    pub size: u64,
    pub sha256: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Readiness {
    Ready,
    Missing,
    Broken,
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomainDiagnostic {
    pub domain: String,
    pub readiness: Readiness,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    pub message: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub suggestions: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UseError {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
    #[serde(default)]
    pub details: BTreeMap<String, serde_json::Value>,
}

impl UseError {
    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
            suggestion: None,
            details: BTreeMap::new(),
        }
    }

    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
        self.suggestion = Some(suggestion.into());
        self
    }

    pub fn with_detail(
        mut self,
        name: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) -> Self {
        self.details.insert(name.into(), value.into());
        self
    }
}

impl fmt::Display for UseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for UseError {}

pub type UseResult<T> = Result<T, UseError>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FirstUseInstallBlock {
    Offline,
    Disabled,
}

impl FirstUseInstallBlock {
    pub const fn reason(self) -> &'static str {
        match self {
            Self::Offline => "offline mode",
            Self::Disabled => "A3S_NO_AUTO_INSTALL",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FirstUseInstallPolicy {
    offline: bool,
    disabled: bool,
}

impl FirstUseInstallPolicy {
    pub const fn new(offline: bool, disabled: bool) -> Self {
        Self { offline, disabled }
    }

    pub fn from_env() -> UseResult<Self> {
        Self::from_values(
            std::env::var_os("A3S_OFFLINE"),
            std::env::var_os("A3S_NO_AUTO_INSTALL"),
        )
    }

    pub const fn blocked_by(self) -> Option<FirstUseInstallBlock> {
        if self.offline {
            Some(FirstUseInstallBlock::Offline)
        } else if self.disabled {
            Some(FirstUseInstallBlock::Disabled)
        } else {
            None
        }
    }

    pub const fn allows_install(self) -> bool {
        self.blocked_by().is_none()
    }

    fn from_values(offline: Option<OsString>, disabled: Option<OsString>) -> UseResult<Self> {
        Ok(Self {
            offline: parse_environment_boolean("A3S_OFFLINE", offline)?,
            disabled: parse_environment_boolean("A3S_NO_AUTO_INSTALL", disabled)?,
        })
    }
}

fn parse_environment_boolean(name: &'static str, value: Option<OsString>) -> UseResult<bool> {
    let Some(value) = value else {
        return Ok(false);
    };
    if value.is_empty() {
        return Ok(true);
    }
    let value = value.into_string().map_err(|_| {
        UseError::new(
            "use.first_use.policy_invalid",
            format!("{name} must contain a valid UTF-8 boolean value."),
        )
        .with_detail("variable", name)
    })?;
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        _ => Err(UseError::new(
            "use.first_use.policy_invalid",
            format!("{name} must be a boolean value."),
        )
        .with_detail("variable", name)),
    }
}

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

    #[test]
    fn session_ids_are_bounded_and_serializable() {
        let id = UseSessionId::parse("browser_01").unwrap();
        assert_eq!(id.as_str(), "browser_01");
        assert!(UseSessionId::parse("../escape").is_err());
        assert_eq!(serde_json::to_string(&id).unwrap(), "\"browser_01\"");
    }

    #[test]
    fn stable_error_has_machine_code_and_suggestion() {
        let error = UseError::new("use.runtime.missing", "Runtime is missing.")
            .with_suggestion("Run a3s install use/browser.");
        let value = serde_json::to_value(error).unwrap();
        assert_eq!(value["code"], "use.runtime.missing");
        assert!(value["suggestion"]
            .as_str()
            .unwrap()
            .contains("a3s install"));
    }

    #[test]
    fn first_use_policy_uses_a3s_boolean_conventions() {
        for value in [None, Some("0"), Some("false"), Some("no"), Some("off")] {
            let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
            assert!(policy.allows_install());
        }
        for value in [Some(""), Some("1"), Some("true"), Some("yes"), Some("on")] {
            let policy = FirstUseInstallPolicy::from_values(value.map(Into::into), None).unwrap();
            assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
        }
    }

    #[test]
    fn offline_policy_takes_precedence_over_no_auto_install() {
        let policy =
            FirstUseInstallPolicy::from_values(Some("1".into()), Some("1".into())).unwrap();
        assert_eq!(policy.blocked_by(), Some(FirstUseInstallBlock::Offline));
    }

    #[test]
    fn invalid_first_use_policy_is_typed() {
        let error = FirstUseInstallPolicy::from_values(Some("sometimes".into()), None).unwrap_err();
        assert_eq!(error.code, "use.first_use.policy_invalid");
        assert_eq!(error.details["variable"], "A3S_OFFLINE");
    }
}