runmat_package/source/
server_policy.rs1use 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}