1use schemars::JsonSchema;
2use semver::{Version, VersionReq};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7pub struct ExecutablePluginManifestContractV1 {
8 pub namespace: String,
10 pub version: String,
12 pub entrypoint: String,
14 pub capabilities: Vec<String>,
16 pub trust_class: String,
18 pub commands: Vec<String>,
20 pub compatibility_window: String,
22}
23
24pub fn validate_executable_plugin_manifest_contract(
26 payload: &ExecutablePluginManifestContractV1,
27) -> Result<(), String> {
28 if payload.namespace.trim().is_empty() {
29 return Err("namespace cannot be empty".to_string());
30 }
31 if payload.entrypoint.trim().is_empty() {
32 return Err("entrypoint cannot be empty".to_string());
33 }
34 if payload.commands.is_empty() {
35 return Err("commands cannot be empty".to_string());
36 }
37 if payload.capabilities.is_empty() {
38 return Err("capabilities cannot be empty".to_string());
39 }
40 if payload.trust_class.trim().is_empty() {
41 return Err("trust_class cannot be empty".to_string());
42 }
43 Version::parse(&payload.version).map_err(|error| format!("invalid version: {error}"))?;
44 VersionReq::parse(&payload.compatibility_window)
45 .map_err(|error| format!("invalid compatibility_window: {error}"))?;
46 Ok(())
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct PluginSubprocessExecutionPolicyV1 {
52 pub argv: Vec<String>,
54 pub timeout_ms: u64,
56 pub env_allowlist: Vec<String>,
58 pub working_directory_policy: String,
60 pub output_envelope_schema: String,
62}
63
64pub fn validate_plugin_subprocess_execution_policy(
66 payload: &PluginSubprocessExecutionPolicyV1,
67) -> Result<(), String> {
68 if payload.argv.is_empty() {
69 return Err("argv cannot be empty".to_string());
70 }
71 if payload.argv.iter().any(|arg| arg.contains('\n') || arg.contains('\0')) {
72 return Err("argv contains invalid control characters".to_string());
73 }
74 if payload.timeout_ms == 0 {
75 return Err("timeout_ms must be greater than zero".to_string());
76 }
77 if payload.env_allowlist.iter().any(|key| key.trim().is_empty()) {
78 return Err("env_allowlist cannot include blank keys".to_string());
79 }
80 if !matches!(
81 payload.working_directory_policy.as_str(),
82 "workspace-root" | "plugin-root" | "isolated-temp"
83 ) {
84 return Err("working_directory_policy is invalid".to_string());
85 }
86 if payload.output_envelope_schema.trim().is_empty() {
87 return Err("output_envelope_schema cannot be empty".to_string());
88 }
89 Ok(())
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94pub struct PluginScaffoldConformanceEntryV1 {
95 pub language: String,
97 pub build_ok: bool,
99 pub discovered_by_root_cli: bool,
101 pub executable_with_valid_envelope: bool,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107pub struct PluginScaffoldConformanceReportV1 {
108 pub entries: Vec<PluginScaffoldConformanceEntryV1>,
110 pub fully_conformant: bool,
112}
113
114pub fn build_plugin_scaffold_conformance_report(
116 entries: Vec<PluginScaffoldConformanceEntryV1>,
117) -> Result<PluginScaffoldConformanceReportV1, String> {
118 if entries.is_empty() {
119 return Err("entries cannot be empty".to_string());
120 }
121 let mut ordered = entries;
122 ordered.sort_by(|left, right| left.language.cmp(&right.language));
123 let fully_conformant = ordered.iter().all(|entry| {
124 entry.build_ok && entry.discovered_by_root_cli && entry.executable_with_valid_envelope
125 });
126 Ok(PluginScaffoldConformanceReportV1 { entries: ordered, fully_conformant })
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131pub struct OfficialAppDescriptorCompatibilityInputV1 {
132 pub host_version: String,
134 pub app_version: String,
136 pub host_compatibility_window: String,
138 pub lifecycle_state: String,
140 pub command_surfaces: Vec<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146pub struct OfficialAppDescriptorCompatibilityReportV1 {
147 pub compatible: bool,
149 pub message: String,
151}
152
153pub fn evaluate_official_app_descriptor_compatibility(
155 payload: &OfficialAppDescriptorCompatibilityInputV1,
156) -> Result<OfficialAppDescriptorCompatibilityReportV1, String> {
157 let host = Version::parse(&payload.host_version)
158 .map_err(|error| format!("invalid host_version: {error}"))?;
159 Version::parse(&payload.app_version)
160 .map_err(|error| format!("invalid app_version: {error}"))?;
161 let requirement = VersionReq::parse(&payload.host_compatibility_window)
162 .map_err(|error| format!("invalid host_compatibility_window: {error}"))?;
163 if payload.command_surfaces.is_empty() {
164 return Err("command_surfaces cannot be empty".to_string());
165 }
166 if payload.lifecycle_state == "disabled" {
167 return Ok(OfficialAppDescriptorCompatibilityReportV1 {
168 compatible: false,
169 message: "app is disabled; use migration route".to_string(),
170 });
171 }
172 if requirement.matches(&host) {
173 Ok(OfficialAppDescriptorCompatibilityReportV1 {
174 compatible: true,
175 message: "descriptor compatible with host runtime".to_string(),
176 })
177 } else {
178 Ok(OfficialAppDescriptorCompatibilityReportV1 {
179 compatible: false,
180 message: "host version outside compatibility window; migrate app descriptor"
181 .to_string(),
182 })
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct LegacyShimPolicyDecisionV1 {
189 pub shim_command: String,
191 pub canonical_command: String,
193 pub decision: String,
195 pub message: String,
197}
198
199pub fn evaluate_legacy_shim_policy(
201 shim_command: &str,
202 canonical_command: &str,
203 shim_mode: &str,
204) -> Result<LegacyShimPolicyDecisionV1, String> {
205 if shim_command.trim().is_empty() || canonical_command.trim().is_empty() {
206 return Err("shim_command and canonical_command cannot be empty".to_string());
207 }
208 let (decision, message) = match shim_mode {
209 "supported" => {
210 ("supported", "legacy shim is temporarily supported; prefer canonical command")
211 }
212 "warned" => ("warned", "legacy shim is deprecated; migrate to canonical command"),
213 "refused" => ("refused", "legacy shim refused; use canonical command"),
214 _ => return Err("shim_mode must be supported, warned, or refused".to_string()),
215 };
216 Ok(LegacyShimPolicyDecisionV1 {
217 shim_command: shim_command.to_string(),
218 canonical_command: canonical_command.to_string(),
219 decision: decision.to_string(),
220 message: message.to_string(),
221 })
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
226pub struct RouteConflictContenderV1 {
227 pub source: String,
229 pub target: String,
231 pub priority: i32,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
237pub struct RouteConflictResolutionV1 {
238 pub route_key: String,
240 pub winner: Option<RouteConflictContenderV1>,
242 pub refusal_reason: Option<String>,
244}
245
246pub fn resolve_route_conflict_deterministically(
248 route_key: &str,
249 contenders: Vec<RouteConflictContenderV1>,
250) -> Result<RouteConflictResolutionV1, String> {
251 if route_key.trim().is_empty() {
252 return Err("route_key cannot be empty".to_string());
253 }
254 if contenders.is_empty() {
255 return Ok(RouteConflictResolutionV1 {
256 route_key: route_key.to_string(),
257 winner: None,
258 refusal_reason: Some("no contenders registered".to_string()),
259 });
260 }
261 let mut ordered = contenders;
262 ordered.sort_by(|left, right| {
263 right
264 .priority
265 .cmp(&left.priority)
266 .then_with(|| left.source.cmp(&right.source))
267 .then_with(|| left.target.cmp(&right.target))
268 });
269 let winner = ordered.first().cloned().expect("contenders not empty");
270 let same_rank: Vec<&RouteConflictContenderV1> = ordered
271 .iter()
272 .filter(|candidate| {
273 candidate.priority == winner.priority && candidate.target != winner.target
274 })
275 .collect();
276 if same_rank.is_empty() {
277 Ok(RouteConflictResolutionV1 {
278 route_key: route_key.to_string(),
279 winner: Some(winner),
280 refusal_reason: None,
281 })
282 } else {
283 Ok(RouteConflictResolutionV1 {
284 route_key: route_key.to_string(),
285 winner: None,
286 refusal_reason: Some("priority tie with conflicting targets".to_string()),
287 })
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
293pub struct AppRouteProvenanceRecordV1 {
294 pub route_path: String,
296 pub descriptor_hash: String,
298 pub handler_identity: String,
300 pub output_schema: String,
302}
303
304pub fn build_app_route_provenance_record(
306 route_path: &str,
307 descriptor_hash: &str,
308 handler_identity: &str,
309 output_schema: &str,
310) -> Result<AppRouteProvenanceRecordV1, String> {
311 for (field, value) in [
312 ("route_path", route_path),
313 ("descriptor_hash", descriptor_hash),
314 ("handler_identity", handler_identity),
315 ("output_schema", output_schema),
316 ] {
317 if value.trim().is_empty() {
318 return Err(format!("{field} cannot be empty"));
319 }
320 }
321 Ok(AppRouteProvenanceRecordV1 {
322 route_path: route_path.to_string(),
323 descriptor_hash: descriptor_hash.to_string(),
324 handler_identity: handler_identity.to_string(),
325 output_schema: output_schema.to_string(),
326 })
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331pub struct SdkExampleConformanceEntryV1 {
332 pub language: String,
334 pub exposes_inspectable_commands: bool,
336 pub supports_config_contract: bool,
338 pub supports_error_contract: bool,
340 pub emits_machine_output_envelope: bool,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
346pub struct SdkExampleConformanceReportV1 {
347 pub entries: Vec<SdkExampleConformanceEntryV1>,
349 pub fully_conformant: bool,
351}
352
353pub fn build_sdk_example_conformance_report(
355 entries: Vec<SdkExampleConformanceEntryV1>,
356) -> Result<SdkExampleConformanceReportV1, String> {
357 if entries.is_empty() {
358 return Err("entries cannot be empty".to_string());
359 }
360 let mut ordered = entries;
361 ordered.sort_by(|left, right| left.language.cmp(&right.language));
362 let fully_conformant = ordered.iter().all(|entry| {
363 entry.exposes_inspectable_commands
364 && entry.supports_config_contract
365 && entry.supports_error_contract
366 && entry.emits_machine_output_envelope
367 });
368 Ok(SdkExampleConformanceReportV1 { entries: ordered, fully_conformant })
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
373pub struct PluginTrustEnforcementDecisionV1 {
374 pub trust_class: String,
376 pub command_risk: String,
378 pub allowed: bool,
380 pub rationale: String,
382}
383
384pub fn enforce_plugin_trust_class_behavior(
386 trust_class: &str,
387 command_risk: &str,
388 experimental_destructive_enabled: bool,
389) -> Result<PluginTrustEnforcementDecisionV1, String> {
390 if trust_class.trim().is_empty() || command_risk.trim().is_empty() {
391 return Err("trust_class and command_risk cannot be empty".to_string());
392 }
393 let decision = match (trust_class, command_risk) {
394 ("disabled", _) => (false, "plugin trust class is disabled"),
395 ("experimental", "destructive") if !experimental_destructive_enabled => {
396 (false, "experimental destructive command requires explicit enable flag")
397 }
398 ("experimental", "destructive") => (true, "experimental destructive override enabled"),
399 (_, _) => (true, "trust policy allows command"),
400 };
401 Ok(PluginTrustEnforcementDecisionV1 {
402 trust_class: trust_class.to_string(),
403 command_risk: command_risk.to_string(),
404 allowed: decision.0,
405 rationale: decision.1.to_string(),
406 })
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
411pub struct AppCapabilityDiscoveryReportV1 {
412 pub app_namespace: String,
414 pub command_groups: Vec<String>,
416 pub feature_flags: Vec<String>,
418 pub required_config_keys: Vec<String>,
420 pub schema_versions: Vec<String>,
422 pub missing_prerequisites: Vec<String>,
424}
425
426pub fn build_app_capability_discovery_report(
428 app_namespace: &str,
429 command_groups: Vec<String>,
430 feature_flags: Vec<String>,
431 required_config_keys: Vec<String>,
432 schema_versions: Vec<String>,
433 missing_prerequisites: Vec<String>,
434) -> Result<AppCapabilityDiscoveryReportV1, String> {
435 if app_namespace.trim().is_empty() {
436 return Err("app_namespace cannot be empty".to_string());
437 }
438 if command_groups.is_empty() {
439 return Err("command_groups cannot be empty".to_string());
440 }
441 if schema_versions.is_empty() {
442 return Err("schema_versions cannot be empty".to_string());
443 }
444 Ok(AppCapabilityDiscoveryReportV1 {
445 app_namespace: app_namespace.to_string(),
446 command_groups,
447 feature_flags,
448 required_config_keys,
449 schema_versions,
450 missing_prerequisites,
451 })
452}
453
454#[cfg(test)]
455mod tests {
456 use super::{
457 build_app_capability_discovery_report, build_app_route_provenance_record,
458 build_plugin_scaffold_conformance_report, build_sdk_example_conformance_report,
459 enforce_plugin_trust_class_behavior, evaluate_legacy_shim_policy,
460 evaluate_official_app_descriptor_compatibility, resolve_route_conflict_deterministically,
461 validate_executable_plugin_manifest_contract, validate_plugin_subprocess_execution_policy,
462 ExecutablePluginManifestContractV1, LegacyShimPolicyDecisionV1,
463 OfficialAppDescriptorCompatibilityInputV1, PluginScaffoldConformanceEntryV1,
464 PluginSubprocessExecutionPolicyV1, PluginTrustEnforcementDecisionV1,
465 RouteConflictContenderV1, SdkExampleConformanceEntryV1,
466 };
467
468 #[test]
469 fn plugin_manifest_contract_refuses_invalid_compatibility_window() {
470 let manifest = ExecutablePluginManifestContractV1 {
471 namespace: "community-tools".to_string(),
472 version: "1.2.0".to_string(),
473 entrypoint: "plugin:main".to_string(),
474 capabilities: vec!["inspect".to_string(), "validate".to_string()],
475 trust_class: "local".to_string(),
476 commands: vec!["community lint".to_string()],
477 compatibility_window: "not-semver".to_string(),
478 };
479 assert!(validate_executable_plugin_manifest_contract(&manifest).is_err());
480 }
481
482 #[test]
483 fn plugin_subprocess_policy_refuses_invalid_working_directory_policy() {
484 let policy = PluginSubprocessExecutionPolicyV1 {
485 argv: vec!["plugin-bin".to_string(), "run".to_string()],
486 timeout_ms: 30_000,
487 env_allowlist: vec!["BIJUX_CONFIG_ROOT".to_string()],
488 working_directory_policy: "home-directory".to_string(),
489 output_envelope_schema: "command-envelope-v1".to_string(),
490 };
491 assert!(validate_plugin_subprocess_execution_policy(&policy).is_err());
492 }
493
494 #[test]
495 fn plugin_scaffold_conformance_requires_discovery_and_envelope_validity() {
496 let report = build_plugin_scaffold_conformance_report(vec![
497 PluginScaffoldConformanceEntryV1 {
498 language: "rust".to_string(),
499 build_ok: true,
500 discovered_by_root_cli: true,
501 executable_with_valid_envelope: true,
502 },
503 PluginScaffoldConformanceEntryV1 {
504 language: "python".to_string(),
505 build_ok: true,
506 discovered_by_root_cli: false,
507 executable_with_valid_envelope: true,
508 },
509 ])
510 .expect("conformance report should build");
511 assert!(!report.fully_conformant);
512 }
513
514 #[test]
515 fn descriptor_compatibility_reports_version_window_mismatch() {
516 let report = evaluate_official_app_descriptor_compatibility(
517 &OfficialAppDescriptorCompatibilityInputV1 {
518 host_version: "0.4.0".to_string(),
519 app_version: "1.4.0".to_string(),
520 host_compatibility_window: ">=0.5,<0.6".to_string(),
521 lifecycle_state: "active".to_string(),
522 command_surfaces: vec!["dag run".to_string()],
523 },
524 )
525 .expect("compatibility report should build");
526 assert!(!report.compatible);
527 }
528
529 #[test]
530 fn legacy_shim_policy_warns_with_canonical_route() {
531 let decision: LegacyShimPolicyDecisionV1 =
532 evaluate_legacy_shim_policy("bijux-dag", "bijux dag", "warned")
533 .expect("shim decision should build");
534 assert_eq!(decision.decision, "warned");
535 assert_eq!(decision.canonical_command, "bijux dag");
536 }
537
538 #[test]
539 fn route_conflict_resolution_is_deterministic_by_priority() {
540 let resolution = resolve_route_conflict_deterministically(
541 "dag run",
542 vec![
543 RouteConflictContenderV1 {
544 source: "plugin".to_string(),
545 target: "plugin.dag.run".to_string(),
546 priority: 10,
547 },
548 RouteConflictContenderV1 {
549 source: "official-app".to_string(),
550 target: "official.dag.run".to_string(),
551 priority: 100,
552 },
553 ],
554 )
555 .expect("conflict resolution should succeed");
556 assert_eq!(resolution.winner.expect("winner").target, "official.dag.run");
557 assert!(resolution.refusal_reason.is_none());
558 }
559
560 #[test]
561 fn route_provenance_record_captures_handler_and_descriptor_hash() {
562 let record = build_app_route_provenance_record(
563 "dag run",
564 "sha256:abc123",
565 "bijux-dag-cli::dag::run",
566 "dag-run-envelope-v1",
567 )
568 .expect("provenance record should build");
569 assert_eq!(record.descriptor_hash, "sha256:abc123");
570 assert_eq!(record.handler_identity, "bijux-dag-cli::dag::run");
571 }
572
573 #[test]
574 fn sdk_example_conformance_requires_config_and_error_contracts() {
575 let report = build_sdk_example_conformance_report(vec![
576 SdkExampleConformanceEntryV1 {
577 language: "rust".to_string(),
578 exposes_inspectable_commands: true,
579 supports_config_contract: true,
580 supports_error_contract: true,
581 emits_machine_output_envelope: true,
582 },
583 SdkExampleConformanceEntryV1 {
584 language: "python".to_string(),
585 exposes_inspectable_commands: true,
586 supports_config_contract: false,
587 supports_error_contract: true,
588 emits_machine_output_envelope: true,
589 },
590 ])
591 .expect("sdk conformance should build");
592 assert!(!report.fully_conformant);
593 }
594
595 #[test]
596 fn experimental_destructive_plugin_command_is_blocked_without_override() {
597 let decision: PluginTrustEnforcementDecisionV1 =
598 enforce_plugin_trust_class_behavior("experimental", "destructive", false)
599 .expect("trust decision should build");
600 assert!(!decision.allowed);
601 assert!(decision.rationale.contains("requires explicit enable flag"));
602 }
603
604 #[test]
605 fn app_capability_discovery_reports_missing_optional_prerequisites() {
606 let report = build_app_capability_discovery_report(
607 "dag",
608 vec!["run".to_string(), "plan".to_string()],
609 vec!["cache".to_string()],
610 vec!["dag.run_root".to_string()],
611 vec!["dag-run-envelope-v1".to_string()],
612 vec!["apptainer-not-installed".to_string()],
613 )
614 .expect("capability report should build");
615 assert_eq!(report.app_namespace, "dag");
616 assert_eq!(report.missing_prerequisites, vec!["apptainer-not-installed"]);
617 }
618}