Skip to main content

runmat_package/source/
server_policy.rs

1use super::{SourceAcquisitionIntent, SourceAcquisitionPolicy, SourceLockAction};
2use crate::ServerProjectSourceId;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8pub enum ServerSnapshotSelector {
9    Exact { value: String },
10    Tag { value: String },
11}
12
13impl ServerSnapshotSelector {
14    pub fn from_manifest(value: Option<&str>) -> Result<Self, ServerPolicyError> {
15        let value = value.unwrap_or("main").trim();
16        validate_selector(value)?;
17        Ok(if is_exact_snapshot_id(value)? {
18            Self::Exact {
19                value: value.to_string(),
20            }
21        } else {
22            Self::Tag {
23                value: value.to_string(),
24            }
25        })
26    }
27
28    pub fn value(&self) -> &str {
29        match self {
30            Self::Exact { value } | Self::Tag { value } => value,
31        }
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ServerProjectAcquisitionPlan {
38    pub service: String,
39    pub project: String,
40    pub selector: ServerSnapshotSelector,
41    pub allow_network: bool,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub expected: Option<ServerProjectSourceId>,
44    pub lock_action: SourceLockAction,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Error)]
48pub enum ServerPolicyError {
49    #[error("Server snapshot selector is invalid")]
50    InvalidSelector,
51    #[error("frozen mode cannot update mutable Server snapshot sources")]
52    FrozenUpdate,
53    #[error("locked mode cannot update mutable Server snapshot sources")]
54    LockedUpdate,
55    #[error("locked or frozen mode requires an exact Server snapshot source in runmat.lock")]
56    MissingLock,
57    #[error("locked Server snapshot source does not match the manifest service or project")]
58    LockLocatorMismatch,
59    #[error("Server provider returned {found:?}; expected locked source {expected:?}")]
60    LockedSourceMismatch {
61        expected: Box<ServerProjectSourceId>,
62        found: Box<ServerProjectSourceId>,
63    },
64    #[error("Server provider returned a different service or project than requested")]
65    AcquiredLocatorMismatch,
66    #[error("Server service origin is invalid: {0}")]
67    InvalidService(String),
68}
69
70pub fn plan_server_project_acquisition(
71    service: &str,
72    project: &str,
73    selector: ServerSnapshotSelector,
74    locked_source: Option<&ServerProjectSourceId>,
75    intent: SourceAcquisitionIntent,
76    policy: SourceAcquisitionPolicy,
77) -> Result<ServerProjectAcquisitionPlan, ServerPolicyError> {
78    let service = ServerProjectSourceId::normalize_service(service)
79        .map_err(|error| ServerPolicyError::InvalidService(error.to_string()))?;
80    if intent == SourceAcquisitionIntent::Update {
81        if policy.frozen {
82            return Err(ServerPolicyError::FrozenUpdate);
83        }
84        if policy.locked {
85            return Err(ServerPolicyError::LockedUpdate);
86        }
87    }
88    if let Some(locked) = locked_source {
89        if locked.service != service || locked.project != project {
90            return Err(ServerPolicyError::LockLocatorMismatch);
91        }
92    }
93    let use_locked = intent != SourceAcquisitionIntent::Update && locked_source.is_some();
94    if !use_locked && (policy.locked || policy.frozen) {
95        return Err(ServerPolicyError::MissingLock);
96    }
97    if use_locked {
98        let expected = locked_source.cloned().expect("checked locked source");
99        return Ok(ServerProjectAcquisitionPlan {
100            service,
101            project: project.to_string(),
102            selector: ServerSnapshotSelector::Exact {
103                value: expected.snapshot.clone(),
104            },
105            allow_network: !policy.offline && !policy.frozen,
106            expected: Some(expected),
107            lock_action: SourceLockAction::Preserve,
108        });
109    }
110    Ok(ServerProjectAcquisitionPlan {
111        service,
112        project: project.to_string(),
113        selector,
114        allow_network: !policy.offline && !policy.frozen,
115        expected: None,
116        lock_action: match intent {
117            SourceAcquisitionIntent::Update => SourceLockAction::Replace,
118            SourceAcquisitionIntent::Execute | SourceAcquisitionIntent::Fetch => {
119                SourceLockAction::Write
120            }
121        },
122    })
123}
124
125pub fn validate_server_project_acquisition(
126    plan: &ServerProjectAcquisitionPlan,
127    acquired: &ServerProjectSourceId,
128) -> Result<(), ServerPolicyError> {
129    if acquired.service != plan.service || acquired.project != plan.project {
130        return Err(ServerPolicyError::AcquiredLocatorMismatch);
131    }
132    if let Some(expected) = &plan.expected {
133        if expected != acquired {
134            return Err(ServerPolicyError::LockedSourceMismatch {
135                expected: Box::new(expected.clone()),
136                found: Box::new(acquired.clone()),
137            });
138        }
139    } else if let ServerSnapshotSelector::Exact { value } = &plan.selector {
140        if acquired.snapshot != *value {
141            return Err(ServerPolicyError::AcquiredLocatorMismatch);
142        }
143    }
144    Ok(())
145}
146
147fn validate_selector(value: &str) -> Result<(), ServerPolicyError> {
148    if value.is_empty()
149        || value.len() > 128
150        || value
151            .chars()
152            .any(|character| character.is_control() || matches!(character, '/' | '\\'))
153    {
154        Err(ServerPolicyError::InvalidSelector)
155    } else {
156        Ok(())
157    }
158}
159
160fn is_exact_snapshot_id(value: &str) -> Result<bool, ServerPolicyError> {
161    if !value.starts_with("snap_") {
162        return Ok(false);
163    }
164    let suffix = &value["snap_".len()..];
165    if suffix.len() != 32
166        || !suffix
167            .bytes()
168            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
169    {
170        return Err(ServerPolicyError::InvalidSelector);
171    }
172    Ok(true)
173}