feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
//! 目录索引与占位条目加载(Wave 0)
|
||||
//! 目录索引与条目加载(Wave 1)
|
||||
//!
|
||||
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,
|
||||
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段。
|
||||
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,对每个工具
|
||||
//! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段
|
||||
//! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter`(`load_adapters`)。
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -9,6 +10,7 @@ use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AdapterError;
|
||||
use crate::schema::{Adapter, parse_adapter};
|
||||
|
||||
/// 目录索引(adapters/catalog.yaml)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
@@ -24,7 +26,7 @@ pub struct CatalogRef {
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1)
|
||||
/// 目录条目(五字段视图,供 UI 展示)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct CatalogEntry {
|
||||
pub id: String,
|
||||
@@ -36,40 +38,58 @@ pub struct CatalogEntry {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 加载目录索引与全部工具占位 YAML
|
||||
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
|
||||
impl From<Adapter> for CatalogEntry {
|
||||
fn from(a: Adapter) -> Self {
|
||||
CatalogEntry {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
name_zh: a.name_zh,
|
||||
vendor: a.vendor,
|
||||
status: a.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取目录索引。
|
||||
fn read_index<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
|
||||
let dir = adapters_dir.as_ref();
|
||||
let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
|
||||
let catalog: Catalog = serde_yaml::from_str(&catalog_text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?;
|
||||
serde_yaml::from_str(&catalog_text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(catalog.tools.len());
|
||||
/// 加载并校验全部工具适配器(完整 schema)。
|
||||
pub fn load_adapters<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, AdapterError> {
|
||||
let dir = adapters_dir.as_ref();
|
||||
let catalog = read_index(dir)?;
|
||||
|
||||
let mut adapters = Vec::with_capacity(catalog.tools.len());
|
||||
for r in &catalog.tools {
|
||||
let text = fs::read_to_string(dir.join(&r.file))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
|
||||
let entry: CatalogEntry = serde_yaml::from_str(&text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?;
|
||||
if entry.id != r.id {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
let adapter = parse_adapter(&text)
|
||||
.map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?;
|
||||
if adapter.id != r.id {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"索引 id 与文件内 id 不一致: 索引={} 文件={}",
|
||||
r.id, entry.id
|
||||
r.id, adapter.id
|
||||
)));
|
||||
}
|
||||
if entry.status != "available" && entry.status != "watch" {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
"{} 的 status 非法: {}(应为 available | watch)",
|
||||
entry.id, entry.status
|
||||
)));
|
||||
}
|
||||
entries.push(entry);
|
||||
adapters.push(adapter);
|
||||
}
|
||||
Ok(entries)
|
||||
Ok(adapters)
|
||||
}
|
||||
|
||||
/// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。
|
||||
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
|
||||
Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::AdapterError;
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_tool_yaml() {
|
||||
@@ -81,19 +101,11 @@ mod tests {
|
||||
assert_eq!(entry.status, "available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_status() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
|
||||
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
|
||||
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_real_catalog_from_repo() {
|
||||
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters)
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let entries = load_catalog(&dir).expect("真实目录应可加载");
|
||||
let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验");
|
||||
assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
|
||||
for e in &entries {
|
||||
assert!(!e.id.is_empty());
|
||||
@@ -107,4 +119,50 @@ mod tests {
|
||||
assert!(ids.contains(&want), "目录应包含 {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。
|
||||
#[test]
|
||||
fn loader_rejects_dangerous_adapter() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("tools")).unwrap();
|
||||
fs::write(
|
||||
dir.join("catalog.yaml"),
|
||||
"catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("tools/evil.yaml"),
|
||||
"id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_adapters(&dir) {
|
||||
Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"),
|
||||
other => panic!("应因危险命令拒载,实际 {other:?}"),
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 加载器校验版本号:非法 adapter_version 拒载。
|
||||
#[test]
|
||||
fn loader_rejects_bad_version() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("tools")).unwrap();
|
||||
fs::write(
|
||||
dir.join("catalog.yaml"),
|
||||
"catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("tools/bad.yaml"),
|
||||
"id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_))));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 危险命令检测(架构 §4.2)
|
||||
//!
|
||||
//! 默认禁止 shell 拼接:管道、重定向、`$()`、反引号、`&&` 链、`;`、`\`。
|
||||
//! 适配器声明里的所有 `command` argv 都必须在加载期通过本检测,
|
||||
//! 否则整条适配器拒载并给出中文错误。
|
||||
|
||||
/// shell 元字符集合(架构 §4.2;`(` `)` 覆盖 `$()`,反引号覆盖命令替换)
|
||||
const SHELL_METACHARS: &[char] = &['|', '&', ';', '$', '\\', '>', '<', '(', ')', '`'];
|
||||
|
||||
/// 返回第一个命中的 shell 元字符;无则返回 None。
|
||||
pub fn first_shell_metachar(s: &str) -> Option<char> {
|
||||
s.chars().find(|c| SHELL_METACHARS.contains(c))
|
||||
}
|
||||
|
||||
/// 判断一个字符串是否含 shell 元字符。
|
||||
pub fn contains_shell_metachar(s: &str) -> bool {
|
||||
first_shell_metachar(s).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_common_metachars() {
|
||||
for (sample, expected) in [
|
||||
("curl | sh", Some('|')),
|
||||
("a && b", Some('&')),
|
||||
("a; rm -rf /", Some(';')),
|
||||
("$(id)", Some('$')),
|
||||
("echo `whoami`", Some('`')),
|
||||
("ls > out", Some('>')),
|
||||
("cat < in", Some('<')),
|
||||
("a\\b", Some('\\')),
|
||||
("echo (x)", Some('(')),
|
||||
("echo )", Some(')')),
|
||||
] {
|
||||
assert_eq!(first_shell_metachar(sample), expected, "样本 {sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_plain_argv() {
|
||||
for sample in [
|
||||
"npm",
|
||||
"install",
|
||||
"-g",
|
||||
"@openai/codex",
|
||||
"codex",
|
||||
"--version",
|
||||
"https://example.com/install.ps1",
|
||||
] {
|
||||
assert!(!contains_shell_metachar(sample), "普通参数 {sample:?} 不应被判危险");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ use std::fmt;
|
||||
pub enum AdapterError {
|
||||
Io(String),
|
||||
Parse(String),
|
||||
/// schema / 语义校验失败(中文)
|
||||
Validation(String),
|
||||
/// 命令含 shell 元字符等危险输入(中文)
|
||||
DangerousCommand(String),
|
||||
/// 尚未实现的能力(Wave 2 起逐波落地)
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AdapterError {
|
||||
@@ -13,6 +19,9 @@ impl fmt::Display for AdapterError {
|
||||
match self {
|
||||
AdapterError::Io(m) => write!(f, "IO: {m}"),
|
||||
AdapterError::Parse(m) => write!(f, "Parse: {m}"),
|
||||
AdapterError::Validation(m) => write!(f, "校验失败: {m}"),
|
||||
AdapterError::DangerousCommand(m) => write!(f, "危险命令: {m}"),
|
||||
AdapterError::NotImplemented(m) => write!(f, "未实现: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 统一执行器接口与 dry-run(架构 §3.2)
|
||||
//!
|
||||
//! `AdapterExecutor` 是各适配器的统一操作面;本波只落地骨架与
|
||||
//! `preview_action`(返回 `DryRunPlan`),真实安装/检测/配置/授权/诊断
|
||||
//! 命令的执行在 Wave 2 起由具体实现补全。所有 command 一律走 `agentdock-exec`
|
||||
//! 的 argv 数组执行,禁止 shell 拼接。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AdapterError;
|
||||
use crate::schema::Adapter;
|
||||
|
||||
/// 动作类型(对应 IPC 契约 previewAction/runAction 的 action)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdapterAction {
|
||||
Install,
|
||||
Update,
|
||||
Uninstall,
|
||||
WriteConfig,
|
||||
Authorize,
|
||||
Repair,
|
||||
}
|
||||
|
||||
impl AdapterAction {
|
||||
/// 动作的中文名(用于 dry-run 说明与 UI)
|
||||
pub fn label_zh(&self) -> &'static str {
|
||||
match self {
|
||||
AdapterAction::Install => "安装",
|
||||
AdapterAction::Update => "更新",
|
||||
AdapterAction::Uninstall => "卸载",
|
||||
AdapterAction::WriteConfig => "写配置",
|
||||
AdapterAction::Authorize => "授权",
|
||||
AdapterAction::Repair => "修复",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 干燥运行计划:命令 argv、权限、影响文件、回滚说明(架构 §3.2 dry_run)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DryRunPlan {
|
||||
/// 将执行的命令(argv 数组,禁止 shell)
|
||||
pub commands: Vec<Vec<String>>,
|
||||
/// 是否需要权限提升
|
||||
pub elevate: bool,
|
||||
/// 权限提升说明(中文),需要提升时必填
|
||||
pub elevate_reason_zh: Option<String>,
|
||||
/// 将影响/写入的文件路径
|
||||
pub affected_files: Vec<String>,
|
||||
/// 回滚说明(中文)
|
||||
pub rollback_zh: String,
|
||||
}
|
||||
|
||||
/// 统一执行器接口(架构 §3.2)
|
||||
///
|
||||
/// 除 `dry_run` 外,其余方法为骨架:默认返回 `NotImplemented`,由 Wave 2 起
|
||||
/// 各适配器实现补全。任何实现都不得绕过 `agentdock-exec` 拼接 shell。
|
||||
pub trait AdapterExecutor {
|
||||
/// 返回适配器定义
|
||||
fn adapter(&self) -> &Adapter;
|
||||
|
||||
/// 干燥运行:不真正执行,返回将执行的命令与影响面
|
||||
fn dry_run(&self, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
|
||||
preview_action(self.adapter(), action)
|
||||
}
|
||||
|
||||
fn detect(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("detect 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn install(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("install 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("update 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn uninstall(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("uninstall 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn read_config(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("read_config 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn write_config(&self, _patch: &str) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("write_config 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn authorization_status(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("authorization_status 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn authorize(&self, _mode: &str) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("authorize 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn diagnose(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("diagnose 自 Wave 2 起实现".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据适配器声明 + 动作生成干燥运行计划(不执行)。
|
||||
pub fn preview_action(adapter: &Adapter, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
|
||||
let mut commands: Vec<Vec<String>> = Vec::new();
|
||||
let mut elevate = false;
|
||||
let mut elevate_reason_zh: Option<String> = None;
|
||||
let mut affected_files: Vec<String> = Vec::new();
|
||||
|
||||
// 影响文件:配置文件路径(写配置/授权/修复都会触碰)
|
||||
if let Some(cfg) = &adapter.configuration {
|
||||
for f in &cfg.files {
|
||||
affected_files.push(f.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match action {
|
||||
AdapterAction::Install => {
|
||||
if let Some(install) = &adapter.install {
|
||||
if let Some(channel) = pick_channel(install) {
|
||||
if !channel.command.is_empty() {
|
||||
commands.push(channel.command.clone());
|
||||
}
|
||||
elevate = channel_elevates(channel);
|
||||
elevate_reason_zh = channel.elevate_reason_zh.clone();
|
||||
if let Some(script) = &channel.script {
|
||||
if let Some(url) = &script.url {
|
||||
affected_files.push(format!("下载脚本: {url}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Update => {
|
||||
if let Some(update) = &adapter.update {
|
||||
if !update.command.is_empty() {
|
||||
commands.push(update.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Uninstall => {
|
||||
if let Some(uninstall) = &adapter.uninstall {
|
||||
if !uninstall.command.is_empty() {
|
||||
commands.push(uninstall.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Authorize => {
|
||||
if let Some(auth) = &adapter.authorization {
|
||||
if let Some(mode) = auth.modes.first() {
|
||||
if !mode.command.is_empty() {
|
||||
commands.push(mode.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::WriteConfig => {
|
||||
// 写配置无外部命令,仅落盘配置文件
|
||||
}
|
||||
AdapterAction::Repair => {
|
||||
// 修复由诊断规则驱动,本波仅占位
|
||||
}
|
||||
}
|
||||
|
||||
let rollback_zh = rollback_note(adapter, action);
|
||||
|
||||
Ok(DryRunPlan {
|
||||
commands,
|
||||
elevate,
|
||||
elevate_reason_zh,
|
||||
affected_files,
|
||||
rollback_zh,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取首选渠道,无 preferred 时回退到第一个声明了命令的渠道。
|
||||
fn pick_channel(install: &crate::schema::Install) -> Option<&crate::schema::Channel> {
|
||||
if let Some(preferred) = &install.preferred {
|
||||
if let Some(ch) = install.channels.iter().find(|c| &c.id == preferred) {
|
||||
return Some(ch);
|
||||
}
|
||||
}
|
||||
install.channels.first()
|
||||
}
|
||||
|
||||
/// 渠道是否需要权限提升(never → false,其余 true)。
|
||||
fn channel_elevates(channel: &crate::schema::Channel) -> bool {
|
||||
matches!(channel.elevate.as_deref(), Some("if_needed") | Some("required"))
|
||||
}
|
||||
|
||||
/// 生成中文回滚说明。
|
||||
fn rollback_note(adapter: &Adapter, action: AdapterAction) -> String {
|
||||
match action {
|
||||
AdapterAction::Install | AdapterAction::Update => {
|
||||
format!("如需回退,可重新运行卸载({});已保留原配置文件不动。", adapter.id)
|
||||
}
|
||||
AdapterAction::Uninstall => {
|
||||
format!("卸载默认保留配置文件({});如需彻底移除请手动删除配置文件。", adapter.id)
|
||||
}
|
||||
AdapterAction::WriteConfig => {
|
||||
format!("写配置前会自动备份原文件为 .bak.<时间戳>,可随时恢复。")
|
||||
}
|
||||
AdapterAction::Authorize => {
|
||||
format!("授权信息仅写入系统密钥库,不落盘;如需撤销可删除对应密钥条目。")
|
||||
}
|
||||
AdapterAction::Repair => {
|
||||
format!("修复动作前会生成干燥运行计划,确认后才执行。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema::parse_adapter;
|
||||
|
||||
#[test]
|
||||
fn preview_install_builds_argv_plan() {
|
||||
let adapter = parse_adapter(
|
||||
r#"
|
||||
id: claude-code
|
||||
name: Claude Code
|
||||
name_zh: Claude Code
|
||||
vendor: Anthropic
|
||||
status: available
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows]
|
||||
command: [npm, install, -g, "@anthropic-ai/claude-code"]
|
||||
elevate: never
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.claude/settings.json"
|
||||
format: json
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
|
||||
assert_eq!(plan.commands, vec![vec!["npm", "install", "-g", "@anthropic-ai/claude-code"]]);
|
||||
assert!(!plan.elevate);
|
||||
assert!(plan.affected_files.iter().any(|f| f.contains("settings.json")));
|
||||
assert!(!plan.rollback_zh.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_elevates_when_required() {
|
||||
let adapter = parse_adapter(
|
||||
r#"
|
||||
id: crush
|
||||
name: Crush
|
||||
name_zh: Crush
|
||||
vendor: Charm
|
||||
status: available
|
||||
install:
|
||||
preferred: apt
|
||||
channels:
|
||||
- id: apt
|
||||
command: [apt, install, crush]
|
||||
elevate: required
|
||||
elevate_reason_zh: "需要管理员权限写入系统目录"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
|
||||
assert!(plan.elevate);
|
||||
assert_eq!(plan.elevate_reason_zh.as_deref(), Some("需要管理员权限写入系统目录"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_label_zh_is_chinese() {
|
||||
assert_eq!(AdapterAction::Install.label_zh(), "安装");
|
||||
assert_eq!(AdapterAction::WriteConfig.label_zh(), "写配置");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave2_methods_return_not_implemented() {
|
||||
let adapter = parse_adapter("id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\n").unwrap();
|
||||
struct NoopExecutor(Adapter);
|
||||
impl AdapterExecutor for NoopExecutor {
|
||||
fn adapter(&self) -> &Adapter {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
let ex = NoopExecutor(adapter);
|
||||
assert!(matches!(ex.detect(), Err(AdapterError::NotImplemented(_))));
|
||||
assert!(matches!(ex.install(), Err(AdapterError::NotImplemented(_))));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
//! agentdock-adapter —— 适配器层
|
||||
//!
|
||||
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
|
||||
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块);
|
||||
//! 完整 schema 校验、执行器接口随 Wave 1 实现。
|
||||
//! Wave 1:完整 schema 定义与校验(含危险命令拒载)、统一执行器骨架、
|
||||
//! dry-run 计划(`preview_action`)。真实安装/检测/配置命令 Wave 2 起落地。
|
||||
|
||||
pub mod catalog;
|
||||
pub mod danger;
|
||||
pub mod error;
|
||||
pub mod executor;
|
||||
pub mod schema;
|
||||
|
||||
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog};
|
||||
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_adapters, load_catalog};
|
||||
pub use danger::{contains_shell_metachar, first_shell_metachar};
|
||||
pub use error::AdapterError;
|
||||
pub use executor::{AdapterAction, AdapterExecutor, DryRunPlan, preview_action};
|
||||
pub use schema::{Adapter, parse_adapter};
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! 适配器完整 schema(对齐架构 §3.1)
|
||||
//!
|
||||
//! 与 `adapters/schema/adapter.schema.json` 同源:JSON Schema 是声明式契约,
|
||||
//! 本文件用 Rust 强类型结构体做运行时校验(deny_unknown_fields + 语义校验),
|
||||
//! 两者字段一一对应。Wave 1 的 14 个占位 YAML 仅填五字段,其余字段均可选。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::danger::first_shell_metachar;
|
||||
use crate::error::AdapterError;
|
||||
|
||||
/// 适配器定义(完整字段,§3.1)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Adapter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "name_zh")]
|
||||
pub name_zh: String,
|
||||
pub vendor: String,
|
||||
/// available | watch
|
||||
pub status: String,
|
||||
/// semver,如 1.2.0
|
||||
#[serde(rename = "adapter_version", default)]
|
||||
pub adapter_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platforms: Option<Platforms>,
|
||||
#[serde(default)]
|
||||
pub official: Option<Official>,
|
||||
#[serde(rename = "runtime_deps", default)]
|
||||
pub runtime_deps: Vec<RuntimeDep>,
|
||||
#[serde(default)]
|
||||
pub install: Option<Install>,
|
||||
#[serde(default)]
|
||||
pub detect: Option<Detect>,
|
||||
#[serde(default)]
|
||||
pub update: Option<Update>,
|
||||
#[serde(default)]
|
||||
pub uninstall: Option<Uninstall>,
|
||||
#[serde(default)]
|
||||
pub authorization: Option<Authorization>,
|
||||
#[serde(default)]
|
||||
pub configuration: Option<Configuration>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(default)]
|
||||
pub documentation: Option<Documentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Platforms {
|
||||
#[serde(default)]
|
||||
pub windows: Option<PlatformWindows>,
|
||||
#[serde(default)]
|
||||
pub linux: Option<PlatformLinux>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlatformWindows {
|
||||
#[serde(default)]
|
||||
pub architectures: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlatformLinux {
|
||||
#[serde(default)]
|
||||
pub distributions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub architectures: Vec<String>,
|
||||
#[serde(rename = "min_ubuntu", default)]
|
||||
pub min_ubuntu: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Official {
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
pub docs: Option<String>,
|
||||
#[serde(rename = "allowed_hosts", default)]
|
||||
pub allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeDep {
|
||||
pub id: String,
|
||||
#[serde(rename = "semver_range", default)]
|
||||
pub semver_range: Option<String>,
|
||||
#[serde(rename = "required_for", default)]
|
||||
pub required_for: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Install {
|
||||
#[serde(default)]
|
||||
pub preferred: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Channel {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub script: Option<Script>,
|
||||
#[serde(default)]
|
||||
pub package: Option<String>,
|
||||
#[serde(default)]
|
||||
pub elevate: Option<String>,
|
||||
#[serde(rename = "elevate_reason_zh", default)]
|
||||
pub elevate_reason_zh: Option<String>,
|
||||
#[serde(rename = "post_checks", default)]
|
||||
pub post_checks: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Script {
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub integrity: Option<Integrity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Integrity {
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Detect {
|
||||
pub executable: String,
|
||||
#[serde(rename = "version_args", default)]
|
||||
pub version_args: Vec<String>,
|
||||
#[serde(rename = "version_regex", default)]
|
||||
pub version_regex: Option<String>,
|
||||
#[serde(rename = "version_unconfirmed", default)]
|
||||
pub version_unconfirmed: Option<bool>,
|
||||
#[serde(rename = "path_hints", default)]
|
||||
pub path_hints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Update {
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Uninstall {
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(rename = "keep_config_default", default = "default_true")]
|
||||
pub keep_config_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Authorization {
|
||||
#[serde(default)]
|
||||
pub modes: Vec<AuthMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthMode {
|
||||
pub mode: String,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(rename = "env_keys", default)]
|
||||
pub env_keys: Vec<String>,
|
||||
#[serde(rename = "status_command", default)]
|
||||
pub status_command: Vec<String>,
|
||||
#[serde(rename = "notes_zh", default)]
|
||||
pub notes_zh: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Configuration {
|
||||
#[serde(default)]
|
||||
pub files: Vec<ConfigFile>,
|
||||
#[serde(default)]
|
||||
pub environment: Vec<EnvMapping>,
|
||||
#[serde(default)]
|
||||
pub fields: Vec<ConfigField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFile {
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EnvMapping {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
#[serde(rename = "maps_to_field", default)]
|
||||
pub maps_to_field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigField {
|
||||
pub id: String,
|
||||
#[serde(rename = "label_zh")]
|
||||
pub label_zh: String,
|
||||
#[serde(rename = "help_zh", default)]
|
||||
pub help_zh: Option<String>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
/// string | url | enum | bool
|
||||
#[serde(rename = "type")]
|
||||
pub field_type: String,
|
||||
/// file | env | keyring
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(rename = "docs_url", default)]
|
||||
pub docs_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Diagnostic {
|
||||
#[serde(rename = "rule_id")]
|
||||
pub rule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Documentation {
|
||||
#[serde(rename = "quickstart_zh", default)]
|
||||
pub quickstart_zh: Option<String>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<DocCommand>,
|
||||
#[serde(rename = "updated_at", default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(rename = "risks_zh", default)]
|
||||
pub risks_zh: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DocCommand {
|
||||
pub cmd: String,
|
||||
#[serde(rename = "desc_zh", default)]
|
||||
pub desc_zh: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 校验 id 是否符合稳定 ID 约定:`^[a-z0-9][a-z0-9-]*$`
|
||||
pub fn is_valid_id(id: &str) -> bool {
|
||||
let mut chars = id.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// 校验 semver 形式(x.y.z,可选 -prerelease / +build),不引入 semver 依赖。
|
||||
pub fn is_valid_semver(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 拆分 build 元数据(+...)
|
||||
let (no_build, build) = match s.split_once('+') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (s, None),
|
||||
};
|
||||
if let Some(b) = build {
|
||||
if !is_semver_ident(b) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 拆分预发布(-...)
|
||||
let (core, pre) = match no_build.split_once('-') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (no_build, None),
|
||||
};
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return false;
|
||||
}
|
||||
for p in &parts {
|
||||
if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(pre) = pre {
|
||||
if !is_semver_ident(pre) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// semver 标识段(预发布/构建元数据):由字母数字与 `-` 组成,点分隔各段非空。
|
||||
fn is_semver_ident(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.split('.').all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// 从 YAML 文本解析并校验适配器(解析失败 → Parse,语义/危险命令 → 对应错误)。
|
||||
pub fn parse_adapter(yaml: &str) -> Result<Adapter, AdapterError> {
|
||||
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
|
||||
adapter.validate()?;
|
||||
Ok(adapter)
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// 语义校验:id / status / 版本号 / 危险命令。
|
||||
/// 校验通过返回 Ok(()),否则返回带中文说明的错误。
|
||||
pub fn validate(&self) -> Result<(), AdapterError> {
|
||||
if self.id.is_empty() {
|
||||
return Err(AdapterError::Validation("id 不能为空".into()));
|
||||
}
|
||||
if !is_valid_id(&self.id) {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"id「{}」非法:只能由小写字母、数字、连字符组成,且以字母或数字开头",
|
||||
self.id
|
||||
)));
|
||||
}
|
||||
if self.status != "available" && self.status != "watch" {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"「{}」的 status 非法: {}(应为 available | watch)",
|
||||
self.id, self.status
|
||||
)));
|
||||
}
|
||||
if let Some(v) = &self.adapter_version {
|
||||
if !is_valid_semver(v) {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"「{}」的 adapter_version 非法: {}(应为 semver,如 1.2.0)",
|
||||
self.id, v
|
||||
)));
|
||||
}
|
||||
}
|
||||
// 危险命令检查:所有 command argv 必须不含 shell 元字符
|
||||
if let Some(offender) = self.find_dangerous_command() {
|
||||
return Err(AdapterError::DangerousCommand(format!(
|
||||
"适配器「{}」声明了含 shell 元字符的命令参数 {:?},已拒绝加载(禁止管道/重定向/命令替换等)",
|
||||
self.id, offender
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 遍历所有 command 数组,返回首个含 shell 元字符的参数。
|
||||
pub fn find_dangerous_command(&self) -> Option<String> {
|
||||
let mut commands: Vec<&Vec<String>> = Vec::new();
|
||||
if let Some(install) = &self.install {
|
||||
for ch in &install.channels {
|
||||
commands.push(&ch.command);
|
||||
}
|
||||
}
|
||||
if let Some(update) = &self.update {
|
||||
commands.push(&update.command);
|
||||
}
|
||||
if let Some(uninstall) = &self.uninstall {
|
||||
commands.push(&uninstall.command);
|
||||
}
|
||||
if let Some(auth) = &self.authorization {
|
||||
for m in &auth.modes {
|
||||
commands.push(&m.command);
|
||||
commands.push(&m.status_command);
|
||||
}
|
||||
}
|
||||
for argv in commands {
|
||||
for arg in argv {
|
||||
if let Some(c) = first_shell_metachar(arg) {
|
||||
return Some(format!("{arg}(元字符 {c:?})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(yaml: &str) -> Result<Adapter, AdapterError> {
|
||||
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
|
||||
adapter.validate()?;
|
||||
Ok(adapter)
|
||||
}
|
||||
|
||||
// ---- 合法 fixture(≥3) ----
|
||||
|
||||
#[test]
|
||||
fn valid_minimal_five_fields() {
|
||||
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
|
||||
assert!(parse(yaml).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_full_schema() {
|
||||
let yaml = r#"
|
||||
id: claude-code
|
||||
name: Claude Code
|
||||
name_zh: Claude Code
|
||||
vendor: Anthropic
|
||||
status: available
|
||||
adapter_version: 1.2.0
|
||||
license: 专有(仅官方渠道安装、不重打包)
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
min_ubuntu: "22.04"
|
||||
official:
|
||||
homepage: https://claude.ai
|
||||
docs: https://docs.anthropic.com
|
||||
allowed_hosts: [claude.ai]
|
||||
runtime_deps:
|
||||
- id: node
|
||||
semver_range: ">=20"
|
||||
required_for: [install]
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, "@anthropic-ai/claude-code"]
|
||||
elevate: never
|
||||
detect:
|
||||
executable: claude
|
||||
version_args: ["--version"]
|
||||
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
notes_zh: 浏览器登录
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.claude/settings.json"
|
||||
format: json
|
||||
scope: user
|
||||
"#;
|
||||
let adapter = parse(yaml).expect("全字段 fixture 应合法");
|
||||
assert_eq!(adapter.adapter_version.as_deref(), Some("1.2.0"));
|
||||
assert_eq!(adapter.install.as_ref().unwrap().channels.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pre_release_semver() {
|
||||
let yaml = "id: warp\nname: Warp Agent CLI\nname_zh: Warp Agent CLI\nvendor: Warp\nstatus: available\nadapter_version: 2.0.0-rc.1\n";
|
||||
assert!(parse(yaml).is_ok());
|
||||
}
|
||||
|
||||
// ---- 非法 fixture(≥3) ----
|
||||
|
||||
#[test]
|
||||
fn invalid_unknown_field_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nbogus_field: 1\n";
|
||||
let adapter: Result<Adapter, _> = serde_yaml::from_str(yaml);
|
||||
assert!(adapter.is_err(), "未知字段应被 deny_unknown_fields 拒绝");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_id_pattern_rejected() {
|
||||
let yaml = "id: Bad_ID!\nname: X\nname_zh: X\nvendor: V\nstatus: available\n";
|
||||
match parse(yaml) {
|
||||
Err(AdapterError::Validation(m)) => assert!(m.contains("id"), "错误应指向 id: {m}"),
|
||||
other => panic!("应返回 Validation 错误,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_status_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
|
||||
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_adapter_version_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nadapter_version: not-a-version\n";
|
||||
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangerous_command_rejected_with_chinese_error() {
|
||||
let yaml = r#"
|
||||
id: x
|
||||
name: X
|
||||
name_zh: X
|
||||
vendor: V
|
||||
status: available
|
||||
install:
|
||||
channels:
|
||||
- id: official_script
|
||||
command: ["curl", "https://x.sh", "|", "sh"]
|
||||
"#;
|
||||
match parse(yaml) {
|
||||
Err(AdapterError::DangerousCommand(m)) => {
|
||||
assert!(m.contains('|'), "错误信息应包含元字符: {m}");
|
||||
assert!(m.contains("已拒绝"), "错误应为中文且明确拒载: {m}");
|
||||
}
|
||||
other => panic!("应返回 DangerousCommand,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_helper() {
|
||||
assert!(is_valid_semver("1.2.0"));
|
||||
assert!(is_valid_semver("0.0.1"));
|
||||
assert!(is_valid_semver("10.20.30-alpha.1+build5"));
|
||||
assert!(!is_valid_semver("1.2"));
|
||||
assert!(!is_valid_semver("1.2.x"));
|
||||
assert!(!is_valid_semver(""));
|
||||
assert!(!is_valid_semver("v1.2.3"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user