Skip to main content

soma_application/providers/
filesystem_python.rs

1//! Python-provider concerns for the file-backed provider source: interpreter
2//! selection, immutable prepared-environment selections, dependency scanning,
3//! and environment fingerprinting. Split out of `filesystem.rs` to stay under
4//! the module size hard limit.
5use std::{
6    collections::{BTreeMap, BTreeSet, HashMap},
7    fs,
8    path::{Path, PathBuf},
9    sync::{
10        Arc, Mutex, OnceLock, Weak,
11        atomic::{AtomicU64, Ordering},
12    },
13    time::{Duration, SystemTime},
14};
15
16use async_trait::async_trait;
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use soma_provider_adapters::python::{
20    PythonInterpreter,
21    lifecycle::PythonEnvironmentLifecycle,
22    materializer::{PreparedPythonEnvironment, UvRunner},
23};
24use soma_provider_core::{ProviderCatalog, ProviderOutput};
25
26use super::{FileProviderLoadError, FileProviderSource};
27use crate::{
28    provider_errors::ProviderError,
29    provider_registry::{Provider, ProviderCall},
30};
31
32/// Prepares an immutable Python environment before a provider candidate is
33/// imported for catalog discovery.
34pub trait PythonProviderEnvironmentPreparer: Send + Sync {
35    /// Returns the interpreter selected for `provider_path` after preparing its environment.
36    fn prepare(&self, provider_path: &Path) -> Result<PythonInterpreter, String>;
37
38    /// Revalidates an immutable candidate against the current provider source and cache.
39    fn validate_candidate(
40        &self,
41        provider_path: &Path,
42        candidate: &PreparedPythonEnvironment,
43    ) -> Result<PythonInterpreter, String>;
44}
45
46impl<R> PythonProviderEnvironmentPreparer for PythonEnvironmentLifecycle<R>
47where
48    R: UvRunner + 'static,
49{
50    fn prepare(&self, provider_path: &Path) -> Result<PythonInterpreter, String> {
51        self.prepare_provider(provider_path)
52            .map(|prepared| PythonInterpreter::prepared(&prepared))
53            .map_err(|error| error.to_string())
54    }
55
56    fn validate_candidate(
57        &self,
58        provider_path: &Path,
59        candidate: &PreparedPythonEnvironment,
60    ) -> Result<PythonInterpreter, String> {
61        self.validate_provider_candidate(provider_path, candidate)
62            .map(|prepared| PythonInterpreter::prepared(&prepared))
63            .map_err(|error| error.to_string())
64    }
65}
66
67/// Immutable prepared-environment selections keyed by managed Python provider path.
68pub type PythonProviderEnvironmentSelections = BTreeMap<PathBuf, PreparedPythonEnvironment>;
69
70const MAX_GENERATION_FILES: usize = 4_096;
71const MAX_GENERATION_BYTES: u64 = 64 * 1024 * 1024;
72const STALE_GENERATION_STORE_AGE: Duration = Duration::from_secs(24 * 60 * 60);
73struct GenerationLeaseEntry {
74    id: u64,
75    lease: Weak<PythonGenerationLease>,
76}
77
78static GENERATION_LEASES: OnceLock<Mutex<HashMap<PathBuf, GenerationLeaseEntry>>> = OnceLock::new();
79static GENERATION_STORE_INITIALIZED: OnceLock<()> = OnceLock::new();
80static GENERATION_LEASE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
81
82pub(super) struct ImmutablePythonSource {
83    pub(super) path: PathBuf,
84    pub(super) lease: Arc<PythonGenerationLease>,
85}
86
87pub(super) struct ImmutablePythonGeneration {
88    root: PathBuf,
89    pub(super) digest: String,
90    lease: Arc<PythonGenerationLease>,
91}
92
93impl ImmutablePythonGeneration {
94    pub(super) fn source(
95        &self,
96        provider_root: &Path,
97        path: &Path,
98    ) -> Result<ImmutablePythonSource, FileProviderLoadError> {
99        let relative_source =
100            path.strip_prefix(provider_root)
101                .map_err(|_| FileProviderLoadError {
102                    path: path.to_path_buf(),
103                    message: "Python provider source is outside its managed root".to_owned(),
104                })?;
105        Ok(ImmutablePythonSource {
106            path: self.root.join(relative_source),
107            lease: self.lease.clone(),
108        })
109    }
110}
111
112pub(super) struct PythonGenerationLease {
113    generation: PathBuf,
114    id: u64,
115}
116
117impl Drop for PythonGenerationLease {
118    fn drop(&mut self) {
119        let cleanup = if let Some(leases) = GENERATION_LEASES.get() {
120            let mut leases = leases
121                .lock()
122                .expect("Python generation lease lock should not be poisoned");
123            let owns_entry = leases
124                .get(&self.generation)
125                .is_some_and(|entry| entry.id == self.id);
126            if !owns_entry {
127                return;
128            }
129            leases.remove(&self.generation);
130            claim_generation_for_cleanup(&self.generation, self.id)
131        } else {
132            claim_generation_for_cleanup(&self.generation, self.id)
133        };
134        let Some(cleanup) = cleanup else {
135            return;
136        };
137        let background_cleanup = cleanup.clone();
138        if let Err(error) = std::thread::Builder::new()
139            .name("soma-python-generation-cleanup".to_owned())
140            .spawn(move || remove_claimed_generation(&background_cleanup))
141        {
142            tracing::warn!(
143                path = %cleanup.display(),
144                %error,
145                "failed to start immutable Python generation cleanup thread"
146            );
147            remove_claimed_generation(&cleanup);
148        }
149    }
150}
151
152fn claim_generation_for_cleanup(generation: &Path, id: u64) -> Option<PathBuf> {
153    let name = generation.file_name()?.to_string_lossy();
154    let cleanup = generation.with_file_name(format!(".{name}.{id}.reclaiming"));
155    match fs::rename(generation, &cleanup) {
156        Ok(()) => Some(cleanup),
157        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
158        Err(error) => {
159            tracing::warn!(
160                path = %generation.display(),
161                %error,
162                "failed to atomically claim immutable Python generation for cleanup"
163            );
164            None
165        }
166    }
167}
168
169fn remove_claimed_generation(generation: &Path) {
170    if let Err(error) = fs::remove_dir_all(generation)
171        && error.kind() != std::io::ErrorKind::NotFound
172    {
173        tracing::warn!(
174            path = %generation.display(),
175            %error,
176            "failed to reclaim immutable Python generation"
177        );
178    }
179}
180
181struct SnapshotRetainedProvider {
182    inner: Arc<dyn Provider>,
183    _lease: Arc<PythonGenerationLease>,
184}
185
186pub(super) fn retain_python_snapshot(
187    inner: Arc<dyn Provider>,
188    lease: Arc<PythonGenerationLease>,
189) -> Arc<dyn Provider> {
190    Arc::new(SnapshotRetainedProvider {
191        inner,
192        _lease: lease,
193    })
194}
195
196#[async_trait]
197impl Provider for SnapshotRetainedProvider {
198    fn catalog(&self) -> ProviderCatalog {
199        self.inner.catalog()
200    }
201
202    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
203        self.inner.call(call).await
204    }
205
206    async fn retire(&self) {
207        self.inner.retire().await;
208    }
209
210    async fn suspend(&self) {
211        self.inner.suspend().await;
212    }
213
214    fn runtime_status(&self) -> Option<Value> {
215        self.inner.runtime_status()
216    }
217
218    fn cancel_active(&self) -> bool {
219        self.inner.cancel_active()
220    }
221
222    async fn reset_quarantine(&self) {
223        self.inner.reset_quarantine().await;
224    }
225
226    fn deactivate(&self) {
227        self.inner.deactivate();
228    }
229
230    fn activate(&self) {
231        self.inner.activate();
232    }
233
234    fn acquire_dispatch(&self) -> bool {
235        self.inner.acquire_dispatch()
236    }
237
238    fn release_dispatch(&self) {
239        self.inner.release_dispatch();
240    }
241}
242
243#[cfg(test)]
244pub(super) fn immutable_python_source(
245    provider_root: &Path,
246    path: &Path,
247) -> Result<ImmutablePythonSource, FileProviderLoadError> {
248    immutable_python_generation(provider_root)?.source(provider_root, path)
249}
250
251pub(super) fn immutable_python_generation(
252    provider_root: &Path,
253) -> Result<ImmutablePythonGeneration, FileProviderLoadError> {
254    static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(1);
255    let mut paths = BTreeSet::new();
256    collect_python_dependency_paths(provider_root, &mut paths)?;
257    validate_generation_limits(provider_root, &paths)?;
258    let digest = python_tree_digest(provider_root, &paths)?;
259    let generation_store =
260        std::env::temp_dir().join(format!("soma-python-generations.{}", std::process::id()));
261    create_private_generation_store(&generation_store)?;
262    GENERATION_STORE_INITIALIZED.get_or_init(|| {
263        if let Ok(entries) = fs::read_dir(&generation_store) {
264            for entry in entries.flatten() {
265                let path = entry.path();
266                let _ = if path.is_dir() {
267                    fs::remove_dir_all(path)
268                } else {
269                    fs::remove_file(path)
270                };
271            }
272        }
273    });
274    cleanup_stale_generation_stores(&generation_store);
275    let generation = generation_store.join(&digest);
276    if generation.exists() {
277        verify_immutable_generation(&generation, &digest)?;
278        return Ok(ImmutablePythonGeneration {
279            root: generation.join("tree"),
280            digest,
281            lease: generation_lease(generation)?,
282        });
283    }
284    let staging = generation_store.join(format!(
285        ".{digest}.{}.{}.staging",
286        std::process::id(),
287        STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed)
288    ));
289    let staging_tree = staging.join("tree");
290    let staged = (|| {
291        for source in &paths {
292            let relative = source
293                .strip_prefix(provider_root)
294                .expect("collected dependency must remain under provider root");
295            let destination = staging_tree.join(relative);
296            if let Some(parent) = destination.parent() {
297                fs::create_dir_all(parent).map_err(|error| FileProviderLoadError {
298                    path: parent.to_path_buf(),
299                    message: format!("failed to create Python generation directory: {error}"),
300                })?;
301            }
302            fs::copy(source, &destination).map_err(|error| FileProviderLoadError {
303                path: destination,
304                message: format!("failed to snapshot Python generation source: {error}"),
305            })?;
306        }
307        verify_immutable_generation(&staging, &digest)?;
308        Ok::<(), FileProviderLoadError>(())
309    })();
310    if let Err(error) = staged {
311        let _ = fs::remove_dir_all(&staging);
312        return Err(error);
313    }
314    match fs::rename(&staging, &generation) {
315        Ok(()) => Ok(ImmutablePythonGeneration {
316            root: generation.join("tree"),
317            digest,
318            lease: generation_lease(generation)?,
319        }),
320        Err(_) if generation.exists() => {
321            let _ = fs::remove_dir_all(&staging);
322            verify_immutable_generation(&generation, &digest)?;
323            Ok(ImmutablePythonGeneration {
324                root: generation.join("tree"),
325                digest,
326                lease: generation_lease(generation)?,
327            })
328        }
329        Err(error) => {
330            let _ = fs::remove_dir_all(&staging);
331            Err(FileProviderLoadError {
332                path: generation,
333                message: format!("failed to publish Python generation snapshot: {error}"),
334            })
335        }
336    }
337}
338
339fn generation_lease(
340    generation: PathBuf,
341) -> Result<Arc<PythonGenerationLease>, FileProviderLoadError> {
342    let mut leases = GENERATION_LEASES
343        .get_or_init(|| Mutex::new(HashMap::new()))
344        .lock()
345        .expect("Python generation lease lock should not be poisoned");
346    if let Some(lease) = leases
347        .get(&generation)
348        .and_then(|entry| Weak::upgrade(&entry.lease))
349    {
350        return Ok(lease);
351    }
352    if !generation.is_dir() {
353        return Err(FileProviderLoadError {
354            path: generation,
355            message: "Python generation snapshot disappeared during acquisition".to_owned(),
356        });
357    }
358    let id = GENERATION_LEASE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
359    let lease = Arc::new(PythonGenerationLease {
360        generation: generation.clone(),
361        id,
362    });
363    leases.insert(
364        generation,
365        GenerationLeaseEntry {
366            id,
367            lease: Arc::downgrade(&lease),
368        },
369    );
370    Ok(lease)
371}
372
373fn cleanup_stale_generation_stores(active_store: &Path) {
374    let Some(parent) = active_store.parent() else {
375        return;
376    };
377    let Ok(entries) = fs::read_dir(parent) else {
378        return;
379    };
380    let now = SystemTime::now();
381    for entry in entries.flatten() {
382        let path = entry.path();
383        if path == active_store
384            || !entry
385                .file_name()
386                .to_string_lossy()
387                .starts_with("soma-python-generations.")
388        {
389            continue;
390        }
391        let stale = entry
392            .metadata()
393            .and_then(|metadata| metadata.modified())
394            .ok()
395            .and_then(|modified| now.duration_since(modified).ok())
396            .is_some_and(|age| age >= STALE_GENERATION_STORE_AGE);
397        if stale && stale_store_process_is_gone(&entry.file_name().to_string_lossy()) {
398            let _ = fs::remove_dir_all(path);
399        }
400    }
401}
402
403#[cfg(target_os = "linux")]
404fn stale_store_process_is_gone(name: &str) -> bool {
405    let Some(pid) = name
406        .strip_prefix("soma-python-generations.")
407        .and_then(|pid| pid.parse::<u32>().ok())
408    else {
409        return false;
410    };
411    !Path::new("/proc").join(pid.to_string()).exists()
412}
413
414#[cfg(not(target_os = "linux"))]
415fn stale_store_process_is_gone(_name: &str) -> bool {
416    false
417}
418
419fn validate_generation_limits(
420    root: &Path,
421    paths: &BTreeSet<PathBuf>,
422) -> Result<(), FileProviderLoadError> {
423    if paths.len() > MAX_GENERATION_FILES {
424        return Err(FileProviderLoadError {
425            path: root.to_path_buf(),
426            message: format!(
427                "Python generation tree exceeds the {MAX_GENERATION_FILES}-file limit"
428            ),
429        });
430    }
431    let mut total = 0_u64;
432    for path in paths {
433        total = total.saturating_add(
434            fs::metadata(path)
435                .map_err(|error| FileProviderLoadError {
436                    path: path.clone(),
437                    message: format!("failed to inspect Python generation input: {error}"),
438                })?
439                .len(),
440        );
441        if total > MAX_GENERATION_BYTES {
442            return Err(FileProviderLoadError {
443                path: root.to_path_buf(),
444                message: format!(
445                    "Python generation tree exceeds the {MAX_GENERATION_BYTES}-byte limit"
446                ),
447            });
448        }
449    }
450    Ok(())
451}
452
453fn secure_generation_store(path: &Path) -> Result<(), FileProviderLoadError> {
454    let metadata = fs::symlink_metadata(path).map_err(|error| FileProviderLoadError {
455        path: path.to_path_buf(),
456        message: format!("failed to inspect Python generation store: {error}"),
457    })?;
458    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
459        return Err(FileProviderLoadError {
460            path: path.to_path_buf(),
461            message: "Python generation store is not a private directory".to_owned(),
462        });
463    }
464    #[cfg(unix)]
465    {
466        use std::os::unix::fs::PermissionsExt;
467        fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
468            FileProviderLoadError {
469                path: path.to_path_buf(),
470                message: format!("failed to secure Python generation store: {error}"),
471            }
472        })?;
473    }
474    Ok(())
475}
476
477fn create_private_generation_store(path: &Path) -> Result<(), FileProviderLoadError> {
478    #[cfg(unix)]
479    {
480        use std::os::unix::fs::DirBuilderExt;
481        let mut builder = fs::DirBuilder::new();
482        builder.mode(0o700);
483        match builder.create(path) {
484            Ok(()) => {}
485            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
486            Err(error) => {
487                return Err(FileProviderLoadError {
488                    path: path.to_path_buf(),
489                    message: format!("failed to create Python generation store: {error}"),
490                });
491            }
492        }
493    }
494    #[cfg(not(unix))]
495    fs::create_dir_all(path).map_err(|error| FileProviderLoadError {
496        path: path.to_path_buf(),
497        message: format!("failed to create Python generation store: {error}"),
498    })?;
499    secure_generation_store(path)
500}
501
502fn python_tree_digest(
503    root: &Path,
504    paths: &BTreeSet<PathBuf>,
505) -> Result<String, FileProviderLoadError> {
506    let mut hasher = Sha256::new();
507    for path in paths {
508        let relative = path.strip_prefix(root).map_err(|_| FileProviderLoadError {
509            path: path.clone(),
510            message: "Python dependency is outside its managed root".to_owned(),
511        })?;
512        let bytes = fs::read(path).map_err(|error| FileProviderLoadError {
513            path: path.clone(),
514            message: format!("failed to read Python generation source: {error}"),
515        })?;
516        let label = relative.to_string_lossy();
517        hasher.update(label.len().to_le_bytes());
518        hasher.update(label.as_bytes());
519        hasher.update(bytes.len().to_le_bytes());
520        hasher.update(bytes);
521    }
522    Ok(hasher
523        .finalize()
524        .iter()
525        .map(|byte| format!("{byte:02x}"))
526        .collect())
527}
528
529fn verify_immutable_generation(
530    generation: &Path,
531    expected: &str,
532) -> Result<(), FileProviderLoadError> {
533    let tree = generation.join("tree");
534    let mut paths = BTreeSet::new();
535    collect_python_dependency_paths(&tree, &mut paths)?;
536    validate_generation_limits(&tree, &paths)?;
537    let actual = python_tree_digest(&tree, &paths)?;
538    if actual != expected {
539        return Err(FileProviderLoadError {
540            path: generation.to_path_buf(),
541            message: "Python generation snapshot digest mismatch".to_owned(),
542        });
543    }
544    Ok(())
545}
546
547impl FileProviderSource {
548    /// Resolves an exact managed `.py` provider path under this source.
549    pub fn resolve_python_provider_path(
550        &self,
551        provider_path: &Path,
552    ) -> Result<PathBuf, FileProviderLoadError> {
553        let requested = if provider_path.is_absolute() {
554            provider_path.to_path_buf()
555        } else {
556            self.root.join(provider_path)
557        };
558        self.provider_paths()?
559            .into_iter()
560            .find(|path| path == &requested && is_python_provider_source(path))
561            .ok_or_else(|| FileProviderLoadError {
562                path: requested,
563                message: "Python provider path is not a managed provider source".to_owned(),
564            })
565    }
566
567    pub(super) fn validate_python_environment_selections(
568        &self,
569        selections: &PythonProviderEnvironmentSelections,
570    ) -> Result<(), FileProviderLoadError> {
571        if selections.is_empty() {
572            return Ok(());
573        }
574        let managed = self
575            .provider_paths()?
576            .into_iter()
577            .filter(|path| is_python_provider_source(path))
578            .collect::<BTreeSet<_>>();
579        if let Some(path) = selections.keys().find(|path| !managed.contains(*path)) {
580            return Err(FileProviderLoadError {
581                path: path.clone(),
582                message: "Python environment selection is not a managed provider source".to_owned(),
583            });
584        }
585        Ok(())
586    }
587
588    #[cfg(test)]
589    pub(super) fn python_interpreter(
590        &self,
591        path: &Path,
592    ) -> Result<PythonInterpreter, FileProviderLoadError> {
593        self.python_interpreter_with_environments(path, &PythonProviderEnvironmentSelections::new())
594    }
595
596    pub(super) fn python_interpreter_with_environments(
597        &self,
598        path: &Path,
599        selections: &PythonProviderEnvironmentSelections,
600    ) -> Result<PythonInterpreter, FileProviderLoadError> {
601        if !is_python_provider_source(path) {
602            return Ok(PythonInterpreter::Ambient);
603        }
604        if let Some(candidate) = selections.get(path) {
605            let preparer =
606                self.python_environment_preparer
607                    .as_ref()
608                    .ok_or_else(|| FileProviderLoadError {
609                        path: path.to_path_buf(),
610                        message: "Python candidate validation requires an environment preparer"
611                            .to_owned(),
612                    })?;
613            return preparer
614                .validate_candidate(path, candidate)
615                .map_err(|source| FileProviderLoadError {
616                    path: path.to_path_buf(),
617                    message: format!("failed to validate Python provider candidate: {source}"),
618                });
619        }
620        self.python_environment_preparer.as_ref().map_or(
621            Ok(PythonInterpreter::Ambient),
622            |preparer| {
623                preparer
624                    .prepare(path)
625                    .map_err(|source| FileProviderLoadError {
626                        path: path.to_path_buf(),
627                        message: format!("failed to prepare Python provider environment: {source}"),
628                    })
629            },
630        )
631    }
632}
633
634pub(super) fn collect_python_dependency_paths(
635    root: &Path,
636    paths: &mut BTreeSet<PathBuf>,
637) -> Result<(), FileProviderLoadError> {
638    if !root.exists() {
639        return Ok(());
640    }
641    collect_python_dependency_paths_inner(root, paths)
642}
643
644fn collect_python_dependency_paths_inner(
645    dir: &Path,
646    paths: &mut BTreeSet<PathBuf>,
647) -> Result<(), FileProviderLoadError> {
648    let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
649        path: dir.to_path_buf(),
650        message: format!("failed to read provider dependency directory: {source}"),
651    })?;
652    for entry in entries {
653        let entry = entry.map_err(|source| FileProviderLoadError {
654            path: dir.to_path_buf(),
655            message: format!("failed to read provider dependency directory entry: {source}"),
656        })?;
657        let path = entry.path();
658        let metadata = fs::symlink_metadata(&path).map_err(|source| FileProviderLoadError {
659            path: path.clone(),
660            message: format!("failed to inspect provider dependency entry: {source}"),
661        })?;
662        if metadata.file_type().is_symlink() {
663            return Err(FileProviderLoadError {
664                path,
665                message: "Python generation inputs must not contain symbolic links".to_owned(),
666            });
667        }
668        if metadata.is_dir() {
669            if should_scan_dependency_dir(&path) {
670                collect_python_dependency_paths_inner(&path, paths)?;
671            }
672            continue;
673        }
674        if metadata.is_file() {
675            paths.insert(path);
676        }
677    }
678    Ok(())
679}
680
681fn should_scan_dependency_dir(path: &Path) -> bool {
682    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
683        return false;
684    };
685    !matches!(
686        name,
687        "__pycache__"
688            | ".git"
689            | ".mypy_cache"
690            | ".pytest_cache"
691            | ".ruff_cache"
692            | ".venv"
693            | "venv"
694            | "node_modules"
695            | "target"
696            | "dist"
697            | "build"
698    )
699}
700
701pub(super) fn is_python_provider_source(path: &Path) -> bool {
702    path.extension().and_then(|extension| extension.to_str()) == Some("py")
703}
704
705pub(super) fn fingerprint_python_environment(
706    hasher: &mut Sha256,
707    root: &Path,
708    path: &Path,
709    candidate: &PreparedPythonEnvironment,
710) {
711    let label = path
712        .strip_prefix(root)
713        .unwrap_or(path)
714        .display()
715        .to_string();
716    hasher.update(b"python-environment\0");
717    hasher.update(label.as_bytes());
718    hasher.update([0]);
719    for value in [
720        candidate.key.as_str(),
721        candidate.lock_sha256.as_str(),
722        candidate
723            .provider_source_sha256
724            .as_deref()
725            .unwrap_or_default(),
726        candidate.input_plan_key.as_deref().unwrap_or_default(),
727    ] {
728        hasher.update(value.len().to_le_bytes());
729        hasher.update(value.as_bytes());
730        hasher.update([0]);
731    }
732}
733
734pub(super) fn python_worker_generation_digest(
735    source_tree_digest: &str,
736    selections: &PythonProviderEnvironmentSelections,
737) -> String {
738    let mut hasher = Sha256::new();
739    hasher.update(b"python-worker-generation-v1\0");
740    hasher.update(source_tree_digest.as_bytes());
741    for (path, candidate) in selections {
742        let path = path.to_string_lossy();
743        for value in [
744            path.as_ref(),
745            candidate.key.as_str(),
746            candidate.lock_sha256.as_str(),
747        ] {
748            hasher.update(value.len().to_le_bytes());
749            hasher.update(value.as_bytes());
750        }
751    }
752    hasher
753        .finalize()
754        .iter()
755        .map(|byte| format!("{byte:02x}"))
756        .collect()
757}
758
759#[cfg(test)]
760#[path = "filesystem_python_tests.rs"]
761mod tests;