Skip to main content

rustfs_targets/
control_plane.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::manifest::{
16    SUPPORTED_PLUGIN_API_COMPATIBILITY_VERSION, TargetPluginDistributionManifest, TargetPluginExternalRuntimeContract,
17    TargetPluginManifest, TargetPluginMarketplaceManifest, TargetPluginPackaging,
18};
19use crate::runtime::sidecar::{SidecarRuntimePolicy, SidecarRuntimeSafetyChecks};
20use crate::runtime::sidecar_protocol::SIDECAR_RUNTIME_PROTOCOL_VERSION;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use url::Url;
24
25const SHA256_HEX_DIGEST_LEN: usize = 64;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TargetPluginInstallState {
30    NotInstalled,
31    Installed,
32    InstallFailed,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum TargetPluginEnableState {
38    Enabled,
39    Disabled,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum TargetPluginRuntimeState {
45    Running,
46    Offline,
47    Error,
48    Unknown,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub struct TargetPluginRevision {
54    pub version: String,
55    pub digest_sha256: Option<String>,
56    pub source: String,
57    pub installed_at: Option<String>,
58    pub artifact_id: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub struct TargetPluginInstallation {
64    pub install_state: TargetPluginInstallState,
65    pub current_revision: Option<TargetPluginRevision>,
66    pub previous_revision: Option<TargetPluginRevision>,
67    pub validation_error: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub struct TargetPluginOperationalState {
73    pub install_state: TargetPluginInstallState,
74    pub enable_state: TargetPluginEnableState,
75    pub runtime_state: TargetPluginRuntimeState,
76}
77
78pub fn builtin_target_plugin_installation(manifest: &TargetPluginManifest) -> TargetPluginInstallation {
79    TargetPluginInstallation {
80        install_state: TargetPluginInstallState::Installed,
81        current_revision: Some(TargetPluginRevision {
82            version: manifest.version.to_string(),
83            digest_sha256: None,
84            source: "builtin".to_string(),
85            installed_at: None,
86            artifact_id: None,
87        }),
88        previous_revision: None,
89        validation_error: None,
90    }
91}
92
93pub fn external_target_plugin_installation(
94    version: impl Into<String>,
95    digest_sha256: impl Into<String>,
96    artifact_id: impl Into<String>,
97    installed_at: Option<String>,
98) -> TargetPluginInstallation {
99    TargetPluginInstallation {
100        install_state: TargetPluginInstallState::Installed,
101        current_revision: Some(TargetPluginRevision {
102            version: version.into(),
103            digest_sha256: Some(digest_sha256.into()),
104            source: "external".to_string(),
105            installed_at,
106            artifact_id: Some(artifact_id.into()),
107        }),
108        previous_revision: None,
109        validation_error: None,
110    }
111}
112
113pub fn failed_external_target_plugin_installation(
114    version: impl Into<String>,
115    artifact_id: impl Into<String>,
116    validation_error: impl Into<String>,
117) -> TargetPluginInstallation {
118    TargetPluginInstallation {
119        install_state: TargetPluginInstallState::InstallFailed,
120        current_revision: Some(TargetPluginRevision {
121            version: version.into(),
122            digest_sha256: None,
123            source: "external".to_string(),
124            installed_at: None,
125            artifact_id: Some(artifact_id.into()),
126        }),
127        previous_revision: None,
128        validation_error: Some(validation_error.into()),
129    }
130}
131
132pub fn rollback_target_plugin_installation(
133    current: TargetPluginRevision,
134    previous: TargetPluginRevision,
135) -> TargetPluginInstallation {
136    TargetPluginInstallation {
137        install_state: TargetPluginInstallState::Installed,
138        current_revision: Some(previous),
139        previous_revision: Some(current),
140        validation_error: None,
141    }
142}
143
144pub fn builtin_target_plugin_operational_state(
145    enabled: bool,
146    runtime_state: TargetPluginRuntimeState,
147) -> TargetPluginOperationalState {
148    TargetPluginOperationalState {
149        install_state: TargetPluginInstallState::Installed,
150        enable_state: if enabled {
151            TargetPluginEnableState::Enabled
152        } else {
153            TargetPluginEnableState::Disabled
154        },
155        runtime_state,
156    }
157}
158
159pub fn runtime_state_from_status_label(status: &str) -> TargetPluginRuntimeState {
160    if status.eq_ignore_ascii_case("online") {
161        TargetPluginRuntimeState::Running
162    } else if status.eq_ignore_ascii_case("offline") {
163        TargetPluginRuntimeState::Offline
164    } else if status.eq_ignore_ascii_case("error") {
165        TargetPluginRuntimeState::Error
166    } else {
167        TargetPluginRuntimeState::Unknown
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum TargetPluginExternalAction {
174    Install,
175    Enable,
176    Disable,
177    Rollback,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub struct TargetPluginExternalActionDecision {
183    pub action: TargetPluginExternalAction,
184    pub plugin_id: String,
185    pub installation: TargetPluginInstallation,
186    pub operational_state: TargetPluginOperationalState,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct TargetPluginExternalFlowGate {
191    pub enabled: bool,
192    pub install_policy: TargetPluginInstallPolicy,
193    pub runtime_policy: SidecarRuntimePolicy,
194    pub runtime_safety_checks: SidecarRuntimeSafetyChecks,
195    pub circuit_breaker_closed: bool,
196}
197
198impl Default for TargetPluginExternalFlowGate {
199    fn default() -> Self {
200        Self {
201            enabled: false,
202            install_policy: TargetPluginInstallPolicy::default(),
203            runtime_policy: SidecarRuntimePolicy::default(),
204            runtime_safety_checks: SidecarRuntimeSafetyChecks {
205                sandboxed: false,
206                provenance_verified: false,
207                queue_depth: 0,
208            },
209            circuit_breaker_closed: false,
210        }
211    }
212}
213
214impl TargetPluginExternalFlowGate {
215    pub fn verified(runtime_policy: SidecarRuntimePolicy, runtime_safety_checks: SidecarRuntimeSafetyChecks) -> Self {
216        Self {
217            enabled: true,
218            install_policy: TargetPluginInstallPolicy::default(),
219            runtime_policy,
220            runtime_safety_checks,
221            circuit_breaker_closed: true,
222        }
223    }
224
225    pub fn status(&self) -> TargetPluginExternalFlowGateStatus {
226        TargetPluginExternalFlowGateStatus {
227            enabled: self.enabled,
228            install_requires_signature: self.install_policy.require_signature,
229            install_requires_provenance: self.install_policy.require_provenance,
230            runtime_allows_external_sidecars: self.runtime_policy.allow_external_sidecars,
231            runtime_requires_sandbox: self.runtime_policy.require_sandbox,
232            runtime_requires_provenance: self.runtime_policy.require_provenance,
233            circuit_breaker_closed: self.circuit_breaker_closed,
234            max_queue_depth: self.runtime_policy.max_queue_depth,
235            failure_threshold: self.runtime_policy.failure_threshold(),
236            redacts_error_details: self.runtime_policy.redact_error_details,
237        }
238    }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub struct TargetPluginExternalFlowGateStatus {
244    pub enabled: bool,
245    pub install_requires_signature: bool,
246    pub install_requires_provenance: bool,
247    pub runtime_allows_external_sidecars: bool,
248    pub runtime_requires_sandbox: bool,
249    pub runtime_requires_provenance: bool,
250    pub circuit_breaker_closed: bool,
251    pub max_queue_depth: usize,
252    pub failure_threshold: usize,
253    pub redacts_error_details: bool,
254}
255
256#[derive(Debug, Error, PartialEq, Eq)]
257pub enum TargetPluginExternalActionError {
258    #[error("external plugin flow is disabled")]
259    ExternalFlowDisabled,
260
261    #[error("plugin {plugin_id} is not an external plugin")]
262    NotExternalPlugin { plugin_id: String },
263
264    #[error("plugin {plugin_id} is not installed")]
265    NotInstalled { plugin_id: String },
266
267    #[error("plugin {plugin_id} has no previous revision for rollback")]
268    MissingPreviousRevision { plugin_id: String },
269
270    #[error("external plugin install policy denied action: {reason}")]
271    InstallPolicyDenied { reason: String },
272
273    #[error("external plugin runtime policy denied action: {reason}")]
274    RuntimePolicyDenied { reason: String },
275
276    #[error("external plugin circuit breaker is open")]
277    CircuitBreakerOpen,
278
279    #[error("external plugin {plugin_id} has no installable artifact for host target triple {target_triple}")]
280    MissingArtifactForHost { plugin_id: String, target_triple: String },
281}
282
283pub fn plan_external_target_plugin_action(
284    manifest: &TargetPluginMarketplaceManifest,
285    action: TargetPluginExternalAction,
286    installation: &TargetPluginInstallation,
287    gate: &TargetPluginExternalFlowGate,
288    host_target_triple: &str,
289) -> Result<TargetPluginExternalActionDecision, TargetPluginExternalActionError> {
290    validate_external_action_subject(manifest)?;
291
292    // The external-plugin flow master switch gates every action.
293    if !gate.enabled {
294        return Err(TargetPluginExternalActionError::ExternalFlowDisabled);
295    }
296
297    // Gate split: the circuit breaker and runtime-activation checks only guard
298    // the *activating* actions (Install/Enable). Disable and Rollback are
299    // break-glass remediation and must stay available precisely when the
300    // breaker is open — otherwise a failing plugin can never be stopped or
301    // rolled back.
302    match action {
303        TargetPluginExternalAction::Install => {
304            validate_external_activation_gate(gate)?;
305            plan_external_install(manifest, action, installation, gate, host_target_triple)
306        }
307        TargetPluginExternalAction::Enable => {
308            validate_external_activation_gate(gate)?;
309            require_installed(manifest.plugin_id, installation)?;
310            Ok(TargetPluginExternalActionDecision {
311                action,
312                plugin_id: manifest.plugin_id.to_string(),
313                installation: installation.clone(),
314                operational_state: TargetPluginOperationalState {
315                    install_state: TargetPluginInstallState::Installed,
316                    enable_state: TargetPluginEnableState::Enabled,
317                    runtime_state: TargetPluginRuntimeState::Running,
318                },
319            })
320        }
321        TargetPluginExternalAction::Disable => {
322            require_installed(manifest.plugin_id, installation)?;
323            Ok(TargetPluginExternalActionDecision {
324                action,
325                plugin_id: manifest.plugin_id.to_string(),
326                installation: installation.clone(),
327                operational_state: TargetPluginOperationalState {
328                    install_state: TargetPluginInstallState::Installed,
329                    enable_state: TargetPluginEnableState::Disabled,
330                    runtime_state: TargetPluginRuntimeState::Offline,
331                },
332            })
333        }
334        TargetPluginExternalAction::Rollback => plan_external_rollback(manifest, action, installation),
335    }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct TargetPluginInstallPolicy {
340    pub allowed_providers: Vec<String>,
341    pub allowed_download_hosts: Vec<String>,
342    pub require_https: bool,
343    pub require_signature: bool,
344    pub require_provenance: bool,
345}
346
347impl Default for TargetPluginInstallPolicy {
348    fn default() -> Self {
349        Self {
350            allowed_providers: vec!["rustfs".to_string(), "rustfs-labs".to_string()],
351            // Deny-by-default: operators must explicitly allow the hosts
352            // artifacts may be downloaded from before any install can plan.
353            allowed_download_hosts: Vec::new(),
354            require_https: true,
355            require_signature: true,
356            require_provenance: true,
357        }
358    }
359}
360
361pub fn validate_external_plugin_installation(
362    manifest: &TargetPluginManifest,
363    runtime_contract: &TargetPluginExternalRuntimeContract,
364    distribution: Option<TargetPluginDistributionManifest>,
365    policy: &TargetPluginInstallPolicy,
366) -> Result<(), String> {
367    if !policy.allowed_providers.iter().any(|provider| provider == manifest.provider) {
368        return Err(format!("provider {} is not allowed by install policy", manifest.provider));
369    }
370
371    // Enforce the sidecar runtime protocol version for *every* external
372    // transport. Gating this behind a specific transport let an external
373    // plugin skip the check simply by declaring a different transport.
374    if runtime_contract.protocol_version != SIDECAR_RUNTIME_PROTOCOL_VERSION {
375        return Err(format!(
376            "sidecar runtime protocol mismatch: expected {}, got {}",
377            SIDECAR_RUNTIME_PROTOCOL_VERSION, runtime_contract.protocol_version
378        ));
379    }
380
381    let distribution = distribution.ok_or_else(|| "external plugin is missing distribution metadata".to_string())?;
382    if distribution.artifacts.is_empty() {
383        return Err("external plugin distribution has no artifacts".to_string());
384    }
385
386    for artifact in distribution.artifacts {
387        let parsed_uri = Url::parse(artifact.download_uri)
388            .map_err(|err| format!("invalid artifact download uri {}: {}", artifact.download_uri, err))?;
389        if policy.require_https && parsed_uri.scheme() != "https" {
390            return Err(format!(
391                "artifact {} must use https download uri, got {}",
392                artifact.artifact_id, artifact.download_uri
393            ));
394        }
395        let authority = uri_host_authority(&parsed_uri)
396            .ok_or_else(|| format!("artifact {} download uri has no host", artifact.artifact_id))?;
397        if !policy.allowed_download_hosts.iter().any(|allowed| allowed == &authority) {
398            return Err(format!("artifact {} download host {} is not allowed", artifact.artifact_id, authority));
399        }
400        if artifact.size_bytes == 0 {
401            return Err(format!("artifact {} must declare a non-zero size", artifact.artifact_id));
402        }
403        if artifact.digest_sha256.len() != SHA256_HEX_DIGEST_LEN
404            || !artifact.digest_sha256.chars().all(|ch| ch.is_ascii_hexdigit())
405        {
406            return Err(format!(
407                "artifact {} has invalid digest_sha256 {} (expected {} hex characters)",
408                artifact.artifact_id, artifact.digest_sha256, SHA256_HEX_DIGEST_LEN
409            ));
410        }
411        if policy.require_signature && artifact.signature_uri.is_empty() {
412            return Err(format!("artifact {} must declare a signature uri", artifact.artifact_id));
413        }
414        if policy.require_provenance && artifact.provenance_uri.is_empty() {
415            return Err(format!("artifact {} must declare a provenance uri", artifact.artifact_id));
416        }
417        validate_artifact_uri("signature", artifact.artifact_id, artifact.signature_uri, policy)?;
418        validate_artifact_uri("provenance", artifact.artifact_id, artifact.provenance_uri, policy)?;
419    }
420
421    Ok(())
422}
423
424fn validate_external_action_subject(manifest: &TargetPluginMarketplaceManifest) -> Result<(), TargetPluginExternalActionError> {
425    if manifest.packaging != TargetPluginPackaging::External {
426        return Err(TargetPluginExternalActionError::NotExternalPlugin {
427            plugin_id: manifest.plugin_id.to_string(),
428        });
429    }
430
431    Ok(())
432}
433
434/// Activation gate for Install/Enable only: circuit breaker plus runtime policy
435/// activation. Callers must have already checked `gate.enabled`.
436fn validate_external_activation_gate(gate: &TargetPluginExternalFlowGate) -> Result<(), TargetPluginExternalActionError> {
437    if !gate.circuit_breaker_closed {
438        return Err(TargetPluginExternalActionError::CircuitBreakerOpen);
439    }
440    gate.runtime_policy
441        .validate_activation(&gate.runtime_safety_checks)
442        .map_err(|reason| TargetPluginExternalActionError::RuntimePolicyDenied {
443            reason: reason.to_string(),
444        })
445}
446
447fn plan_external_install(
448    manifest: &TargetPluginMarketplaceManifest,
449    action: TargetPluginExternalAction,
450    installation: &TargetPluginInstallation,
451    gate: &TargetPluginExternalFlowGate,
452    host_target_triple: &str,
453) -> Result<TargetPluginExternalActionDecision, TargetPluginExternalActionError> {
454    // The plugin API compatibility version is validated at planning time so an
455    // artifact built against an unsupported contract can never be installed.
456    if manifest.api_compatibility_version != SUPPORTED_PLUGIN_API_COMPATIBILITY_VERSION {
457        return Err(TargetPluginExternalActionError::InstallPolicyDenied {
458            reason: format!(
459                "plugin api compatibility version mismatch: expected {}, got {}",
460                SUPPORTED_PLUGIN_API_COMPATIBILITY_VERSION, manifest.api_compatibility_version
461            ),
462        });
463    }
464
465    let install_manifest = TargetPluginManifest {
466        plugin_id: manifest.plugin_id,
467        display_name: manifest.display_name,
468        provider: manifest.provider,
469        version: manifest.version,
470        target_type: manifest.target_type,
471        supported_domains: manifest.supported_domains,
472        secret_fields: manifest.secret_fields,
473    };
474    validate_external_plugin_installation(
475        &install_manifest,
476        &manifest.runtime_contract,
477        manifest.distribution,
478        &gate.install_policy,
479    )
480    .map_err(|reason| TargetPluginExternalActionError::InstallPolicyDenied { reason })?;
481
482    let artifact = manifest
483        .distribution
484        .and_then(|distribution| {
485            distribution
486                .artifacts
487                .iter()
488                .find(|artifact| artifact.target_triple == host_target_triple)
489        })
490        .ok_or_else(|| TargetPluginExternalActionError::MissingArtifactForHost {
491            plugin_id: manifest.plugin_id.to_string(),
492            target_triple: host_target_triple.to_string(),
493        })?;
494
495    let mut new_installation =
496        external_target_plugin_installation(manifest.version, artifact.digest_sha256, artifact.artifact_id, None);
497    // Preserve the currently installed revision as `previous_revision` so a
498    // later Rollback can restore it. Dropping it would make rollback a no-op.
499    if installation.install_state == TargetPluginInstallState::Installed
500        && let Some(current) = installation.current_revision.clone()
501    {
502        new_installation.previous_revision = Some(current);
503    }
504
505    Ok(TargetPluginExternalActionDecision {
506        action,
507        plugin_id: manifest.plugin_id.to_string(),
508        installation: new_installation,
509        operational_state: TargetPluginOperationalState {
510            install_state: TargetPluginInstallState::Installed,
511            enable_state: TargetPluginEnableState::Disabled,
512            runtime_state: TargetPluginRuntimeState::Offline,
513        },
514    })
515}
516
517fn require_installed(plugin_id: &str, installation: &TargetPluginInstallation) -> Result<(), TargetPluginExternalActionError> {
518    if installation.install_state != TargetPluginInstallState::Installed || installation.current_revision.is_none() {
519        return Err(TargetPluginExternalActionError::NotInstalled {
520            plugin_id: plugin_id.to_string(),
521        });
522    }
523
524    Ok(())
525}
526
527fn plan_external_rollback(
528    manifest: &TargetPluginMarketplaceManifest,
529    action: TargetPluginExternalAction,
530    installation: &TargetPluginInstallation,
531) -> Result<TargetPluginExternalActionDecision, TargetPluginExternalActionError> {
532    require_installed(manifest.plugin_id, installation)?;
533
534    let Some(current) = installation.current_revision.clone() else {
535        return Err(TargetPluginExternalActionError::NotInstalled {
536            plugin_id: manifest.plugin_id.to_string(),
537        });
538    };
539    let Some(previous) = installation.previous_revision.clone() else {
540        return Err(TargetPluginExternalActionError::MissingPreviousRevision {
541            plugin_id: manifest.plugin_id.to_string(),
542        });
543    };
544
545    Ok(TargetPluginExternalActionDecision {
546        action,
547        plugin_id: manifest.plugin_id.to_string(),
548        installation: rollback_target_plugin_installation(current, previous),
549        operational_state: TargetPluginOperationalState {
550            install_state: TargetPluginInstallState::Installed,
551            enable_state: TargetPluginEnableState::Disabled,
552            runtime_state: TargetPluginRuntimeState::Offline,
553        },
554    })
555}
556
557fn validate_artifact_uri(label: &str, artifact_id: &str, uri: &str, policy: &TargetPluginInstallPolicy) -> Result<(), String> {
558    if uri.is_empty() {
559        return Ok(());
560    }
561
562    let parsed_uri = Url::parse(uri).map_err(|err| format!("invalid artifact {label} uri {uri}: {err}"))?;
563    if policy.require_https && parsed_uri.scheme() != "https" {
564        return Err(format!("artifact {artifact_id} must use https {label} uri, got {uri}"));
565    }
566    let authority = uri_host_authority(&parsed_uri).ok_or_else(|| format!("artifact {artifact_id} {label} uri has no host"))?;
567    if !policy.allowed_download_hosts.iter().any(|allowed| allowed == &authority) {
568        return Err(format!("artifact {artifact_id} {label} host {authority} is not allowed"));
569    }
570
571    Ok(())
572}
573
574/// Returns the host authority used for allowlist matching. When the URI carries
575/// an explicit port the port is part of the authority, so that an allowlisted
576/// `host` never implicitly authorizes a different `host:port`. A default-port
577/// URI (`port()` is `None`) matches a bare `host` entry.
578fn uri_host_authority(parsed_uri: &Url) -> Option<String> {
579    parsed_uri.host_str().map(|host| match parsed_uri.port() {
580        Some(port) => format!("{host}:{port}"),
581        None => host.to_string(),
582    })
583}
584
585#[cfg(test)]
586mod tests {
587    use super::{
588        TargetPluginEnableState, TargetPluginExternalAction, TargetPluginExternalActionError, TargetPluginExternalFlowGate,
589        TargetPluginInstallPolicy, TargetPluginInstallState, TargetPluginInstallation, TargetPluginRevision,
590        TargetPluginRuntimeState, builtin_target_plugin_installation, builtin_target_plugin_operational_state,
591        external_target_plugin_installation, failed_external_target_plugin_installation, plan_external_target_plugin_action,
592        rollback_target_plugin_installation, runtime_state_from_status_label, validate_external_plugin_installation,
593    };
594    use crate::catalog::example_external_webhook_plugin;
595    use crate::manifest::{
596        TargetPluginArtifactManifest, TargetPluginDistributionManifest, TargetPluginExternalRuntimeContract,
597        TargetPluginManifest, TargetPluginRuntimeTransport, builtin_target_manifest, builtin_target_marketplace_manifest,
598    };
599    use crate::{SidecarRuntimePolicy, SidecarRuntimeSafetyChecks};
600    use std::time::Duration;
601
602    const TEST_HOST_TRIPLE: &str = "x86_64-unknown-linux-gnu";
603
604    fn policy_allowing_example_host() -> TargetPluginInstallPolicy {
605        TargetPluginInstallPolicy {
606            allowed_download_hosts: vec!["plugins.example.test".to_string()],
607            ..TargetPluginInstallPolicy::default()
608        }
609    }
610
611    fn verified_gate_with_example_host() -> TargetPluginExternalFlowGate {
612        let mut gate = TargetPluginExternalFlowGate::verified(
613            SidecarRuntimePolicy::verified_external(16, Duration::from_secs(5), 3),
614            SidecarRuntimeSafetyChecks::verified(0),
615        );
616        gate.install_policy = policy_allowing_example_host();
617        gate
618    }
619
620    #[test]
621    fn builtin_installation_maps_to_virtual_installed_revision() {
622        let installation = builtin_target_plugin_installation(&builtin_target_manifest("webhook"));
623
624        assert_eq!(installation.install_state, TargetPluginInstallState::Installed);
625        assert_eq!(
626            installation
627                .current_revision
628                .as_ref()
629                .expect("builtin installation should expose current revision")
630                .source,
631            "builtin"
632        );
633        assert_eq!(
634            installation
635                .current_revision
636                .as_ref()
637                .expect("builtin installation should expose current revision")
638                .artifact_id,
639            None
640        );
641        assert!(installation.previous_revision.is_none());
642        assert_eq!(installation.validation_error, None);
643    }
644
645    #[test]
646    fn builtin_operational_state_tracks_enablement_and_runtime() {
647        let enabled = builtin_target_plugin_operational_state(true, TargetPluginRuntimeState::Running);
648        let disabled = builtin_target_plugin_operational_state(false, TargetPluginRuntimeState::Offline);
649
650        assert_eq!(enabled.install_state, TargetPluginInstallState::Installed);
651        assert_eq!(enabled.enable_state, TargetPluginEnableState::Enabled);
652        assert_eq!(enabled.runtime_state, TargetPluginRuntimeState::Running);
653
654        assert_eq!(disabled.enable_state, TargetPluginEnableState::Disabled);
655        assert_eq!(disabled.runtime_state, TargetPluginRuntimeState::Offline);
656    }
657
658    #[test]
659    fn runtime_state_from_status_maps_known_labels() {
660        assert_eq!(runtime_state_from_status_label("online"), TargetPluginRuntimeState::Running);
661        assert_eq!(runtime_state_from_status_label("offline"), TargetPluginRuntimeState::Offline);
662        assert_eq!(runtime_state_from_status_label("error"), TargetPluginRuntimeState::Error);
663        assert_eq!(runtime_state_from_status_label("unexpected"), TargetPluginRuntimeState::Unknown);
664    }
665
666    #[test]
667    fn external_installation_captures_revision_metadata() {
668        let installation = external_target_plugin_installation(
669            "1.2.3",
670            "0123456789abcdef",
671            "sidecar-linux-amd64",
672            Some("2026-05-13T12:00:00Z".to_string()),
673        );
674
675        let revision = installation
676            .current_revision
677            .as_ref()
678            .expect("external installation should expose current revision");
679        assert_eq!(installation.install_state, TargetPluginInstallState::Installed);
680        assert_eq!(revision.source, "external");
681        assert_eq!(revision.digest_sha256.as_deref(), Some("0123456789abcdef"));
682        assert_eq!(revision.artifact_id.as_deref(), Some("sidecar-linux-amd64"));
683        assert_eq!(installation.validation_error, None);
684    }
685
686    #[test]
687    fn rollback_swaps_current_and_previous_revisions() {
688        let current = TargetPluginRevision {
689            version: "2.0.0".to_string(),
690            digest_sha256: Some("new-digest".to_string()),
691            source: "external".to_string(),
692            installed_at: Some("2026-05-13T12:05:00Z".to_string()),
693            artifact_id: Some("sidecar-linux-amd64-v2".to_string()),
694        };
695        let previous = TargetPluginRevision {
696            version: "1.9.0".to_string(),
697            digest_sha256: Some("old-digest".to_string()),
698            source: "external".to_string(),
699            installed_at: Some("2026-05-13T11:55:00Z".to_string()),
700            artifact_id: Some("sidecar-linux-amd64-v1".to_string()),
701        };
702
703        let installation = rollback_target_plugin_installation(current.clone(), previous.clone());
704
705        assert_eq!(installation.current_revision, Some(previous));
706        assert_eq!(installation.previous_revision, Some(current));
707        assert_eq!(installation.validation_error, None);
708    }
709
710    #[test]
711    fn failed_external_installation_preserves_error_context() {
712        let installation =
713            failed_external_target_plugin_installation("1.2.3", "sidecar-linux-amd64", "digest mismatch during install");
714
715        assert_eq!(installation.install_state, TargetPluginInstallState::InstallFailed);
716        assert_eq!(installation.validation_error.as_deref(), Some("digest mismatch during install"));
717    }
718
719    #[test]
720    fn external_action_gate_is_disabled_by_default() {
721        let example = example_external_webhook_plugin();
722        let gate = TargetPluginExternalFlowGate::default();
723
724        let result = plan_external_target_plugin_action(
725            &example.manifest,
726            TargetPluginExternalAction::Install,
727            &TargetPluginInstallation {
728                install_state: TargetPluginInstallState::NotInstalled,
729                current_revision: None,
730                previous_revision: None,
731                validation_error: None,
732            },
733            &gate,
734            TEST_HOST_TRIPLE,
735        );
736
737        assert_eq!(result, Err(TargetPluginExternalActionError::ExternalFlowDisabled));
738        assert!(!gate.status().enabled);
739        assert!(gate.status().install_requires_signature);
740        assert!(gate.status().install_requires_provenance);
741    }
742
743    #[test]
744    fn external_action_rejects_builtin_manifest() {
745        let gate = TargetPluginExternalFlowGate::verified(
746            SidecarRuntimePolicy::verified_external(16, Duration::from_secs(5), 3),
747            SidecarRuntimeSafetyChecks::verified(0),
748        );
749
750        let result = plan_external_target_plugin_action(
751            &builtin_target_marketplace_manifest("webhook"),
752            TargetPluginExternalAction::Install,
753            &TargetPluginInstallation {
754                install_state: TargetPluginInstallState::NotInstalled,
755                current_revision: None,
756                previous_revision: None,
757                validation_error: None,
758            },
759            &gate,
760            TEST_HOST_TRIPLE,
761        );
762
763        assert_eq!(
764            result,
765            Err(TargetPluginExternalActionError::NotExternalPlugin {
766                plugin_id: "builtin:webhook".to_string()
767            })
768        );
769    }
770
771    #[test]
772    fn external_action_install_requires_signature_and_provenance() {
773        const MISSING_PROVENANCE_ARTIFACTS: &[TargetPluginArtifactManifest] = &[TargetPluginArtifactManifest {
774            artifact_id: "sidecar-linux-amd64",
775            target_triple: "x86_64-unknown-linux-gnu",
776            download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
777            digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
778            signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
779            provenance_uri: "",
780            size_bytes: 8192,
781        }];
782
783        let mut example = example_external_webhook_plugin();
784        example.manifest.distribution = Some(TargetPluginDistributionManifest {
785            artifacts: MISSING_PROVENANCE_ARTIFACTS,
786        });
787        let gate = verified_gate_with_example_host();
788
789        let result = plan_external_target_plugin_action(
790            &example.manifest,
791            TargetPluginExternalAction::Install,
792            &TargetPluginInstallation {
793                install_state: TargetPluginInstallState::NotInstalled,
794                current_revision: None,
795                previous_revision: None,
796                validation_error: None,
797            },
798            &gate,
799            TEST_HOST_TRIPLE,
800        );
801
802        assert_eq!(
803            result,
804            Err(TargetPluginExternalActionError::InstallPolicyDenied {
805                reason: "artifact sidecar-linux-amd64 must declare a provenance uri".to_string()
806            })
807        );
808    }
809
810    #[test]
811    fn external_action_enable_requires_sandbox_and_provenance() {
812        let example = example_external_webhook_plugin();
813        let gate = TargetPluginExternalFlowGate::verified(
814            SidecarRuntimePolicy::verified_external(16, Duration::from_secs(5), 3),
815            SidecarRuntimeSafetyChecks {
816                sandboxed: false,
817                provenance_verified: true,
818                queue_depth: 0,
819            },
820        );
821
822        let result = plan_external_target_plugin_action(
823            &example.manifest,
824            TargetPluginExternalAction::Enable,
825            &example.installation,
826            &gate,
827            TEST_HOST_TRIPLE,
828        );
829
830        assert_eq!(
831            result,
832            Err(TargetPluginExternalActionError::RuntimePolicyDenied {
833                reason: "sidecar runtime requires sandbox isolation".to_string()
834            })
835        );
836    }
837
838    #[test]
839    fn external_action_enable_requires_closed_circuit_breaker() {
840        let example = example_external_webhook_plugin();
841        let mut gate = TargetPluginExternalFlowGate::verified(
842            SidecarRuntimePolicy::verified_external(16, Duration::from_secs(5), 3),
843            SidecarRuntimeSafetyChecks::verified(0),
844        );
845        gate.circuit_breaker_closed = false;
846
847        let result = plan_external_target_plugin_action(
848            &example.manifest,
849            TargetPluginExternalAction::Enable,
850            &example.installation,
851            &gate,
852            TEST_HOST_TRIPLE,
853        );
854
855        assert_eq!(result, Err(TargetPluginExternalActionError::CircuitBreakerOpen));
856    }
857
858    #[test]
859    fn external_actions_plan_install_disable_and_rollback_without_execution() {
860        let example = example_external_webhook_plugin();
861        let gate = verified_gate_with_example_host();
862
863        let install = plan_external_target_plugin_action(
864            &example.manifest,
865            TargetPluginExternalAction::Install,
866            &TargetPluginInstallation {
867                install_state: TargetPluginInstallState::NotInstalled,
868                current_revision: None,
869                previous_revision: None,
870                validation_error: None,
871            },
872            &gate,
873            TEST_HOST_TRIPLE,
874        )
875        .expect("verified external install action should plan");
876        assert_eq!(install.installation.install_state, TargetPluginInstallState::Installed);
877        assert_eq!(install.operational_state.enable_state, TargetPluginEnableState::Disabled);
878        assert_eq!(install.operational_state.runtime_state, TargetPluginRuntimeState::Offline);
879
880        let disable = plan_external_target_plugin_action(
881            &example.manifest,
882            TargetPluginExternalAction::Disable,
883            &example.installation,
884            &gate,
885            TEST_HOST_TRIPLE,
886        )
887        .expect("verified external disable action should plan");
888        assert_eq!(disable.operational_state.enable_state, TargetPluginEnableState::Disabled);
889        assert_eq!(disable.operational_state.runtime_state, TargetPluginRuntimeState::Offline);
890
891        let current = TargetPluginRevision {
892            version: "2.0.0".to_string(),
893            digest_sha256: Some("new-digest".to_string()),
894            source: "external".to_string(),
895            installed_at: Some("2026-05-13T12:05:00Z".to_string()),
896            artifact_id: Some("sidecar-linux-amd64-v2".to_string()),
897        };
898        let previous = TargetPluginRevision {
899            version: "1.9.0".to_string(),
900            digest_sha256: Some("old-digest".to_string()),
901            source: "external".to_string(),
902            installed_at: Some("2026-05-13T11:55:00Z".to_string()),
903            artifact_id: Some("sidecar-linux-amd64-v1".to_string()),
904        };
905        let rollback = plan_external_target_plugin_action(
906            &example.manifest,
907            TargetPluginExternalAction::Rollback,
908            &TargetPluginInstallation {
909                install_state: TargetPluginInstallState::Installed,
910                current_revision: Some(current.clone()),
911                previous_revision: Some(previous.clone()),
912                validation_error: None,
913            },
914            &gate,
915            TEST_HOST_TRIPLE,
916        )
917        .expect("verified external rollback action should plan");
918
919        assert_eq!(rollback.installation.current_revision, Some(previous));
920        assert_eq!(rollback.installation.previous_revision, Some(current));
921        assert_eq!(rollback.operational_state.enable_state, TargetPluginEnableState::Disabled);
922    }
923
924    #[test]
925    fn validate_external_installation_accepts_allowed_https_artifact() {
926        let manifest = TargetPluginManifest {
927            plugin_id: "external:webhook-sidecar",
928            display_name: "Webhook Sidecar",
929            provider: "rustfs-labs",
930            version: "1.0.0",
931            target_type: "webhook",
932            supported_domains: &[],
933            secret_fields: &[],
934        };
935        let distribution = TargetPluginDistributionManifest {
936            artifacts: &[TargetPluginArtifactManifest {
937                artifact_id: "sidecar-linux-amd64",
938                target_triple: "x86_64-unknown-linux-gnu",
939                download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
940                digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
941                signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
942                provenance_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.intoto.jsonl",
943                size_bytes: 8192,
944            }],
945        };
946        let policy = policy_allowing_example_host();
947
948        let result = validate_external_plugin_installation(
949            &manifest,
950            &TargetPluginExternalRuntimeContract {
951                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
952                transport: TargetPluginRuntimeTransport::Grpc,
953            },
954            Some(distribution),
955            &policy,
956        );
957
958        assert!(result.is_ok());
959    }
960
961    #[test]
962    fn default_install_policy_denies_all_download_hosts() {
963        let policy = TargetPluginInstallPolicy::default();
964        assert!(policy.allowed_download_hosts.is_empty());
965
966        let manifest = TargetPluginManifest {
967            plugin_id: "external:webhook-sidecar",
968            display_name: "Webhook Sidecar",
969            provider: "rustfs-labs",
970            version: "1.0.0",
971            target_type: "webhook",
972            supported_domains: &[],
973            secret_fields: &[],
974        };
975        let result = validate_external_plugin_installation(
976            &manifest,
977            &TargetPluginExternalRuntimeContract {
978                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
979                transport: TargetPluginRuntimeTransport::Grpc,
980            },
981            Some(TargetPluginDistributionManifest {
982                artifacts: &[TargetPluginArtifactManifest {
983                    artifact_id: "sidecar-linux-amd64",
984                    target_triple: "x86_64-unknown-linux-gnu",
985                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
986                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
987                    signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
988                    provenance_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.intoto.jsonl",
989                    size_bytes: 8192,
990                }],
991            }),
992            &policy,
993        );
994
995        assert_eq!(
996            result.as_ref().map_err(String::as_str),
997            Err("artifact sidecar-linux-amd64 download host plugins.example.test is not allowed")
998        );
999    }
1000
1001    #[test]
1002    fn validate_external_installation_rejects_truncated_digest() {
1003        let manifest = TargetPluginManifest {
1004            plugin_id: "external:webhook-sidecar",
1005            display_name: "Webhook Sidecar",
1006            provider: "rustfs-labs",
1007            version: "1.0.0",
1008            target_type: "webhook",
1009            supported_domains: &[],
1010            secret_fields: &[],
1011        };
1012        let result = validate_external_plugin_installation(
1013            &manifest,
1014            &TargetPluginExternalRuntimeContract {
1015                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
1016                transport: TargetPluginRuntimeTransport::Grpc,
1017            },
1018            Some(TargetPluginDistributionManifest {
1019                artifacts: &[TargetPluginArtifactManifest {
1020                    artifact_id: "sidecar-linux-amd64",
1021                    target_triple: "x86_64-unknown-linux-gnu",
1022                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
1023                    digest_sha256: "0123456789abcdef0123456789abcdef",
1024                    signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
1025                    provenance_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.intoto.jsonl",
1026                    size_bytes: 8192,
1027                }],
1028            }),
1029            &policy_allowing_example_host(),
1030        );
1031
1032        assert_eq!(
1033            result.as_ref().map_err(String::as_str),
1034            Err(
1035                "artifact sidecar-linux-amd64 has invalid digest_sha256 0123456789abcdef0123456789abcdef (expected 64 hex characters)"
1036            )
1037        );
1038    }
1039
1040    #[test]
1041    fn external_install_requires_artifact_for_host_target_triple() {
1042        let example = example_external_webhook_plugin();
1043        let gate = verified_gate_with_example_host();
1044
1045        let result = plan_external_target_plugin_action(
1046            &example.manifest,
1047            TargetPluginExternalAction::Install,
1048            &TargetPluginInstallation {
1049                install_state: TargetPluginInstallState::NotInstalled,
1050                current_revision: None,
1051                previous_revision: None,
1052                validation_error: None,
1053            },
1054            &gate,
1055            "aarch64-apple-darwin",
1056        );
1057
1058        assert_eq!(
1059            result,
1060            Err(TargetPluginExternalActionError::MissingArtifactForHost {
1061                plugin_id: "external:webhook-sidecar".to_string(),
1062                target_triple: "aarch64-apple-darwin".to_string(),
1063            })
1064        );
1065    }
1066
1067    #[test]
1068    fn validate_external_installation_rejects_disallowed_provider() {
1069        let manifest = TargetPluginManifest {
1070            plugin_id: "external:webhook-sidecar",
1071            display_name: "Webhook Sidecar",
1072            provider: "unknown-vendor",
1073            version: "1.0.0",
1074            target_type: "webhook",
1075            supported_domains: &[],
1076            secret_fields: &[],
1077        };
1078        let policy = policy_allowing_example_host();
1079
1080        let result = validate_external_plugin_installation(
1081            &manifest,
1082            &TargetPluginExternalRuntimeContract {
1083                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
1084                transport: TargetPluginRuntimeTransport::Grpc,
1085            },
1086            Some(TargetPluginDistributionManifest {
1087                artifacts: &[TargetPluginArtifactManifest {
1088                    artifact_id: "sidecar-linux-amd64",
1089                    target_triple: "x86_64-unknown-linux-gnu",
1090                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
1091                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1092                    signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
1093                    provenance_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.intoto.jsonl",
1094                    size_bytes: 8192,
1095                }],
1096            }),
1097            &policy,
1098        );
1099
1100        assert!(result.is_err());
1101    }
1102
1103    #[test]
1104    fn validate_external_installation_rejects_missing_artifact_signature() {
1105        let manifest = TargetPluginManifest {
1106            plugin_id: "external:webhook-sidecar",
1107            display_name: "Webhook Sidecar",
1108            provider: "rustfs-labs",
1109            version: "1.0.0",
1110            target_type: "webhook",
1111            supported_domains: &[],
1112            secret_fields: &[],
1113        };
1114        let policy = policy_allowing_example_host();
1115
1116        let result = validate_external_plugin_installation(
1117            &manifest,
1118            &TargetPluginExternalRuntimeContract {
1119                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
1120                transport: TargetPluginRuntimeTransport::Grpc,
1121            },
1122            Some(TargetPluginDistributionManifest {
1123                artifacts: &[TargetPluginArtifactManifest {
1124                    artifact_id: "sidecar-linux-amd64",
1125                    target_triple: "x86_64-unknown-linux-gnu",
1126                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
1127                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1128                    signature_uri: "",
1129                    provenance_uri: "https://plugins.example.test/webhook-sidecar.intoto.jsonl",
1130                    size_bytes: 8192,
1131                }],
1132            }),
1133            &policy,
1134        );
1135
1136        assert_eq!(
1137            result.as_ref().map_err(String::as_str),
1138            Err("artifact sidecar-linux-amd64 must declare a signature uri")
1139        );
1140    }
1141
1142    #[test]
1143    fn validate_external_installation_rejects_missing_artifact_provenance() {
1144        let manifest = TargetPluginManifest {
1145            plugin_id: "external:webhook-sidecar",
1146            display_name: "Webhook Sidecar",
1147            provider: "rustfs-labs",
1148            version: "1.0.0",
1149            target_type: "webhook",
1150            supported_domains: &[],
1151            secret_fields: &[],
1152        };
1153        let policy = policy_allowing_example_host();
1154
1155        let result = validate_external_plugin_installation(
1156            &manifest,
1157            &TargetPluginExternalRuntimeContract {
1158                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
1159                transport: TargetPluginRuntimeTransport::Grpc,
1160            },
1161            Some(TargetPluginDistributionManifest {
1162                artifacts: &[TargetPluginArtifactManifest {
1163                    artifact_id: "sidecar-linux-amd64",
1164                    target_triple: "x86_64-unknown-linux-gnu",
1165                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
1166                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1167                    signature_uri: "https://plugins.example.test/webhook-sidecar.sig",
1168                    provenance_uri: "",
1169                    size_bytes: 8192,
1170                }],
1171            }),
1172            &policy,
1173        );
1174
1175        assert_eq!(
1176            result.as_ref().map_err(String::as_str),
1177            Err("artifact sidecar-linux-amd64 must declare a provenance uri")
1178        );
1179    }
1180
1181    #[test]
1182    fn install_carries_forward_previous_revision_for_rollback() {
1183        let example = example_external_webhook_plugin();
1184        let gate = verified_gate_with_example_host();
1185
1186        let existing_revision = TargetPluginRevision {
1187            version: "0.9.0".to_string(),
1188            digest_sha256: Some("old-digest".to_string()),
1189            source: "external".to_string(),
1190            installed_at: Some("2026-05-13T10:00:00Z".to_string()),
1191            artifact_id: Some("sidecar-linux-amd64-old".to_string()),
1192        };
1193        let existing = TargetPluginInstallation {
1194            install_state: TargetPluginInstallState::Installed,
1195            current_revision: Some(existing_revision.clone()),
1196            previous_revision: None,
1197            validation_error: None,
1198        };
1199
1200        let install = plan_external_target_plugin_action(
1201            &example.manifest,
1202            TargetPluginExternalAction::Install,
1203            &existing,
1204            &gate,
1205            TEST_HOST_TRIPLE,
1206        )
1207        .expect("install over an existing revision should plan");
1208
1209        // The freshly installed revision becomes current; the prior revision is
1210        // preserved so a later Rollback can restore it.
1211        assert_eq!(install.installation.current_revision.as_ref().map(|r| r.version.as_str()), Some("1.0.0"));
1212        assert_eq!(install.installation.previous_revision, Some(existing_revision));
1213    }
1214
1215    #[test]
1216    fn disable_is_allowed_while_circuit_breaker_is_open() {
1217        let example = example_external_webhook_plugin();
1218        let mut gate = verified_gate_with_example_host();
1219        gate.circuit_breaker_closed = false;
1220
1221        let disable = plan_external_target_plugin_action(
1222            &example.manifest,
1223            TargetPluginExternalAction::Disable,
1224            &example.installation,
1225            &gate,
1226            TEST_HOST_TRIPLE,
1227        )
1228        .expect("disable must remain available while the breaker is open");
1229
1230        assert_eq!(disable.operational_state.enable_state, TargetPluginEnableState::Disabled);
1231        assert_eq!(disable.operational_state.runtime_state, TargetPluginRuntimeState::Offline);
1232    }
1233
1234    #[test]
1235    fn rollback_is_allowed_while_circuit_breaker_is_open() {
1236        let example = example_external_webhook_plugin();
1237        let mut gate = verified_gate_with_example_host();
1238        gate.circuit_breaker_closed = false;
1239
1240        let current = TargetPluginRevision {
1241            version: "2.0.0".to_string(),
1242            digest_sha256: Some("new-digest".to_string()),
1243            source: "external".to_string(),
1244            installed_at: Some("2026-05-13T12:05:00Z".to_string()),
1245            artifact_id: Some("sidecar-linux-amd64-v2".to_string()),
1246        };
1247        let previous = TargetPluginRevision {
1248            version: "1.9.0".to_string(),
1249            digest_sha256: Some("old-digest".to_string()),
1250            source: "external".to_string(),
1251            installed_at: Some("2026-05-13T11:55:00Z".to_string()),
1252            artifact_id: Some("sidecar-linux-amd64-v1".to_string()),
1253        };
1254
1255        let rollback = plan_external_target_plugin_action(
1256            &example.manifest,
1257            TargetPluginExternalAction::Rollback,
1258            &TargetPluginInstallation {
1259                install_state: TargetPluginInstallState::Installed,
1260                current_revision: Some(current.clone()),
1261                previous_revision: Some(previous.clone()),
1262                validation_error: None,
1263            },
1264            &gate,
1265            TEST_HOST_TRIPLE,
1266        )
1267        .expect("rollback must remain available while the breaker is open");
1268
1269        assert_eq!(rollback.installation.current_revision, Some(previous));
1270        assert_eq!(rollback.installation.previous_revision, Some(current));
1271    }
1272
1273    #[test]
1274    fn install_is_still_blocked_by_open_circuit_breaker() {
1275        let example = example_external_webhook_plugin();
1276        let mut gate = verified_gate_with_example_host();
1277        gate.circuit_breaker_closed = false;
1278
1279        let result = plan_external_target_plugin_action(
1280            &example.manifest,
1281            TargetPluginExternalAction::Install,
1282            &TargetPluginInstallation {
1283                install_state: TargetPluginInstallState::NotInstalled,
1284                current_revision: None,
1285                previous_revision: None,
1286                validation_error: None,
1287            },
1288            &gate,
1289            TEST_HOST_TRIPLE,
1290        );
1291
1292        assert_eq!(result, Err(TargetPluginExternalActionError::CircuitBreakerOpen));
1293    }
1294
1295    #[test]
1296    fn external_install_rejects_api_compatibility_mismatch() {
1297        let mut example = example_external_webhook_plugin();
1298        example.manifest.api_compatibility_version = "rustfs.target-plugin.v0";
1299        let gate = verified_gate_with_example_host();
1300
1301        let result = plan_external_target_plugin_action(
1302            &example.manifest,
1303            TargetPluginExternalAction::Install,
1304            &TargetPluginInstallation {
1305                install_state: TargetPluginInstallState::NotInstalled,
1306                current_revision: None,
1307                previous_revision: None,
1308                validation_error: None,
1309            },
1310            &gate,
1311            TEST_HOST_TRIPLE,
1312        );
1313
1314        assert_eq!(
1315            result,
1316            Err(TargetPluginExternalActionError::InstallPolicyDenied {
1317                reason:
1318                    "plugin api compatibility version mismatch: expected rustfs.target-plugin.v1, got rustfs.target-plugin.v0"
1319                        .to_string()
1320            })
1321        );
1322    }
1323
1324    #[test]
1325    fn external_install_enforces_protocol_version_for_non_grpc_transport() {
1326        let manifest = TargetPluginManifest {
1327            plugin_id: "external:webhook-sidecar",
1328            display_name: "Webhook Sidecar",
1329            provider: "rustfs-labs",
1330            version: "1.0.0",
1331            target_type: "webhook",
1332            supported_domains: &[],
1333            secret_fields: &[],
1334        };
1335
1336        // A non-gRPC transport must not be able to skip the protocol check.
1337        let result = validate_external_plugin_installation(
1338            &manifest,
1339            &TargetPluginExternalRuntimeContract {
1340                protocol_version: "rustfs.target-runtime.v0",
1341                transport: TargetPluginRuntimeTransport::WasmHost,
1342            },
1343            Some(TargetPluginDistributionManifest {
1344                artifacts: &[TargetPluginArtifactManifest {
1345                    artifact_id: "sidecar-linux-amd64",
1346                    target_triple: "x86_64-unknown-linux-gnu",
1347                    download_uri: "https://plugins.example.test/webhook-sidecar.tar.zst",
1348                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1349                    signature_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.sig",
1350                    provenance_uri: "https://plugins.example.test/webhook-sidecar.tar.zst.intoto.jsonl",
1351                    size_bytes: 8192,
1352                }],
1353            }),
1354            &policy_allowing_example_host(),
1355        );
1356
1357        assert_eq!(
1358            result.as_ref().map_err(String::as_str),
1359            Err("sidecar runtime protocol mismatch: expected rustfs.target-runtime.v1, got rustfs.target-runtime.v0")
1360        );
1361    }
1362
1363    #[test]
1364    fn download_host_allowlist_distinguishes_explicit_port() {
1365        let manifest = TargetPluginManifest {
1366            plugin_id: "external:webhook-sidecar",
1367            display_name: "Webhook Sidecar",
1368            provider: "rustfs-labs",
1369            version: "1.0.0",
1370            target_type: "webhook",
1371            supported_domains: &[],
1372            secret_fields: &[],
1373        };
1374        // Allowlist authorizes the bare host (default port), but the artifact is
1375        // served from an explicit non-default port — it must not match.
1376        let result = validate_external_plugin_installation(
1377            &manifest,
1378            &TargetPluginExternalRuntimeContract {
1379                protocol_version: crate::SIDECAR_RUNTIME_PROTOCOL_VERSION,
1380                transport: TargetPluginRuntimeTransport::Grpc,
1381            },
1382            Some(TargetPluginDistributionManifest {
1383                artifacts: &[TargetPluginArtifactManifest {
1384                    artifact_id: "sidecar-linux-amd64",
1385                    target_triple: "x86_64-unknown-linux-gnu",
1386                    download_uri: "https://plugins.example.test:8443/webhook-sidecar.tar.zst",
1387                    digest_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
1388                    signature_uri: "https://plugins.example.test:8443/webhook-sidecar.tar.zst.sig",
1389                    provenance_uri: "https://plugins.example.test:8443/webhook-sidecar.tar.zst.intoto.jsonl",
1390                    size_bytes: 8192,
1391                }],
1392            }),
1393            &policy_allowing_example_host(),
1394        );
1395
1396        assert_eq!(
1397            result.as_ref().map_err(String::as_str),
1398            Err("artifact sidecar-linux-amd64 download host plugins.example.test:8443 is not allowed")
1399        );
1400    }
1401}