use std::fmt;
pub const PROTOCOL_VERSION: u32 = 3;
pub const PLAN_DOMAIN: &str = "ai-stp:provider-plan:v3";
pub const PROJECTION_DOMAIN: &str = "ai-stp:provider-projection:v3";
pub const PLAN_FORMAT: &str = "ai-stp-provider-plan/3";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Command {
ProviderInfo,
ValidateBundle,
PlanOperation,
ApplyOperation,
RecoverOperation,
Status,
Launch,
}
impl Command {
pub const ALL: &'static [Self] = &[
Self::ProviderInfo,
Self::ValidateBundle,
Self::PlanOperation,
Self::ApplyOperation,
Self::RecoverOperation,
Self::Status,
Self::Launch,
];
pub const CORE: &'static [Self] = &[
Self::ProviderInfo,
Self::ValidateBundle,
Self::PlanOperation,
Self::ApplyOperation,
Self::RecoverOperation,
Self::Status,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ProviderInfo => "provider-info",
Self::ValidateBundle => "validate-bundle",
Self::PlanOperation => "plan-operation",
Self::ApplyOperation => "apply-operation",
Self::RecoverOperation => "recover-operation",
Self::Status => "status",
Self::Launch => "launch",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|command| command.as_str() == text)
}
#[must_use]
pub const fn mutates(self) -> bool {
matches!(self, Self::ApplyOperation | Self::RecoverOperation)
}
#[must_use]
pub const fn takes_target(self) -> bool {
!matches!(self, Self::ProviderInfo)
}
}
impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Operation {
Install,
Replace,
Backup,
Restore,
Remove,
Reset,
SoftwareInstall,
SoftwareUpdate,
SoftwareRemove,
Launch,
}
impl Operation {
pub const ALL: &'static [Self] = &[
Self::Install,
Self::Replace,
Self::Backup,
Self::Restore,
Self::Remove,
Self::Reset,
Self::SoftwareInstall,
Self::SoftwareUpdate,
Self::SoftwareRemove,
Self::Launch,
];
pub const CORE: &'static [Self] = &[
Self::Backup,
Self::Install,
Self::Remove,
Self::Replace,
Self::Restore,
];
pub const CORE_AND_SOFTWARE: &'static [Self] = &[
Self::Backup,
Self::Install,
Self::Remove,
Self::Replace,
Self::Restore,
Self::Reset,
Self::SoftwareInstall,
Self::SoftwareUpdate,
Self::SoftwareRemove,
];
pub const SOFTWARE: &'static [Self] = &[
Self::SoftwareInstall,
Self::SoftwareUpdate,
Self::SoftwareRemove,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Replace => "replace",
Self::Backup => "backup",
Self::Restore => "restore",
Self::Remove => "remove",
Self::Reset => "reset",
Self::SoftwareInstall => "software_install",
Self::SoftwareUpdate => "software_update",
Self::SoftwareRemove => "software_remove",
Self::Launch => "launch",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|operation| operation.as_str() == text)
}
#[must_use]
pub const fn requires_restore_target_digest(self) -> bool {
matches!(self, Self::Restore)
}
}
impl fmt::Display for Operation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ComponentKind {
Instruction,
Skill,
Mcp,
Hook,
Command,
Agent,
Plugin,
Setting,
}
impl ComponentKind {
pub const ALL: &'static [Self] = &[
Self::Instruction,
Self::Skill,
Self::Mcp,
Self::Hook,
Self::Command,
Self::Agent,
Self::Plugin,
Self::Setting,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Instruction => "instruction",
Self::Skill => "skill",
Self::Mcp => "mcp",
Self::Hook => "hook",
Self::Command => "command",
Self::Agent => "agent",
Self::Plugin => "plugin",
Self::Setting => "setting",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|kind| kind.as_str() == text)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProjectionKind {
Marketplace,
Plugin,
NativeFiles,
Package,
}
impl ProjectionKind {
pub const ALL: &'static [Self] = &[
Self::Marketplace,
Self::Plugin,
Self::NativeFiles,
Self::Package,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Marketplace => "marketplace",
Self::Plugin => "plugin",
Self::NativeFiles => "native_files",
Self::Package => "package",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|kind| kind.as_str() == text)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TargetScope {
Project,
UserRoot,
}
impl TargetScope {
pub const REQUEST_FIELD: &'static str = "target_scope";
pub const ALL: &'static [Self] = &[Self::Project, Self::UserRoot];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Project => "project",
Self::UserRoot => "user_root",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|scope| scope.as_str() == text)
}
}
impl fmt::Display for TargetScope {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[cfg(test)]
pub(crate) mod kit {
#![allow(clippy::unwrap_used, clippy::panic)]
use std::path::PathBuf;
pub fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../provider-kit/v3")
}
pub fn json(name: &str) -> serde_json::Value {
let bytes = std::fs::read(root().join(name)).unwrap();
serde_json::from_slice(&bytes).unwrap()
}
pub fn strings(value: &serde_json::Value, key: &str) -> Vec<String> {
value[key]
.as_array()
.unwrap_or_else(|| panic!("{key} is not an array"))
.iter()
.map(|item| item.as_str().unwrap().to_owned())
.collect()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use kit::{json, strings};
#[test]
fn the_target_scopes_are_the_schema_s() {
let schema = json("provider-info.schema.json");
let published = strings(
&schema["properties"]["scoped_projection_profiles"]["items"]["properties"]["target_scope"],
"enum",
);
assert_eq!(
sorted(published),
sorted(spellings(TargetScope::ALL, TargetScope::as_str)),
);
}
fn sorted(mut values: Vec<String>) -> Vec<String> {
values.sort();
values
}
fn spellings<T: Copy>(all: &[T], as_str: fn(T) -> &'static str) -> Vec<String> {
all.iter().map(|item| as_str(*item).to_owned()).collect()
}
fn kit_disagreement(root: &std::path::Path) -> Option<String> {
let sums_bytes = std::fs::read(root.join("SHA256SUMS")).ok()?;
let sums = String::from_utf8(sums_bytes.clone()).ok()?;
let mut covered = Vec::new();
for line in sums.lines().filter(|line| !line.trim().is_empty()) {
let Some((expected, name)) = line.split_once(" ") else {
return Some(format!(
"SHA256SUMS line is not a digest and a name: {line:?}"
));
};
let Ok(bytes) = std::fs::read(root.join(name)) else {
return Some(format!("SHA256SUMS names {name:?} and it is not there"));
};
let actual = crate::setup_core::digest::of_bytes(&bytes);
if actual != format!("sha256:{expected}") {
return Some(format!("{name} does not match SHA256SUMS"));
}
covered.push(name.to_owned());
}
covered.sort();
let mut present: Vec<String> = std::fs::read_dir(root)
.ok()?
.filter_map(std::result::Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| {
std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
&& name != "KIT-IDENTITY.json"
})
.collect();
present.sort();
if covered != present {
return Some(format!(
"SHA256SUMS covers {covered:?} and the directory holds {present:?}"
));
}
let identity: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("KIT-IDENTITY.json")).ok()?).ok()?;
let mut named: Vec<String> = identity["files"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
named.sort();
if named != covered {
return Some(format!(
"KIT-IDENTITY names {named:?} and SHA256SUMS covers {covered:?}"
));
}
let aggregate = crate::setup_core::digest::of_bytes(&sums_bytes);
let recorded = identity["aggregate_digest"].as_str().unwrap_or_default();
if aggregate != recorded {
return Some(format!(
"KIT-IDENTITY records {recorded} and SHA256SUMS hashes to {aggregate}"
));
}
None
}
#[test]
fn the_kit_bytes_match_the_digests_it_publishes() {
assert_eq!(kit_disagreement(&kit::root()), None);
}
#[test]
fn a_kit_that_disagrees_with_itself_is_refused() {
let root = std::env::temp_dir().join(format!("kit-control-{}", std::process::id()));
let seed = |case: &str| {
let dir = root.join(case);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for entry in std::fs::read_dir(kit::root()).unwrap() {
let entry = entry.unwrap();
std::fs::copy(entry.path(), dir.join(entry.file_name())).unwrap();
}
dir
};
let clean = seed("clean");
assert_eq!(kit_disagreement(&clean), None, "the copy itself must pass");
let tampered = seed("tampered-member");
let mut bytes = std::fs::read(tampered.join("manifest.json")).unwrap();
bytes.push(b'\n');
std::fs::write(tampered.join("manifest.json"), bytes).unwrap();
assert!(
kit_disagreement(&tampered).is_some_and(|why| why.contains("does not match")),
"a member whose bytes moved must be refused"
);
let emptied = seed("empty-sums");
std::fs::write(emptied.join("SHA256SUMS"), "").unwrap();
assert!(
kit_disagreement(&emptied).is_some_and(|why| why.contains("the directory holds")),
"an empty SHA256SUMS must not pass by having nothing to iterate"
);
let renamed = seed("identity-file-list");
let mut identity: serde_json::Value =
serde_json::from_slice(&std::fs::read(renamed.join("KIT-IDENTITY.json")).unwrap())
.unwrap();
identity["files"] = serde_json::json!(["manifest.json"]);
std::fs::write(
renamed.join("KIT-IDENTITY.json"),
serde_json::to_vec(&identity).unwrap(),
)
.unwrap();
assert!(
kit_disagreement(&renamed).is_some_and(|why| why.contains("KIT-IDENTITY names")),
"an identity naming a different file set must be refused"
);
let stale = seed("stale-aggregate");
let mut identity: serde_json::Value =
serde_json::from_slice(&std::fs::read(stale.join("KIT-IDENTITY.json")).unwrap())
.unwrap();
identity["aggregate_digest"] = serde_json::json!("sha256:00");
std::fs::write(
stale.join("KIT-IDENTITY.json"),
serde_json::to_vec(&identity).unwrap(),
)
.unwrap();
assert!(
kit_disagreement(&stale).is_some_and(|why| why.contains("KIT-IDENTITY records")),
"the revision every provider publishes must be recomputed, not trusted"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn commands_match_the_manifest() {
let manifest = json("manifest.json");
assert_eq!(
sorted(spellings(Command::ALL, Command::as_str)),
sorted(strings(&manifest, "commands"))
);
assert_eq!(
sorted(spellings(Command::CORE, Command::as_str)),
sorted(strings(&manifest, "core_commands"))
);
assert_eq!(
sorted(strings(&manifest, "optional_commands")),
vec![Command::Launch.as_str().to_owned()]
);
}
#[test]
fn operations_match_the_manifest() {
let manifest = json("manifest.json");
let optional = sorted(strings(&manifest, "optional_operations"));
let core = sorted(strings(&manifest, "core_operations"));
assert_eq!(sorted(spellings(Operation::CORE, Operation::as_str)), core);
let mut declared = core;
declared.extend(optional);
assert_eq!(
sorted(spellings(Operation::ALL, Operation::as_str)),
sorted(declared)
);
}
#[test]
fn component_and_projection_kinds_match_the_manifest() {
let manifest = json("manifest.json");
assert_eq!(
sorted(spellings(ComponentKind::ALL, ComponentKind::as_str)),
sorted(strings(&manifest, "component_kinds"))
);
assert_eq!(
sorted(spellings(ProjectionKind::ALL, ProjectionKind::as_str)),
sorted(strings(&manifest, "projection_kinds"))
);
}
#[test]
fn the_manifest_protocol_version_is_the_one_this_crate_implements() {
assert_eq!(json("manifest.json")["protocol_version"], PROTOCOL_VERSION);
}
#[test]
fn only_apply_and_recover_are_allowed_to_change_a_target() {
let manifest = json("manifest.json");
let apply = sorted(strings(&manifest, "apply_commands"));
let mutating = sorted(
Command::ALL
.iter()
.filter(|command| command.mutates())
.map(|command| command.as_str().to_owned())
.collect(),
);
assert_eq!(mutating, apply);
}
#[test]
fn every_pure_command_the_kit_names_is_one_this_crate_treats_as_pure() {
let cases = json("conformance-cases.json");
for name in strings(&cases, "pure_commands") {
let command = Command::parse(&name).unwrap_or_else(|| panic!("unknown {name}"));
assert!(!command.mutates(), "{name} must not mutate");
}
}
#[test]
fn an_unknown_spelling_parses_to_nothing_rather_than_a_neighbour() {
assert_eq!(Command::parse("plan"), None);
assert_eq!(Operation::parse("software-install"), None);
assert_eq!(ComponentKind::parse("instructions"), None);
assert_eq!(ProjectionKind::parse("native-files"), None);
}
#[test]
fn only_provider_info_is_invoked_without_a_target() {
for command in Command::ALL {
assert_eq!(
command.takes_target(),
*command != Command::ProviderInfo,
"{command} target convention is wrong"
);
}
}
}