talos_plugin/
resolution.rs1use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use crate::{
7 BundleManifest, CompatibleManifest, InstallError, install_bundle_with_guard,
8 parse_compatible_manifest,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum ResolutionConsent {
14 Granted,
16 Denied,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ResolutionRequest {
23 pub capability: String,
25 pub bundle_name: String,
27 pub bundle_version: String,
29}
30
31#[derive(Clone, Copy, Debug)]
33pub struct ResolutionLimits {
34 pub timeout: Duration,
36}
37
38impl Default for ResolutionLimits {
39 fn default() -> Self {
40 Self {
41 timeout: Duration::from_secs(30),
42 }
43 }
44}
45
46#[derive(Debug)]
48pub struct ResolutionResult {
49 pub manifest: BundleManifest,
51 pub destination: PathBuf,
53}
54
55#[derive(Debug, thiserror::Error)]
57pub enum ResolutionError {
58 #[error("explicit consent is required")]
60 ConsentDenied,
61 #[error("resolved Bundle identity does not match the request")]
63 IdentityMismatch,
64 #[error("resolution cancelled or timed out")]
66 Cancelled,
67 #[error("Bundle installation failed: {0}")]
69 Install(#[from] InstallError),
70}
71
72pub fn resolve_verified_bundle(
78 request: &ResolutionRequest,
79 source: &Path,
80 destination: &Path,
81 consent: ResolutionConsent,
82 limits: ResolutionLimits,
83 cancelled: impl Fn() -> bool,
84) -> Result<ResolutionResult, ResolutionError> {
85 if consent != ResolutionConsent::Granted || cancelled() {
86 return Err(if consent == ResolutionConsent::Granted {
87 ResolutionError::Cancelled
88 } else {
89 ResolutionError::ConsentDenied
90 });
91 }
92 let started = Instant::now();
93 let manifest_text =
94 std::fs::read_to_string(source.join("manifest.toml")).map_err(InstallError::Io)?;
95 let expected =
96 match parse_compatible_manifest(&manifest_text).map_err(InstallError::Manifest)? {
97 CompatibleManifest::Bundle(bundle) => bundle,
98 CompatibleManifest::Legacy(_) => {
99 return Err(ResolutionError::Install(InstallError::LegacyManifest));
100 }
101 };
102 let provider_matches = expected
103 .language_provider
104 .as_ref()
105 .map(|provider| format!("language.{}", provider.language) == request.capability)
106 .unwrap_or(false);
107 if expected.bundle.name != request.bundle_name
108 || expected.bundle.version != request.bundle_version
109 || !provider_matches
110 {
111 return Err(ResolutionError::IdentityMismatch);
112 }
113 let staging = destination.with_extension("resolution-staging");
114 let manifest = install_bundle_with_guard(source, &staging, || {
115 cancelled() || started.elapsed() > limits.timeout
116 })
117 .map_err(|error| match error {
118 InstallError::Io(io) if io.kind() == std::io::ErrorKind::Interrupted => {
119 ResolutionError::Cancelled
120 }
121 InstallError::Cancelled => ResolutionError::Cancelled,
122 other => ResolutionError::Install(other),
123 });
124 let manifest = match manifest {
125 Ok(manifest) => manifest,
126 Err(error) => {
127 let _ = std::fs::remove_dir_all(&staging);
128 return Err(error);
129 }
130 };
131 if started.elapsed() > limits.timeout || cancelled() {
132 let _ = std::fs::remove_dir_all(&staging);
133 return Err(ResolutionError::Cancelled);
134 }
135 debug_assert_eq!(manifest.bundle.name, request.bundle_name);
136 let backup = destination.with_extension("resolution-backup");
137 if backup.exists() {
138 std::fs::remove_dir_all(&backup).map_err(InstallError::Io)?;
139 }
140 if destination.exists() {
141 std::fs::rename(destination, &backup).map_err(InstallError::Io)?;
142 }
143 if let Err(error) = std::fs::rename(&staging, destination) {
144 if backup.exists() && !destination.exists() {
145 let _ = std::fs::rename(&backup, destination);
146 }
147 let _ = std::fs::remove_dir_all(&staging);
148 return Err(ResolutionError::Install(InstallError::Io(error)));
149 }
150 if backup.exists() {
151 std::fs::remove_dir_all(&backup).map_err(InstallError::Io)?;
152 }
153 Ok(ResolutionResult {
154 manifest,
155 destination: destination.to_path_buf(),
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use std::fs;
163
164 fn fixture(root: &Path) -> PathBuf {
165 let source = root.join("source");
166 fs::create_dir_all(&source).expect("test fixture operation");
167 fs::write(source.join("provider.wat"), "(module)").expect("test fixture operation");
168 fs::write(
169 source.join("manifest.toml"),
170 "schema_version=1\n[bundle]\nname=\"demo\"\nversion=\"1.0.0\"\ncarrier=\"wasm\"\nartifact=\"provider.wat\"\n[language_provider]\nlanguage=\"python\"\nartifact=\"provider.wat\"",
171 )
172 .expect("test fixture operation");
173 source
174 }
175
176 #[test]
177 fn denied_consent_does_not_write() {
178 let root = std::env::temp_dir().join(format!("talos-resolution-{}", std::process::id()));
179 let source = fixture(&root);
180 let destination = root.join("installed");
181 let request = ResolutionRequest {
182 capability: "language.python".into(),
183 bundle_name: "demo".into(),
184 bundle_version: "1.0.0".into(),
185 };
186 assert!(matches!(
187 resolve_verified_bundle(
188 &request,
189 &source,
190 &destination,
191 ResolutionConsent::Denied,
192 ResolutionLimits::default(),
193 || false
194 ),
195 Err(ResolutionError::ConsentDenied)
196 ));
197 assert!(!destination.exists());
198 let _ = fs::remove_dir_all(root);
199 }
200
201 #[test]
202 fn identity_mismatch_rolls_back_inactive_install() {
203 let root = std::env::temp_dir().join(format!("talos-resolution-id-{}", std::process::id()));
204 let source = fixture(&root);
205 let destination = root.join("installed");
206 let request = ResolutionRequest {
207 capability: "language.python".into(),
208 bundle_name: "other".into(),
209 bundle_version: "1.0.0".into(),
210 };
211 assert!(matches!(
212 resolve_verified_bundle(
213 &request,
214 &source,
215 &destination,
216 ResolutionConsent::Granted,
217 ResolutionLimits::default(),
218 || false
219 ),
220 Err(ResolutionError::IdentityMismatch)
221 ));
222 assert!(!destination.exists());
223 let _ = fs::remove_dir_all(root);
224 }
225
226 #[test]
227 fn cancelled_resolution_preserves_existing_install() {
228 let root =
229 std::env::temp_dir().join(format!("talos-resolution-cancel-{}", std::process::id()));
230 let source = fixture(&root);
231 let destination = root.join("installed");
232 fs::create_dir_all(&destination).expect("test fixture operation");
233 fs::write(destination.join("sentinel"), "old").expect("test fixture operation");
234 let request = ResolutionRequest {
235 capability: "language.python".into(),
236 bundle_name: "demo".into(),
237 bundle_version: "1.0.0".into(),
238 };
239 assert!(matches!(
240 resolve_verified_bundle(
241 &request,
242 &source,
243 &destination,
244 ResolutionConsent::Granted,
245 ResolutionLimits::default(),
246 || true
247 ),
248 Err(ResolutionError::Cancelled)
249 ));
250 assert_eq!(
251 fs::read_to_string(destination.join("sentinel")).expect("test fixture operation"),
252 "old"
253 );
254 let _ = fs::remove_dir_all(root);
255 }
256}