feat(sync): 完成 S-08 数据分类
This commit is contained in:
@@ -101,6 +101,60 @@ struct Preferences {
|
||||
markdown: Markdown,
|
||||
presets: Vec<Preset>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConversationMessage {
|
||||
message_id: String,
|
||||
parent_message_id: Option<String>,
|
||||
role: String,
|
||||
content: String,
|
||||
thinking: Option<String>,
|
||||
attachments: Vec<String>,
|
||||
created_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Conversation {
|
||||
title: String,
|
||||
active_leaf: Option<String>,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
messages: Vec<ConversationMessage>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AgentHistory {
|
||||
status: String,
|
||||
input: String,
|
||||
output: Option<String>,
|
||||
model: String,
|
||||
skill_id: Option<String>,
|
||||
error_code: Option<String>,
|
||||
error_message: Option<String>,
|
||||
token_usage: u64,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProviderSettings {
|
||||
version: u64,
|
||||
provider_type: String,
|
||||
name: String,
|
||||
base_url: Option<String>,
|
||||
default_model: Option<String>,
|
||||
enabled: bool,
|
||||
capabilities: Vec<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExtensionInstallation {
|
||||
package_kind: String,
|
||||
package_id: String,
|
||||
source: String,
|
||||
version: String,
|
||||
sha256: String,
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
|
||||
serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))
|
||||
}
|
||||
@@ -138,6 +192,44 @@ fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool
|
||||
.enumerate()
|
||||
.all(|(index, value)| !values[..index].contains(value))
|
||||
}
|
||||
fn bounded_identifier(value: &str, max_chars: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b))
|
||||
}
|
||||
fn portable_reference(value: &str, max_chars: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& !value.starts_with('/')
|
||||
&& !value.contains(['\\', '\0'])
|
||||
&& value
|
||||
.split('/')
|
||||
.all(|part| !part.is_empty() && part != "." && part != "..")
|
||||
}
|
||||
fn model_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= 256
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/+-".contains(&byte))
|
||||
}
|
||||
fn timestamp(value: i64) -> bool {
|
||||
(0..=253402300799999).contains(&value)
|
||||
}
|
||||
fn safe_base_url(value: &str) -> bool {
|
||||
let Some(rest) = value
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| value.strip_prefix("http://"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!rest.is_empty()
|
||||
&& !rest.bytes().any(|byte| byte.is_ascii_control())
|
||||
&& !rest.contains(['@', '?', '#', '\\'])
|
||||
&& rest.split('/').next().is_some_and(|host| !host.is_empty())
|
||||
}
|
||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
let valid = match kind {
|
||||
"persona" => {
|
||||
@@ -252,6 +344,138 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
&& markdown(&p.preferences)
|
||||
})
|
||||
}
|
||||
"conversation" => {
|
||||
let value: Conversation = decode(value)?;
|
||||
let message_id = |id: &str| bounded_identifier(id, 128);
|
||||
!value.title.trim().is_empty()
|
||||
&& value.title.chars().count() <= 120
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
&& value.messages.len() <= 2_000
|
||||
&& value.active_leaf.as_deref().is_none_or(|id| {
|
||||
message_id(id) && value.messages.iter().any(|v| v.message_id == id)
|
||||
})
|
||||
&& value.messages.iter().enumerate().all(|(index, message)| {
|
||||
message_id(&message.message_id)
|
||||
&& !value.messages[..index]
|
||||
.iter()
|
||||
.any(|previous| previous.message_id == message.message_id)
|
||||
&& message.parent_message_id.as_deref().is_none_or(|id| {
|
||||
message_id(id)
|
||||
&& id != message.message_id
|
||||
&& value
|
||||
.messages
|
||||
.iter()
|
||||
.any(|candidate| candidate.message_id == id)
|
||||
})
|
||||
&& matches!(message.role.as_str(), "user" | "assistant" | "system")
|
||||
&& message.content.chars().count() <= 262_144
|
||||
&& message
|
||||
.thinking
|
||||
.as_ref()
|
||||
.is_none_or(|text| text.chars().count() <= 262_144)
|
||||
&& message.attachments.len() <= 64
|
||||
&& message
|
||||
.attachments
|
||||
.iter()
|
||||
.all(|path| portable_reference(path, 512))
|
||||
&& message
|
||||
.attachments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, path)| !message.attachments[..index].contains(path))
|
||||
&& timestamp(message.created_at_ms)
|
||||
})
|
||||
}
|
||||
"agent_history" => {
|
||||
let value: AgentHistory = decode(value)?;
|
||||
matches!(value.status.as_str(), "completed" | "failed" | "cancelled")
|
||||
&& value.input.chars().count() <= 262_144
|
||||
&& value
|
||||
.output
|
||||
.as_ref()
|
||||
.is_none_or(|text| text.chars().count() <= 524_288)
|
||||
&& model_identifier(&value.model)
|
||||
&& value
|
||||
.skill_id
|
||||
.as_deref()
|
||||
.is_none_or(|id| bounded_identifier(id, 128))
|
||||
&& value
|
||||
.error_code
|
||||
.as_deref()
|
||||
.is_none_or(|code| bounded_identifier(code, 128))
|
||||
&& value
|
||||
.error_message
|
||||
.as_ref()
|
||||
.is_none_or(|message| message.chars().count() <= 4_096)
|
||||
&& value.token_usage <= 9_007_199_254_740_991
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
}
|
||||
"provider_settings" => {
|
||||
let value: ProviderSettings = decode(value)?;
|
||||
let _ = value.enabled;
|
||||
let known_capabilities = [
|
||||
"chat",
|
||||
"vision",
|
||||
"tool_calling",
|
||||
"reasoning",
|
||||
"streaming",
|
||||
"structured_output",
|
||||
"embedding",
|
||||
"transcription",
|
||||
"speaker_matching",
|
||||
];
|
||||
value.version <= 9_007_199_254_740_991
|
||||
&& matches!(
|
||||
value.provider_type.as_str(),
|
||||
"mock"
|
||||
| "openai_responses"
|
||||
| "openai_chat"
|
||||
| "openai_compatible"
|
||||
| "anthropic_messages"
|
||||
| "ollama"
|
||||
)
|
||||
&& !value.name.trim().is_empty()
|
||||
&& value.name.chars().count() <= 128
|
||||
&& value
|
||||
.base_url
|
||||
.as_deref()
|
||||
.is_none_or(|url| url.len() <= 2_048 && safe_base_url(url))
|
||||
&& value.default_model.as_deref().is_none_or(model_identifier)
|
||||
&& unique_bounded(&value.capabilities, 16, 64)
|
||||
&& value
|
||||
.capabilities
|
||||
.iter()
|
||||
.all(|capability| known_capabilities.contains(&capability.as_str()))
|
||||
}
|
||||
"extension_installation" => {
|
||||
let value: ExtensionInstallation = decode(value)?;
|
||||
matches!(value.package_kind.as_str(), "skill" | "plugin" | "theme")
|
||||
&& bounded_identifier(&value.package_id, 128)
|
||||
&& !value.source.is_empty()
|
||||
&& value.source.len() <= 512
|
||||
&& value
|
||||
.source
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
|
||||
&& !value.source.contains("..")
|
||||
&& !value.source.contains('@')
|
||||
&& !value.source.contains(['?', '#', '\\'])
|
||||
&& !value.version.is_empty()
|
||||
&& value.version.len() <= 128
|
||||
&& value
|
||||
.version
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte))
|
||||
&& value.sha256.len() == 64
|
||||
&& value
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
if valid {
|
||||
@@ -347,4 +571,71 @@ mod tests {
|
||||
"RECORD_DATA_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s08_optional_schemas_are_strict_and_installations_cannot_authorize() {
|
||||
let fixtures = [
|
||||
(
|
||||
"conversation",
|
||||
"conversation_00000000000000000000000000000001",
|
||||
json!({"title":"Review","active_leaf":"message_1","created_at_ms":1,"updated_at_ms":2,"messages":[{"message_id":"message_1","parent_message_id":null,"role":"user","content":"Hello","thinking":null,"attachments":[],"created_at_ms":1}]}),
|
||||
),
|
||||
(
|
||||
"agent_history",
|
||||
"agent_run_00000000000000000000000000000001",
|
||||
json!({"status":"completed","input":"Review","output":"Done","model":"gpt-5.6","skill_id":null,"error_code":null,"error_message":null,"token_usage":42,"created_at_ms":1,"updated_at_ms":2}),
|
||||
),
|
||||
(
|
||||
"provider_settings",
|
||||
"provider_00000000000000000000000000000001",
|
||||
json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat","tool_calling"]}),
|
||||
),
|
||||
(
|
||||
"extension_installation",
|
||||
"extension_00000000000000000000000000000001",
|
||||
json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)}),
|
||||
),
|
||||
];
|
||||
for (kind, id, data) in fixtures {
|
||||
let path = crate::records::path_for(kind, id).unwrap();
|
||||
let record = json!({"schema":1,"kind":kind,"id":id,"data":data});
|
||||
crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap();
|
||||
assert!(crate::records::allowed(&path));
|
||||
assert!(!crate::sync_scope::OptionalScope::default().includes(&path));
|
||||
let mut unknown = record.clone();
|
||||
unknown["data"]["api_key"] = json!("must-never-sync");
|
||||
assert_eq!(
|
||||
crate::records::validate(&path, &serde_json::to_vec(&unknown).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
let installation = json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)});
|
||||
for forbidden in [
|
||||
"permissions",
|
||||
"granted_permissions",
|
||||
"enabled",
|
||||
"trusted",
|
||||
"device_grants",
|
||||
"package_path",
|
||||
] {
|
||||
let mut invalid = installation.clone();
|
||||
invalid[forbidden] = json!(true);
|
||||
assert_eq!(
|
||||
validate("extension_installation", &invalid)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut provider = json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat"]});
|
||||
provider["credential_id"] = json!("device-secret-reference");
|
||||
assert_eq!(
|
||||
validate("provider_settings", &provider).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user