1use std::{
4 ffi::OsString,
5 fs, io,
6 path::{Path, PathBuf},
7 sync::atomic::{AtomicU64, Ordering},
8};
9
10use serde::Serialize;
11use thiserror::Error;
12
13use super::{
14 PreparedPythonEnvironment, PythonEnvironmentMaterializer, PythonMaterializationError,
15 PythonMaterializationRequest, READY_FILE, READY_SCHEMA_VERSION, ReadyMarker, UvRunner,
16 environment_python, read_verified_sdk, render_project, sha256_hex,
17};
18use crate::python::environment::{Pep723Metadata, PythonEnvironmentPlan};
19
20const UPDATE_PLAN_VERSION: u32 = 3;
21static UPDATE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Debug, Clone, Copy)]
24pub struct PythonEnvironmentUpdateRequest<'a> {
25 pub materialization: PythonMaterializationRequest<'a>,
26 pub provider_source_sha256: &'a str,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PythonEnvironmentUpdateOutcome {
32 Prepared,
33 Reused,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct PythonEnvironmentUpdateReport {
38 pub outcome: PythonEnvironmentUpdateOutcome,
39 pub current: Option<PreparedPythonEnvironment>,
40 pub candidate: PreparedPythonEnvironment,
41}
42
43#[derive(Debug, Error)]
44pub enum PythonEnvironmentUpdateError {
45 #[error("provider source SHA-256 must contain exactly 64 hexadecimal characters")]
46 InvalidSourceDigest,
47 #[error("current Python environment must be repaired before update: {0}")]
48 CurrentInvalid(String),
49 #[error("resolved Python update candidate is invalid: {0}")]
50 CandidateInvalid(String),
51 #[error("Python update cache plan has no managed cache root")]
52 MissingCacheRoot,
53 #[error("Python update cache path is not a real directory: {}", path.display())]
54 UnsafeCachePath { path: PathBuf },
55 #[error("uv command failed during update {operation}: {message}")]
56 Uv {
57 operation: &'static str,
58 message: String,
59 },
60 #[error(transparent)]
61 Materialization(#[from] PythonMaterializationError),
62 #[error("Python update I/O failed: {0}")]
63 Io(#[from] io::Error),
64}
65
66impl<R: UvRunner> PythonEnvironmentMaterializer<R> {
67 pub fn update(
68 &self,
69 plan: &PythonEnvironmentPlan,
70 request: PythonEnvironmentUpdateRequest<'_>,
71 ) -> Result<PythonEnvironmentUpdateReport, PythonEnvironmentUpdateError> {
72 let source_sha256 = normalize_digest(request.provider_source_sha256)?;
73 let current = match self.open_verified(plan) {
74 Ok(environment) => environment,
75 Err(PythonMaterializationError::IncompleteCache(message))
76 | Err(PythonMaterializationError::InvalidMarker(message)) => {
77 return Err(PythonEnvironmentUpdateError::CurrentInvalid(message));
78 }
79 Err(error) => return Err(error.into()),
80 };
81 self.runner
82 .verify_identity(&self.uv_program, &plan.uv_version)
83 .map_err(|message| PythonEnvironmentUpdateError::Uv {
84 operation: "identity verification",
85 message,
86 })?;
87 let sdk_wheel_bytes =
88 read_verified_sdk(request.materialization.sdk_wheel, &plan.sdk_wheel_sha256)?;
89
90 let python_cache_root = plan
91 .directory
92 .parent()
93 .and_then(Path::parent)
94 .ok_or(PythonEnvironmentUpdateError::MissingCacheRoot)?;
95 ensure_real_directory(python_cache_root, true)?;
96 let candidate_parent = python_cache_root.join(format!("v{UPDATE_PLAN_VERSION}"));
97 ensure_real_directory(&candidate_parent, false)?;
98 let staging = update_staging_path(&candidate_parent, &plan.key)?;
99 fs::create_dir(&staging)?;
100 let sdk_wheel_name = request
101 .materialization
102 .sdk_wheel
103 .file_name()
104 .ok_or_else(|| {
105 PythonMaterializationError::IncompleteCache(
106 "configured SDK wheel has no file name".to_owned(),
107 )
108 })?;
109 let staged_sdk_wheel = staging.join(sdk_wheel_name);
110 fs::write(&staged_sdk_wheel, sdk_wheel_bytes)?;
111 let staged_request = PythonEnvironmentUpdateRequest {
112 materialization: PythonMaterializationRequest {
113 metadata: request.materialization.metadata,
114 python_executable: request.materialization.python_executable,
115 sdk_wheel: &staged_sdk_wheel,
116 offline: request.materialization.offline,
117 },
118 provider_source_sha256: request.provider_source_sha256,
119 };
120
121 let result = self.resolve_and_prepare_update(
122 plan,
123 staged_request,
124 &source_sha256,
125 &candidate_parent,
126 &staging,
127 );
128 if result.is_err() {
129 let _ = fs::remove_dir_all(&staging);
130 }
131 let (outcome, candidate) = result?;
132 Ok(PythonEnvironmentUpdateReport {
133 outcome,
134 current,
135 candidate,
136 })
137 }
138
139 fn resolve_and_prepare_update(
140 &self,
141 plan: &PythonEnvironmentPlan,
142 request: PythonEnvironmentUpdateRequest<'_>,
143 source_sha256: &str,
144 candidate_parent: &Path,
145 staging: &Path,
146 ) -> Result<
147 (PythonEnvironmentUpdateOutcome, PreparedPythonEnvironment),
148 PythonEnvironmentUpdateError,
149 > {
150 fs::write(
151 staging.join("pyproject.toml"),
152 render_project(request.materialization.metadata),
153 )?;
154 let mut lock_args = vec![
155 OsString::from("lock"),
156 OsString::from("--upgrade"),
157 OsString::from("--project"),
158 OsString::from("."),
159 OsString::from("--python"),
160 request
161 .materialization
162 .python_executable
163 .as_os_str()
164 .to_owned(),
165 ];
166 if request.materialization.offline {
167 lock_args.push(OsString::from("--offline"));
168 }
169 self.update_uv("lock", staging, &lock_args)?;
170
171 let lockfile = staging.join("uv.lock");
172 let lock = fs::read(&lockfile)?;
173 let lock_sha256 = sha256_hex(&lock);
174 let candidate_key = resolved_candidate_key(
175 plan,
176 request.materialization.metadata,
177 source_sha256,
178 &lock_sha256,
179 );
180 let candidate_plan = PythonEnvironmentPlan {
181 key: candidate_key.clone(),
182 directory: candidate_parent.join(&candidate_key),
183 plan_version: UPDATE_PLAN_VERSION,
184 dependency_count: plan.dependency_count,
185 runtime: plan.runtime.clone(),
186 sdk_wheel_tag: plan.sdk_wheel_tag.clone(),
187 sdk_wheel_sha256: plan.sdk_wheel_sha256.clone(),
188 uv_version: plan.uv_version.clone(),
189 };
190
191 match self.open_verified(&candidate_plan) {
192 Ok(Some(candidate)) => {
193 validate_candidate_identity(&candidate, source_sha256, &plan.key)?;
194 fs::remove_dir_all(staging)?;
195 return Ok((PythonEnvironmentUpdateOutcome::Reused, candidate));
196 }
197 Ok(None) => {}
198 Err(error) => {
199 return Err(PythonEnvironmentUpdateError::CandidateInvalid(
200 error.to_string(),
201 ));
202 }
203 }
204
205 let mut sync_args = vec![
206 OsString::from("sync"),
207 OsString::from("--project"),
208 OsString::from("."),
209 OsString::from("--locked"),
210 OsString::from("--no-install-project"),
211 OsString::from("--python"),
212 request
213 .materialization
214 .python_executable
215 .as_os_str()
216 .to_owned(),
217 ];
218 if request.materialization.offline {
219 sync_args.push(OsString::from("--offline"));
220 }
221 self.update_uv("sync", staging, &sync_args)?;
222 let python = environment_python(staging);
223 self.runner
224 .verify_python(&python, &candidate_plan.runtime)
225 .map_err(|message| PythonEnvironmentUpdateError::Uv {
226 operation: "Python runtime identity verification",
227 message,
228 })?;
229 self.update_uv(
230 "SDK install",
231 staging,
232 &[
233 OsString::from("pip"),
234 OsString::from("install"),
235 OsString::from("--python"),
236 python.as_os_str().to_owned(),
237 OsString::from("--offline"),
238 OsString::from("--no-deps"),
239 request.materialization.sdk_wheel.as_os_str().to_owned(),
240 ],
241 )?;
242 if !python.is_file() || !lockfile.is_file() {
243 return Err(PythonMaterializationError::IncompleteCache(
244 "update did not create both .venv Python and uv.lock".to_owned(),
245 )
246 .into());
247 }
248 let marker = ReadyMarker {
249 schema_version: READY_SCHEMA_VERSION,
250 environment_key: candidate_plan.key.clone(),
251 plan_version: candidate_plan.plan_version,
252 dependency_count: candidate_plan.dependency_count,
253 runtime: candidate_plan.runtime.clone(),
254 sdk_wheel_tag: candidate_plan.sdk_wheel_tag.clone(),
255 sdk_wheel_sha256: candidate_plan.sdk_wheel_sha256.clone(),
256 uv_version: candidate_plan.uv_version.clone(),
257 lock_sha256,
258 provider_source_sha256: Some(source_sha256.to_owned()),
259 input_plan_key: Some(plan.key.clone()),
260 };
261 fs::write(
262 staging.join(READY_FILE),
263 serde_json::to_vec_pretty(&marker).expect("update marker is serializable"),
264 )?;
265
266 match fs::rename(staging, &candidate_plan.directory) {
267 Ok(()) => {}
268 Err(_) if fs::symlink_metadata(&candidate_plan.directory).is_ok() => {
269 fs::remove_dir_all(staging)?;
270 }
271 Err(error) => return Err(error.into()),
272 }
273 let candidate = self.open_verified(&candidate_plan)?.ok_or(
274 PythonEnvironmentUpdateError::CandidateInvalid(candidate_key),
275 )?;
276 validate_candidate_identity(&candidate, source_sha256, &plan.key)?;
277 Ok((PythonEnvironmentUpdateOutcome::Prepared, candidate))
278 }
279
280 fn update_uv(
281 &self,
282 operation: &'static str,
283 current_dir: &Path,
284 args: &[OsString],
285 ) -> Result<(), PythonEnvironmentUpdateError> {
286 self.runner
287 .run(&self.uv_program, args, current_dir)
288 .map_err(|message| PythonEnvironmentUpdateError::Uv { operation, message })
289 }
290}
291
292fn ensure_real_directory(
293 path: &Path,
294 create_parents: bool,
295) -> Result<(), PythonEnvironmentUpdateError> {
296 match fs::symlink_metadata(path) {
297 Ok(metadata) => {
298 if metadata.file_type().is_symlink() || !metadata.is_dir() {
299 return Err(PythonEnvironmentUpdateError::UnsafeCachePath {
300 path: path.to_path_buf(),
301 });
302 }
303 }
304 Err(error) if error.kind() == io::ErrorKind::NotFound => {
305 if create_parents {
306 fs::create_dir_all(path)?;
307 } else {
308 fs::create_dir(path)?;
309 }
310 let metadata = fs::symlink_metadata(path)?;
311 if metadata.file_type().is_symlink() || !metadata.is_dir() {
312 return Err(PythonEnvironmentUpdateError::UnsafeCachePath {
313 path: path.to_path_buf(),
314 });
315 }
316 }
317 Err(error) => return Err(error.into()),
318 }
319 Ok(())
320}
321
322fn validate_candidate_identity(
323 candidate: &PreparedPythonEnvironment,
324 source_sha256: &str,
325 input_plan_key: &str,
326) -> Result<(), PythonEnvironmentUpdateError> {
327 if candidate.provider_source_sha256.as_deref() != Some(source_sha256)
328 || candidate.input_plan_key.as_deref() != Some(input_plan_key)
329 {
330 return Err(PythonEnvironmentUpdateError::CandidateInvalid(
331 "readiness identity does not match update request".to_owned(),
332 ));
333 }
334 Ok(())
335}
336
337fn normalize_digest(value: &str) -> Result<String, PythonEnvironmentUpdateError> {
338 let value = value.trim().to_ascii_lowercase();
339 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
340 return Err(PythonEnvironmentUpdateError::InvalidSourceDigest);
341 }
342 Ok(value)
343}
344
345#[derive(Serialize)]
346struct ResolvedCandidateFingerprint<'a> {
347 policy_version: u32,
348 provider_source_sha256: &'a str,
349 input_plan_key: &'a str,
350 metadata: Option<&'a Pep723Metadata>,
351 runtime: &'a crate::python::environment::PythonRuntimeFingerprint,
352 sdk_wheel_tag: &'a crate::python::environment::PythonWheelTag,
353 sdk_wheel_sha256: &'a str,
354 uv_version: &'a str,
355 lock_sha256: &'a str,
356}
357
358fn resolved_candidate_key(
359 plan: &PythonEnvironmentPlan,
360 metadata: Option<&Pep723Metadata>,
361 source_sha256: &str,
362 lock_sha256: &str,
363) -> String {
364 let fingerprint = ResolvedCandidateFingerprint {
365 policy_version: UPDATE_PLAN_VERSION,
366 provider_source_sha256: source_sha256,
367 input_plan_key: &plan.key,
368 metadata,
369 runtime: &plan.runtime,
370 sdk_wheel_tag: &plan.sdk_wheel_tag,
371 sdk_wheel_sha256: &plan.sdk_wheel_sha256,
372 uv_version: &plan.uv_version,
373 lock_sha256,
374 };
375 sha256_hex(&serde_json::to_vec(&fingerprint).expect("candidate fingerprint is serializable"))
376}
377
378fn update_staging_path(parent: &Path, input_plan_key: &str) -> io::Result<PathBuf> {
379 loop {
380 let sequence = UPDATE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
381 let candidate = parent.join(format!(
382 ".{input_plan_key}.update-{}-{sequence}",
383 std::process::id()
384 ));
385 match fs::symlink_metadata(&candidate) {
386 Ok(_) => continue,
387 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(candidate),
388 Err(error) => return Err(error),
389 }
390 }
391}