Skip to main content

agentos_client/
software.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::io::{Read, Write};
4use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7use std::sync::Arc;
8use std::time::Duration;
9
10use futures::StreamExt;
11use once_cell::sync::OnceCell;
12use reqwest::header::{ACCEPT_ENCODING, LOCATION};
13use reqwest::{redirect, StatusCode, Url};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use tokio::io::{AsyncReadExt, AsyncWriteExt};
17use tokio::sync::{Mutex, Notify, Semaphore};
18
19use crate::ClientError;
20
21pub const DEFAULT_MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
22pub const DEFAULT_PACKAGE_DOWNLOAD_TIMEOUT_MS: u64 = 30_000;
23pub const DEFAULT_PACKAGE_CONNECT_TIMEOUT_MS: u64 = 5_000;
24pub const DEFAULT_PACKAGE_REDIRECT_LIMIT: usize = 3;
25pub const DEFAULT_PACKAGE_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
26pub const DEFAULT_PACKAGE_CACHE_MAX_ENTRIES: usize = 256;
27pub const DEFAULT_PACKAGE_CACHE_MAX_CONCURRENT_ACQUISITIONS: usize = 8;
28pub const DEFAULT_PACKAGE_CACHE_MAX_PENDING_ACQUISITIONS: usize = 64;
29pub const DEFAULT_PACKAGE_CACHE_ACQUISITION_TIMEOUT_MS: u64 = 60_000;
30pub const DEFAULT_PACKAGE_CACHE_MAX_SOURCE_ENTRIES: usize = 1024;
31pub const DEFAULT_PACKAGE_CACHE_SOURCE_TTL_MS: u64 = 60_000;
32pub const DEFAULT_PACKAGE_CACHE_MIN_FREE_BYTES: u64 = 512 * 1024 * 1024;
33const MAX_PACKAGE_URL_BYTES: usize = 4 * 1024;
34const MAX_PACKAGE_RESPONSE_HEADERS: usize = 128;
35const MAX_PACKAGE_RESPONSE_HEADER_BYTES: usize = 64 * 1024;
36const MAX_PACKAGE_MANIFEST_BYTES: usize = 1024 * 1024;
37const MAX_PACKAGE_INDEX_BYTES: usize = 64 * 1024 * 1024;
38const MAX_PACKAGE_NAME_BYTES: usize = 128;
39const MAX_PACKAGE_VERSION_BYTES: usize = 128;
40const MAX_PACKAGE_COMMANDS: usize = 1024;
41const MAX_PACKAGE_COMMAND_BYTES: usize = 255;
42const MAX_PACKAGE_ENTRY_BYTES: usize = 4 * 1024;
43const MAX_PACKAGE_PROVIDES_ENV: usize = 1024;
44const MAX_PACKAGE_PROVIDES_FILES: usize = 1024;
45const DOWNLOAD_CHUNK_BYTES: usize = 64 * 1024;
46
47static PROCESS_PACKAGE_CACHE: OnceCell<Arc<ProcessPackageCache>> = OnceCell::new();
48
49/// Operator-owned limits for the one package cache shared by all Core clients
50/// in this process. Hosted actor inputs cannot override these values.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProcessPackageCacheOptions {
53    /// Persistent cache directory. `None` uses a process-lifetime temporary
54    /// directory, which is useful for embedded Core and tests.
55    pub root: Option<PathBuf>,
56    pub min_free_bytes: u64,
57    pub max_bytes: u64,
58    pub max_entries: usize,
59    pub max_concurrent_acquisitions: usize,
60    pub max_pending_acquisitions: usize,
61    pub acquisition_timeout_ms: u64,
62    pub max_source_entries: usize,
63    pub source_ttl_ms: u64,
64}
65
66impl Default for ProcessPackageCacheOptions {
67    fn default() -> Self {
68        Self {
69            root: None,
70            min_free_bytes: DEFAULT_PACKAGE_CACHE_MIN_FREE_BYTES,
71            max_bytes: DEFAULT_PACKAGE_CACHE_MAX_BYTES,
72            max_entries: DEFAULT_PACKAGE_CACHE_MAX_ENTRIES,
73            max_concurrent_acquisitions: DEFAULT_PACKAGE_CACHE_MAX_CONCURRENT_ACQUISITIONS,
74            max_pending_acquisitions: DEFAULT_PACKAGE_CACHE_MAX_PENDING_ACQUISITIONS,
75            acquisition_timeout_ms: DEFAULT_PACKAGE_CACHE_ACQUISITION_TIMEOUT_MS,
76            max_source_entries: DEFAULT_PACKAGE_CACHE_MAX_SOURCE_ENTRIES,
77            source_ttl_ms: DEFAULT_PACKAGE_CACHE_SOURCE_TTL_MS,
78        }
79    }
80}
81
82impl ProcessPackageCacheOptions {
83    pub fn validate(&self) -> Result<(), ClientError> {
84        if self.max_bytes == 0
85            || self.max_entries == 0
86            || self.max_concurrent_acquisitions == 0
87            || self.max_pending_acquisitions == 0
88            || self.acquisition_timeout_ms == 0
89            || self.max_source_entries == 0
90            || self.source_ttl_ms == 0
91        {
92            return Err(ClientError::PackageCacheConfiguration(String::from(
93                "package cache byte, entry, source-index, concurrent acquisition, pending acquisition, and timeout limits must be greater than zero",
94            )));
95        }
96        if self.max_pending_acquisitions < self.max_concurrent_acquisitions {
97            return Err(ClientError::PackageCacheConfiguration(String::from(
98                "max_pending_acquisitions must be at least max_concurrent_acquisitions",
99            )));
100        }
101        Ok(())
102    }
103}
104
105/// A bounded snapshot of process-cache state. Counters saturate instead of
106/// wrapping; package identities are deliberately not exposed as metric labels.
107#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ProcessPackageCacheStats {
111    pub entries: usize,
112    pub source_entries: usize,
113    pub bytes: u64,
114    pub pinned_entries: usize,
115    pub pending_acquisitions: usize,
116    pub hits: u64,
117    pub misses: u64,
118    pub coalesced_waiters: u64,
119    pub acquisitions: u64,
120    pub evictions: u64,
121    pub capacity_failures: u64,
122    pub cancelled_acquisitions: u64,
123}
124
125/// Configure the process cache before constructing a [`PackageResolver`]. A
126/// repeated identical call is idempotent; a different second configuration is
127/// rejected so actors cannot silently change process-wide limits.
128pub fn configure_process_package_cache(
129    options: ProcessPackageCacheOptions,
130) -> Result<(), ClientError> {
131    options.validate()?;
132    if let Some(cache) = PROCESS_PACKAGE_CACHE.get() {
133        if cache.options == options {
134            return Ok(());
135        }
136        return Err(ClientError::PackageCacheConfiguration(format!(
137            "existing options are {:?}, requested options are {options:?}",
138            cache.options
139        )));
140    }
141    let cache = Arc::new(ProcessPackageCache::new(options.clone())?);
142    match PROCESS_PACKAGE_CACHE.set(cache) {
143        Ok(()) => Ok(()),
144        Err(_) => configure_process_package_cache(options),
145    }
146}
147
148fn process_package_cache() -> Result<Arc<ProcessPackageCache>, ClientError> {
149    if PROCESS_PACKAGE_CACHE.get().is_none() {
150        configure_process_package_cache(ProcessPackageCacheOptions::default())?;
151    }
152    PROCESS_PACKAGE_CACHE.get().cloned().ok_or_else(|| {
153        ClientError::PackageCacheConfiguration(String::from(
154            "process package cache initialization did not publish a cache",
155        ))
156    })
157}
158
159pub async fn process_package_cache_stats() -> Result<ProcessPackageCacheStats, ClientError> {
160    Ok(process_package_cache()?.stats().await)
161}
162
163/// Trusted Core package locator. Hosted adapters must define their own URL-only
164/// DTO rather than deserializing this enum directly.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum PackageSource {
167    Url {
168        url: String,
169        expected_digest: Option<String>,
170    },
171    Path {
172        path: String,
173        expected_digest: Option<String>,
174    },
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PackageResolverOptions {
179    pub max_package_bytes: u64,
180    pub download_timeout_ms: u64,
181    pub connect_timeout_ms: u64,
182    pub max_redirects: usize,
183    /// Enables plain HTTP only for loopback destinations. This is intended for
184    /// local integration tests and is never accepted from the hosted actor.
185    pub allow_insecure_local_http: bool,
186}
187
188impl Default for PackageResolverOptions {
189    fn default() -> Self {
190        Self {
191            max_package_bytes: DEFAULT_MAX_PACKAGE_BYTES,
192            download_timeout_ms: DEFAULT_PACKAGE_DOWNLOAD_TIMEOUT_MS,
193            connect_timeout_ms: DEFAULT_PACKAGE_CONNECT_TIMEOUT_MS,
194            max_redirects: DEFAULT_PACKAGE_REDIRECT_LIMIT,
195            allow_insecure_local_http: false,
196        }
197    }
198}
199
200impl PackageResolverOptions {
201    pub(crate) fn validate(&self) -> Result<(), ClientError> {
202        if self.max_package_bytes == 0 {
203            return Err(ClientError::InvalidPackageSource(String::from(
204                "max_package_bytes must be greater than zero",
205            )));
206        }
207        if self.download_timeout_ms == 0 || self.connect_timeout_ms == 0 {
208            return Err(ClientError::InvalidPackageSource(String::from(
209                "package download and connect timeouts must be greater than zero",
210            )));
211        }
212        if self.max_redirects > 16 {
213            return Err(ClientError::InvalidPackageSource(String::from(
214                "max_redirects exceeds limit of 16; reduce the redirect limit",
215            )));
216        }
217        Ok(())
218    }
219}
220
221#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct PackageManifestInfo {
225    pub name: String,
226    pub version: String,
227    pub commands: Vec<String>,
228}
229
230#[derive(Clone)]
231pub struct VerifiedPackage {
232    pub package_id: String,
233    pub digest: String,
234    pub size: u64,
235    pub manifest: PackageManifestInfo,
236    backing: Arc<PackageBacking>,
237}
238
239impl fmt::Debug for VerifiedPackage {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter
242            .debug_struct("VerifiedPackage")
243            .field("package_id", &self.package_id)
244            .field("digest", &self.digest)
245            .field("size", &self.size)
246            .field("manifest", &self.manifest)
247            .finish_non_exhaustive()
248    }
249}
250
251impl VerifiedPackage {
252    /// Trusted embedded-Core access to the verified immutable artifact. Hosted
253    /// actor DTOs never expose this path.
254    pub fn path(&self) -> &Path {
255        match self.backing.as_ref() {
256            PackageBacking::Owned(path) => path.as_ref(),
257            PackageBacking::Borrowed(path) => path,
258            PackageBacking::Cached(artifact) => &artifact.path,
259        }
260    }
261}
262
263enum PackageBacking {
264    Owned(tempfile::TempPath),
265    Borrowed(PathBuf),
266    Cached(Arc<CachedPackageArtifact>),
267}
268
269struct CachedPackageArtifact {
270    path: PathBuf,
271    package_id: String,
272    digest: String,
273    size: u64,
274    manifest: PackageManifestInfo,
275}
276
277struct CachedPackageEntry {
278    artifact: Arc<CachedPackageArtifact>,
279    last_access: u64,
280}
281
282struct CachedSourceEntry {
283    digest: String,
284    resolved_at: std::time::Instant,
285    last_access: u64,
286}
287
288struct PackageAcquisitionFlight {
289    result: Mutex<Option<Result<VerifiedPackage, ClientError>>>,
290    ready: Notify,
291    cancelled: AtomicBool,
292    cancellation: Notify,
293    waiters: AtomicUsize,
294    required_waiters: AtomicUsize,
295}
296
297impl PackageAcquisitionFlight {
298    fn new() -> Self {
299        Self {
300            result: Mutex::new(None),
301            ready: Notify::new(),
302            cancelled: AtomicBool::new(false),
303            cancellation: Notify::new(),
304            waiters: AtomicUsize::new(0),
305            required_waiters: AtomicUsize::new(0),
306        }
307    }
308
309    async fn wait(&self) -> Result<VerifiedPackage, ClientError> {
310        loop {
311            let notified = self.ready.notified();
312            if let Some(result) = self.result.lock().await.clone() {
313                return result;
314            }
315            notified.await;
316        }
317    }
318
319    async fn finish(&self, result: Result<VerifiedPackage, ClientError>) {
320        *self.result.lock().await = Some(result);
321        self.ready.notify_waiters();
322    }
323
324    fn register_waiter(self: &Arc<Self>, required: bool) -> PackageAcquisitionWaiter {
325        self.waiters.fetch_add(1, Ordering::AcqRel);
326        if required {
327            self.required_waiters.fetch_add(1, Ordering::AcqRel);
328        }
329        PackageAcquisitionWaiter {
330            flight: Arc::clone(self),
331            required,
332        }
333    }
334
335    async fn cancelled(&self) {
336        loop {
337            let notified = self.cancellation.notified();
338            if self.cancelled.load(Ordering::Acquire) {
339                return;
340            }
341            notified.await;
342        }
343    }
344}
345
346struct PackageAcquisitionWaiter {
347    flight: Arc<PackageAcquisitionFlight>,
348    required: bool,
349}
350
351impl Drop for PackageAcquisitionWaiter {
352    fn drop(&mut self) {
353        if self.required {
354            self.flight.required_waiters.fetch_sub(1, Ordering::AcqRel);
355        }
356        let previous = self.flight.waiters.fetch_sub(1, Ordering::AcqRel);
357        if previous == 1 && self.flight.required_waiters.load(Ordering::Acquire) == 0 {
358            self.flight.cancelled.store(true, Ordering::Release);
359            self.flight.cancellation.notify_waiters();
360        }
361    }
362}
363
364struct ProcessPackageCacheState {
365    entries: BTreeMap<String, CachedPackageEntry>,
366    source_index: BTreeMap<String, CachedSourceEntry>,
367    flights: BTreeMap<String, Arc<PackageAcquisitionFlight>>,
368    bytes: u64,
369    access_clock: u64,
370    hits: u64,
371    misses: u64,
372    coalesced_waiters: u64,
373    acquisitions: u64,
374    evictions: u64,
375    capacity_failures: u64,
376    cancelled_acquisitions: u64,
377}
378
379struct ProcessPackageCache {
380    options: ProcessPackageCacheOptions,
381    root: ProcessPackageCacheRoot,
382    acquisition_slots: Arc<Semaphore>,
383    state: Mutex<ProcessPackageCacheState>,
384}
385
386enum ProcessPackageCacheRoot {
387    Temporary(tempfile::TempDir),
388    Persistent {
389        path: PathBuf,
390        // Recovery, eviction, and generation pins are process-local. Hold an
391        // OS lock so another worker cannot remove this process's live files.
392        _lock: std::fs::File,
393    },
394}
395
396impl ProcessPackageCacheRoot {
397    fn path(&self) -> &Path {
398        match self {
399            Self::Temporary(root) => root.path(),
400            Self::Persistent { path, .. } => path,
401        }
402    }
403}
404
405fn recover_package_cache(
406    root: &Path,
407    options: &ProcessPackageCacheOptions,
408) -> Result<BTreeMap<String, CachedPackageEntry>, ClientError> {
409    let mut candidates = Vec::new();
410    for entry in std::fs::read_dir(root)
411        .map_err(|error| ClientError::PackageIo(format!("scan package cache directory: {error}")))?
412    {
413        let entry = entry.map_err(|error| {
414            ClientError::PackageIo(format!("read package cache directory entry: {error}"))
415        })?;
416        let path = entry.path();
417        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
418            continue;
419        };
420        if name.starts_with(".package-staging-") {
421            if let Err(error) = std::fs::remove_file(&path) {
422                tracing::warn!(%error, cache_path = %path.display(), "failed to remove stale package cache staging file");
423            }
424            continue;
425        }
426        let Some(digest_hex) = name.strip_suffix(".aospkg").map(str::to_owned) else {
427            continue;
428        };
429        if digest_hex.len() != 64
430            || !digest_hex
431                .bytes()
432                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
433        {
434            quarantine_invalid_cache_file(&path, "invalid cache filename");
435            continue;
436        }
437        let metadata = match std::fs::metadata(&path) {
438            Ok(metadata) if metadata.is_file() => metadata,
439            Ok(_) => {
440                quarantine_invalid_cache_file(&path, "cache entry is not a regular file");
441                continue;
442            }
443            Err(error) => {
444                tracing::warn!(%error, cache_path = %path.display(), "failed to stat package cache entry during recovery");
445                continue;
446            }
447        };
448        let modified = metadata
449            .modified()
450            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
451        candidates.push((
452            modified,
453            path,
454            format!("sha256:{digest_hex}"),
455            metadata.len(),
456        ));
457    }
458    candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
459
460    let mut recovered = BTreeMap::new();
461    let mut recovered_bytes = 0u64;
462    let total = candidates.len();
463    for (index, (_, path, expected_digest, size)) in candidates.into_iter().enumerate() {
464        let validation = (|| {
465            enforce_package_size(size, options.max_bytes.min(DEFAULT_MAX_PACKAGE_BYTES))?;
466            let digest = digest_file_sync(&path, options.max_bytes)?;
467            verify_expected_digest(&digest, Some(&expected_digest))?;
468            let manifest = validate_package_file_sync(&path, size)?;
469            Ok::<_, ClientError>((digest, manifest))
470        })();
471        let (digest, manifest) = match validation {
472            Ok(value) => value,
473            Err(error) => {
474                tracing::warn!(?error, cache_path = %path.display(), "discarding invalid package cache entry during recovery");
475                quarantine_invalid_cache_file(&path, "validation failure");
476                continue;
477            }
478        };
479        if recovered.len() >= options.max_entries
480            || recovered_bytes.saturating_add(size) > options.max_bytes
481        {
482            quarantine_invalid_cache_file(&path, "entry exceeds recovered cache capacity");
483            continue;
484        }
485        recovered_bytes = recovered_bytes.saturating_add(size);
486        let artifact = Arc::new(CachedPackageArtifact {
487            path,
488            package_id: digest.clone(),
489            digest: digest.clone(),
490            size,
491            manifest,
492        });
493        recovered.insert(
494            digest,
495            CachedPackageEntry {
496                artifact,
497                last_access: u64::try_from(total.saturating_sub(index)).unwrap_or(u64::MAX),
498            },
499        );
500    }
501    Ok(recovered)
502}
503
504fn quarantine_invalid_cache_file(path: &Path, reason: &'static str) {
505    if let Err(error) = std::fs::remove_file(path) {
506        tracing::error!(%error, %reason, cache_path = %path.display(), "failed to remove invalid package cache entry");
507    }
508}
509
510fn ensure_cache_disk_reserve(
511    root: &Path,
512    requested: u64,
513    min_free_bytes: u64,
514) -> Result<(), ClientError> {
515    let available = fs2::available_space(root).map_err(|error| {
516        ClientError::PackageIo(format!("inspect package cache free space: {error}"))
517    })?;
518    if available.saturating_sub(requested) < min_free_bytes {
519        return Err(ClientError::PackageCacheConfiguration(format!(
520            "package cache needs {requested} bytes while preserving a {min_free_bytes}-byte free-disk reserve; only {available} bytes are available"
521        )));
522    }
523    Ok(())
524}
525
526impl ProcessPackageCache {
527    fn new(options: ProcessPackageCacheOptions) -> Result<Self, ClientError> {
528        options.validate()?;
529        let root = match &options.root {
530            Some(path) => {
531                std::fs::create_dir_all(path).map_err(|error| {
532                    ClientError::PackageIo(format!("create persistent package cache: {error}"))
533                })?;
534                let lock = std::fs::OpenOptions::new()
535                    .create(true)
536                    .truncate(false)
537                    .read(true)
538                    .write(true)
539                    .open(path.join(".cache.lock"))
540                    .map_err(|error| {
541                        ClientError::PackageIo(format!("open package cache lock: {error}"))
542                    })?;
543                fs2::FileExt::try_lock_exclusive(&lock).map_err(|error| {
544                    ClientError::PackageCacheConfiguration(format!(
545                        "package cache directory {} is already in use or cannot be locked: {error}; configure a distinct --package-cache-dir for each worker process",
546                        path.display()
547                    ))
548                })?;
549                ProcessPackageCacheRoot::Persistent {
550                    path: path.clone(),
551                    _lock: lock,
552                }
553            }
554            None => ProcessPackageCacheRoot::Temporary(
555                tempfile::Builder::new()
556                    .prefix("agentos-process-package-cache-")
557                    .tempdir()
558                    .map_err(|error| {
559                        ClientError::PackageIo(format!("create process package cache: {error}"))
560                    })?,
561            ),
562        };
563        let recovered = recover_package_cache(root.path(), &options)?;
564        Ok(Self {
565            acquisition_slots: Arc::new(Semaphore::new(options.max_concurrent_acquisitions)),
566            options,
567            root,
568            state: Mutex::new(ProcessPackageCacheState {
569                bytes: recovered.values().map(|entry| entry.artifact.size).sum(),
570                access_clock: recovered.len() as u64,
571                entries: recovered,
572                source_index: BTreeMap::new(),
573                flights: BTreeMap::new(),
574                hits: 0,
575                misses: 0,
576                coalesced_waiters: 0,
577                acquisitions: 0,
578                evictions: 0,
579                capacity_failures: 0,
580                cancelled_acquisitions: 0,
581            }),
582        })
583    }
584
585    async fn get(self: &Arc<Self>, digest: &str) -> Option<VerifiedPackage> {
586        let mut state = self.state.lock().await;
587        let artifact = state
588            .entries
589            .get(digest)
590            .map(|entry| Arc::clone(&entry.artifact))?;
591        state.access_clock = state.access_clock.saturating_add(1);
592        let access = state.access_clock;
593        if let Some(entry) = state.entries.get_mut(digest) {
594            entry.last_access = access;
595        }
596        state.hits = state.hits.saturating_add(1);
597        Some(cached_verified_package(artifact))
598    }
599
600    async fn get_source(self: &Arc<Self>, source_key: &str) -> Option<VerifiedPackage> {
601        let mut state = self.state.lock().await;
602        let (digest, expired) = state.source_index.get(source_key).map(|entry| {
603            (
604                entry.digest.clone(),
605                entry.resolved_at.elapsed() > Duration::from_millis(self.options.source_ttl_ms),
606            )
607        })?;
608        if expired {
609            state.source_index.remove(source_key);
610            return None;
611        }
612        let Some(artifact) = state
613            .entries
614            .get(&digest)
615            .map(|entry| Arc::clone(&entry.artifact))
616        else {
617            state.source_index.remove(source_key);
618            return None;
619        };
620        state.access_clock = state.access_clock.saturating_add(1);
621        let access = state.access_clock;
622        if let Some(entry) = state.entries.get_mut(&digest) {
623            entry.last_access = access;
624        }
625        if let Some(entry) = state.source_index.get_mut(source_key) {
626            entry.last_access = access;
627        }
628        state.hits = state.hits.saturating_add(1);
629        Some(cached_verified_package(artifact))
630    }
631
632    async fn record_source(&self, source_key: String, digest: String) {
633        let mut state = self.state.lock().await;
634        if !state.entries.contains_key(&digest) {
635            return;
636        }
637        state.access_clock = state.access_clock.saturating_add(1);
638        let access = state.access_clock;
639        if !state.source_index.contains_key(&source_key)
640            && state.source_index.len() >= self.options.max_source_entries
641        {
642            if let Some(oldest) = state
643                .source_index
644                .iter()
645                .min_by_key(|(_, entry)| entry.last_access)
646                .map(|(source, _)| source.clone())
647            {
648                state.source_index.remove(&oldest);
649            }
650        }
651        state.source_index.insert(
652            source_key,
653            CachedSourceEntry {
654                digest,
655                resolved_at: std::time::Instant::now(),
656                last_access: access,
657            },
658        );
659        if state.source_index.len() * 100 / self.options.max_source_entries >= 80 {
660            tracing::warn!(
661                limit = "process_package_cache_source_entries",
662                observed = state.source_index.len(),
663                capacity = self.options.max_source_entries,
664                configuration_path = "ProcessPackageCacheOptions.max_source_entries",
665                "process package cache source index approaching configured limit"
666            );
667        }
668    }
669
670    async fn invalidate_source(&self, source_key: &str) {
671        self.state.lock().await.source_index.remove(source_key);
672    }
673
674    async fn get_or_acquire<F>(
675        self: &Arc<Self>,
676        flight_key: String,
677        expected_digest: Option<&str>,
678        acquisition: F,
679    ) -> Result<VerifiedPackage, ClientError>
680    where
681        F: std::future::Future<Output = Result<VerifiedPackage, ClientError>> + Send + 'static,
682    {
683        self.get_or_acquire_with_priority(flight_key, expected_digest, true, acquisition)
684            .await
685    }
686
687    async fn get_or_acquire_optional<F>(
688        self: &Arc<Self>,
689        flight_key: String,
690        expected_digest: Option<&str>,
691        acquisition: F,
692    ) -> Result<VerifiedPackage, ClientError>
693    where
694        F: std::future::Future<Output = Result<VerifiedPackage, ClientError>> + Send + 'static,
695    {
696        self.get_or_acquire_with_priority(flight_key, expected_digest, false, acquisition)
697            .await
698    }
699
700    async fn get_or_acquire_with_priority<F>(
701        self: &Arc<Self>,
702        flight_key: String,
703        expected_digest: Option<&str>,
704        required: bool,
705        acquisition: F,
706    ) -> Result<VerifiedPackage, ClientError>
707    where
708        F: std::future::Future<Output = Result<VerifiedPackage, ClientError>> + Send + 'static,
709    {
710        if let Some(digest) = expected_digest {
711            if let Some(package) = self.get(digest).await {
712                return Ok(package);
713            }
714        } else if let Some(package) = self.get_source(&flight_key).await {
715            return Ok(package);
716        }
717
718        let (flight, leader, waiter) = {
719            let mut state = self.state.lock().await;
720            state.misses = state.misses.saturating_add(1);
721            let existing = state
722                .flights
723                .get(&flight_key)
724                .filter(|flight| !flight.cancelled.load(Ordering::Acquire))
725                .cloned();
726            if let Some(flight) = existing {
727                state.coalesced_waiters = state.coalesced_waiters.saturating_add(1);
728                let waiter = flight.register_waiter(required);
729                (flight, false, waiter)
730            } else {
731                state.flights.remove(&flight_key);
732                if state.flights.len() >= self.options.max_pending_acquisitions {
733                    return Err(ClientError::PackageCachePendingLimit {
734                        limit: self.options.max_pending_acquisitions,
735                    });
736                }
737                let flight = Arc::new(PackageAcquisitionFlight::new());
738                let waiter = flight.register_waiter(required);
739                state.flights.insert(flight_key.clone(), flight.clone());
740                if state.flights.len() * 100 / self.options.max_pending_acquisitions >= 80 {
741                    tracing::warn!(
742                        limit = "process_package_cache_pending_acquisitions",
743                        observed = state.flights.len(),
744                        capacity = self.options.max_pending_acquisitions,
745                        configuration_path = "ProcessPackageCacheOptions.max_pending_acquisitions",
746                        "process package cache pending acquisitions approaching configured limit"
747                    );
748                }
749                (flight, true, waiter)
750            }
751        };
752
753        if leader {
754            let cache = Arc::clone(self);
755            let completion = Arc::clone(&flight);
756            let record_source = expected_digest.is_none();
757            tokio::spawn(async move {
758                let result = tokio::select! {
759                    _ = completion.cancelled() => {
760                        let mut state = cache.state.lock().await;
761                        state.cancelled_acquisitions =
762                            state.cancelled_acquisitions.saturating_add(1);
763                        Err(ClientError::PackageDownload(String::from(
764                            "optional package preload acquisition was cancelled after its final waiter left",
765                        )))
766                    }
767                    result = tokio::time::timeout(
768                        Duration::from_millis(cache.options.acquisition_timeout_ms),
769                        cache.run_acquisition(acquisition),
770                    ) => result.unwrap_or_else(|_| {
771                        Err(package_timeout_error(
772                            "package cache acquisition", cache.options.acquisition_timeout_ms,
773                            "ProcessPackageCacheOptions.acquisition_timeout_ms", "process",
774                        ))
775                    }),
776                };
777                if record_source {
778                    if let Ok(package) = &result {
779                        cache
780                            .record_source(flight_key.clone(), package.digest.clone())
781                            .await;
782                    }
783                }
784                {
785                    let mut state = cache.state.lock().await;
786                    if state
787                        .flights
788                        .get(&flight_key)
789                        .is_some_and(|registered| Arc::ptr_eq(registered, &completion))
790                    {
791                        state.flights.remove(&flight_key);
792                    }
793                }
794                completion.finish(result).await;
795            });
796        } else {
797            drop(acquisition);
798        }
799        let result = flight.wait().await;
800        drop(waiter);
801        result
802    }
803
804    async fn run_acquisition<F>(
805        self: &Arc<Self>,
806        acquisition: F,
807    ) -> Result<VerifiedPackage, ClientError>
808    where
809        F: std::future::Future<Output = Result<VerifiedPackage, ClientError>> + Send + 'static,
810    {
811        let _permit = Arc::clone(&self.acquisition_slots)
812            .acquire_owned()
813            .await
814            .map_err(|_| {
815                ClientError::PackageCacheConfiguration(String::from(
816                    "process package cache acquisition semaphore closed",
817                ))
818            })?;
819        {
820            let mut state = self.state.lock().await;
821            state.acquisitions = state.acquisitions.saturating_add(1);
822        }
823        let package = acquisition.await?;
824        self.insert(package).await
825    }
826
827    async fn insert(
828        self: &Arc<Self>,
829        package: VerifiedPackage,
830    ) -> Result<VerifiedPackage, ClientError> {
831        {
832            let mut state = self.state.lock().await;
833            if let Some(entry) = state.entries.get(&package.digest) {
834                return Ok(cached_verified_package(Arc::clone(&entry.artifact)));
835            }
836            if package.size > self.options.max_bytes {
837                state.capacity_failures = state.capacity_failures.saturating_add(1);
838                return Err(ClientError::PackageCacheCapacity {
839                    requested: package.size,
840                    current: state.bytes,
841                    limit: self.options.max_bytes,
842                });
843            }
844            self.evict_for_insert(&mut state, package.size)?;
845        }
846
847        ensure_cache_disk_reserve(self.root.path(), package.size, self.options.min_free_bytes)?;
848
849        let staged = stage_cached_package(
850            package.path().to_path_buf(),
851            self.root.path().to_path_buf(),
852            package.size,
853            package.digest.clone(),
854        )
855        .await?;
856        let mut state = self.state.lock().await;
857        if let Some(entry) = state.entries.get(&package.digest) {
858            return Ok(cached_verified_package(Arc::clone(&entry.artifact)));
859        }
860        self.evict_for_insert(&mut state, package.size)?;
861
862        let digest_hex = package.digest.strip_prefix("sha256:").ok_or_else(|| {
863            ClientError::PackageCacheConfiguration(String::from(
864                "verified package digest lost its sha256 prefix",
865            ))
866        })?;
867        let path = self.root.path().join(format!("{digest_hex}.aospkg"));
868        staged.persist_noclobber(&path).map_err(|error| {
869            ClientError::PackageIo(format!("publish immutable cached package: {error}"))
870        })?;
871        let mut permissions = match std::fs::metadata(&path) {
872            Ok(metadata) => metadata.permissions(),
873            Err(error) => {
874                remove_failed_cache_publication(&path, "metadata failure");
875                return Err(ClientError::PackageIo(format!(
876                    "stat cached package: {error}"
877                )));
878            }
879        };
880        permissions.set_readonly(true);
881        if let Err(error) = std::fs::set_permissions(&path, permissions) {
882            remove_failed_cache_publication(&path, "permission failure");
883            return Err(ClientError::PackageIo(format!(
884                "make cached package immutable: {error}"
885            )));
886        }
887        let artifact = Arc::new(CachedPackageArtifact {
888            path,
889            package_id: package.package_id,
890            digest: package.digest.clone(),
891            size: package.size,
892            manifest: package.manifest,
893        });
894        state.access_clock = state.access_clock.saturating_add(1);
895        let access = state.access_clock;
896        state.bytes = state.bytes.saturating_add(artifact.size);
897        state.entries.insert(
898            artifact.digest.clone(),
899            CachedPackageEntry {
900                artifact: Arc::clone(&artifact),
901                last_access: access,
902            },
903        );
904        if state.bytes.saturating_mul(100) / self.options.max_bytes >= 80
905            || state.entries.len() * 100 / self.options.max_entries >= 80
906        {
907            tracing::warn!(
908                limit = "process_package_cache_capacity",
909                observed_bytes = state.bytes,
910                capacity_bytes = self.options.max_bytes,
911                observed_entries = state.entries.len(),
912                capacity_entries = self.options.max_entries,
913                configuration_path = "ProcessPackageCacheOptions",
914                "process package cache approaching configured capacity"
915            );
916        }
917        Ok(cached_verified_package(artifact))
918    }
919
920    fn evict_for_insert(
921        &self,
922        state: &mut ProcessPackageCacheState,
923        requested: u64,
924    ) -> Result<(), ClientError> {
925        while state.entries.len() >= self.options.max_entries
926            || state.bytes.saturating_add(requested) > self.options.max_bytes
927        {
928            let victim = state
929                .entries
930                .iter()
931                .filter(|(_, entry)| Arc::strong_count(&entry.artifact) == 1)
932                .min_by_key(|(_, entry)| entry.last_access)
933                .map(|(digest, _)| digest.clone());
934            let Some(victim) = victim else {
935                state.capacity_failures = state.capacity_failures.saturating_add(1);
936                if state.entries.len() >= self.options.max_entries {
937                    return Err(ClientError::PackageCacheEntryCapacity {
938                        current: state.entries.len(),
939                        limit: self.options.max_entries,
940                    });
941                }
942                return Err(ClientError::PackageCacheCapacity {
943                    requested,
944                    current: state.bytes,
945                    limit: self.options.max_bytes,
946                });
947            };
948            let entry = state
949                .entries
950                .remove(&victim)
951                .expect("selected package cache victim must remain registered");
952            if let Err(error) = std::fs::remove_file(&entry.artifact.path) {
953                state.entries.insert(victim, entry);
954                return Err(ClientError::PackageIo(format!(
955                    "evict cached package: {error}"
956                )));
957            }
958            state.bytes = state.bytes.saturating_sub(entry.artifact.size);
959            state
960                .source_index
961                .retain(|_, source| source.digest != victim);
962            state.evictions = state.evictions.saturating_add(1);
963        }
964        Ok(())
965    }
966
967    async fn stats(&self) -> ProcessPackageCacheStats {
968        let state = self.state.lock().await;
969        ProcessPackageCacheStats {
970            entries: state.entries.len(),
971            source_entries: state.source_index.len(),
972            bytes: state.bytes,
973            pinned_entries: state
974                .entries
975                .values()
976                .filter(|entry| Arc::strong_count(&entry.artifact) > 1)
977                .count(),
978            pending_acquisitions: state.flights.len(),
979            hits: state.hits,
980            misses: state.misses,
981            coalesced_waiters: state.coalesced_waiters,
982            acquisitions: state.acquisitions,
983            evictions: state.evictions,
984            capacity_failures: state.capacity_failures,
985            cancelled_acquisitions: state.cancelled_acquisitions,
986        }
987    }
988}
989
990fn remove_failed_cache_publication(path: &Path, failure: &'static str) {
991    if let Err(cleanup_error) = std::fs::remove_file(path) {
992        tracing::error!(
993            %cleanup_error,
994            cache_path = %path.display(),
995            %failure,
996            "failed to remove unsuccessfully published cached package"
997        );
998    }
999}
1000
1001fn cached_verified_package(artifact: Arc<CachedPackageArtifact>) -> VerifiedPackage {
1002    VerifiedPackage {
1003        package_id: artifact.package_id.clone(),
1004        digest: artifact.digest.clone(),
1005        size: artifact.size,
1006        manifest: artifact.manifest.clone(),
1007        backing: Arc::new(PackageBacking::Cached(artifact)),
1008    }
1009}
1010
1011async fn stage_cached_package(
1012    source: PathBuf,
1013    root: PathBuf,
1014    expected_size: u64,
1015    expected_digest: String,
1016) -> Result<tempfile::TempPath, ClientError> {
1017    tokio::task::spawn_blocking(move || {
1018        let mut source = std::fs::File::open(&source)
1019            .map_err(|error| ClientError::PackageIo(format!("open verified package: {error}")))?;
1020        let mut staged = tempfile::Builder::new()
1021            .prefix(".package-staging-")
1022            .tempfile_in(root)
1023            .map_err(|error| {
1024                ClientError::PackageIo(format!("create package cache staging file: {error}"))
1025            })?;
1026        let mut hasher = Sha256::new();
1027        let mut buffer = vec![0u8; DOWNLOAD_CHUNK_BYTES];
1028        let mut copied = 0u64;
1029        loop {
1030            let count = source.read(&mut buffer).map_err(|error| {
1031                ClientError::PackageIo(format!("read verified package for cache: {error}"))
1032            })?;
1033            if count == 0 {
1034                break;
1035            }
1036            staged.as_file_mut().write_all(&buffer[..count]).map_err(|error| {
1037                ClientError::PackageIo(format!("copy package into process cache: {error}"))
1038            })?;
1039            hasher.update(&buffer[..count]);
1040            copied = copied.saturating_add(count as u64);
1041        }
1042        if copied != expected_size {
1043            return Err(ClientError::PackageIo(format!(
1044                "verified package changed while caching: expected {expected_size} bytes, copied {copied}"
1045            )));
1046        }
1047        let copied_digest = format!("sha256:{}", hex_digest(hasher.finalize().as_slice()));
1048        if copied_digest != expected_digest {
1049            return Err(ClientError::PackageDigestMismatch {
1050                expected: expected_digest,
1051                actual: copied_digest,
1052            });
1053        }
1054        staged.as_file().sync_all().map_err(|error| {
1055            ClientError::PackageIo(format!("sync package cache staging file: {error}"))
1056        })?;
1057        Ok(staged.into_temp_path())
1058    })
1059    .await
1060    .map_err(|error| ClientError::PackageIo(format!("package cache task failed: {error}")))?
1061}
1062
1063#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1064#[cfg_attr(feature = "contract", ts(rename = "ActorInstalledSoftware"))]
1065#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066#[serde(rename_all = "camelCase")]
1067pub struct InstalledSoftware {
1068    pub package_id: String,
1069    pub digest: String,
1070    pub size_bytes: u64,
1071    pub package_name: String,
1072    pub version: String,
1073    pub commands: Vec<String>,
1074}
1075
1076impl From<&VerifiedPackage> for InstalledSoftware {
1077    fn from(package: &VerifiedPackage) -> Self {
1078        Self {
1079            package_id: package.package_id.clone(),
1080            digest: package.digest.clone(),
1081            size_bytes: package.size,
1082            package_name: package.manifest.name.clone(),
1083            version: package.manifest.version.clone(),
1084            commands: package.manifest.commands.clone(),
1085        }
1086    }
1087}
1088
1089#[derive(Clone)]
1090pub struct PackageResolver {
1091    options: PackageResolverOptions,
1092    cache: Arc<ProcessPackageCache>,
1093}
1094
1095/// Validate source syntax without initializing the process package cache.
1096pub fn validate_package_source(source: &PackageSource) -> Result<(), ClientError> {
1097    match source {
1098        PackageSource::Url {
1099            url,
1100            expected_digest,
1101        } => {
1102            parse_package_url(url)?;
1103            normalize_expected_digest(expected_digest.as_deref())?;
1104        }
1105        PackageSource::Path {
1106            path,
1107            expected_digest,
1108        } => {
1109            if path.is_empty() {
1110                return Err(ClientError::InvalidPackageSource(String::from(
1111                    "package path cannot be empty",
1112                )));
1113            }
1114            normalize_expected_digest(expected_digest.as_deref())?;
1115        }
1116    }
1117    Ok(())
1118}
1119
1120impl PackageResolver {
1121    #[cfg(feature = "sidecar-internals")]
1122    pub(crate) fn acquisition_timeout_ms(&self) -> u64 {
1123        self.cache.options.acquisition_timeout_ms
1124    }
1125
1126    pub fn new(options: PackageResolverOptions) -> Result<Self, ClientError> {
1127        options.validate()?;
1128        Ok(Self {
1129            options,
1130            cache: process_package_cache()?,
1131        })
1132    }
1133
1134    pub async fn resolve(&self, source: PackageSource) -> Result<VerifiedPackage, ClientError> {
1135        self.resolve_with_priority(source, true).await
1136    }
1137
1138    /// Resolve an advisory preload. If every advisory waiter is dropped and no
1139    /// required resolution has joined the same single-flight acquisition, the
1140    /// download is cancelled and its temporary file is removed on drop.
1141    pub async fn preload(&self, source: PackageSource) -> Result<VerifiedPackage, ClientError> {
1142        self.resolve_with_priority(source, false).await
1143    }
1144
1145    async fn resolve_with_priority(
1146        &self,
1147        source: PackageSource,
1148        required: bool,
1149    ) -> Result<VerifiedPackage, ClientError> {
1150        self.validate_source(&source)?;
1151        if let PackageSource::Url { url, .. } = &source {
1152            // A shared cache hit is not permission to use a source rejected by
1153            // this resolver's policy (including cached loopback artifacts).
1154            self.validate_url_addresses(&parse_package_url(url)?)
1155                .await?;
1156        }
1157        let source_key = match &source {
1158            PackageSource::Url { url, .. } => format!("url:{}", parse_package_url(url)?.as_str()),
1159            PackageSource::Path { path, .. } => format!("path:{path}"),
1160        };
1161        let expected_digest = match &source {
1162            PackageSource::Url {
1163                expected_digest, ..
1164            }
1165            | PackageSource::Path {
1166                expected_digest, ..
1167            } => normalize_expected_digest(expected_digest.as_deref())?,
1168        };
1169        // Local package paths are trusted but mutable. Do not let a URL-style
1170        // source alias hide replaced or deleted bytes. Requests that overlap
1171        // still coalesce on the path flight key, and the immutable digest
1172        // entry is reused after the current file has been hashed.
1173        if matches!(&source, PackageSource::Path { .. }) && expected_digest.is_none() {
1174            self.cache.invalidate_source(&source_key).await;
1175        }
1176        let flight_key = if let Some(digest) = &expected_digest {
1177            format!("digest:{digest}")
1178        } else {
1179            source_key.clone()
1180        };
1181        let resolver = self.clone();
1182        let package = if required {
1183            self.cache
1184                .get_or_acquire(flight_key, expected_digest.as_deref(), async move {
1185                    resolver.resolve_uncached(source).await
1186                })
1187                .await?
1188        } else {
1189            self.cache
1190                .get_or_acquire_optional(flight_key, expected_digest.as_deref(), async move {
1191                    resolver.resolve_uncached(source).await
1192                })
1193                .await?
1194        };
1195        enforce_package_size(package.size, self.options.max_package_bytes)?;
1196        // Exact coordinator preloads use a digest flight for correctness. Also
1197        // remember the bounded short-lived source alias so a later actor whose
1198        // creation input names only the same URL can reuse the warmed object.
1199        if expected_digest.is_some() {
1200            self.cache
1201                .record_source(source_key, package.digest.clone())
1202                .await;
1203        }
1204        Ok(package)
1205    }
1206
1207    async fn resolve_uncached(
1208        &self,
1209        source: PackageSource,
1210    ) -> Result<VerifiedPackage, ClientError> {
1211        match source {
1212            PackageSource::Url {
1213                url,
1214                expected_digest,
1215            } => self.resolve_url(&url, expected_digest.as_deref()).await,
1216            PackageSource::Path {
1217                path,
1218                expected_digest,
1219            } => self.resolve_path(&path, expected_digest.as_deref()).await,
1220        }
1221    }
1222
1223    pub async fn cache_stats(&self) -> ProcessPackageCacheStats {
1224        self.cache.stats().await
1225    }
1226
1227    /// Validate source syntax and digest form without opening a path, resolving
1228    /// DNS, or starting a download.
1229    pub fn validate_source(&self, source: &PackageSource) -> Result<(), ClientError> {
1230        validate_package_source(source)?;
1231        if let PackageSource::Url { url, .. } = source {
1232            if parse_package_url(url)?.scheme() == "http" && !self.options.allow_insecure_local_http
1233            {
1234                return Err(ClientError::InvalidPackageSource(String::from(
1235                    "plain HTTP packages require allow_insecure_local_http and a loopback address",
1236                )));
1237            }
1238        }
1239        Ok(())
1240    }
1241
1242    async fn resolve_path(
1243        &self,
1244        source_path: &str,
1245        expected_digest: Option<&str>,
1246    ) -> Result<VerifiedPackage, ClientError> {
1247        if source_path.is_empty() {
1248            return Err(ClientError::InvalidPackageSource(String::from(
1249                "package path cannot be empty",
1250            )));
1251        }
1252        let path = tokio::fs::canonicalize(source_path)
1253            .await
1254            .map_err(|error| ClientError::PackageIo(format!("open package path: {error}")))?;
1255        let metadata = tokio::fs::metadata(&path)
1256            .await
1257            .map_err(|error| ClientError::PackageIo(format!("stat package path: {error}")))?;
1258        if !metadata.is_file() {
1259            return Err(ClientError::InvalidPackageSource(String::from(
1260                "package path must identify a regular .aospkg file",
1261            )));
1262        }
1263        enforce_package_size(metadata.len(), self.options.max_package_bytes)?;
1264
1265        let digest = digest_file(&path, self.options.max_package_bytes).await?;
1266        verify_expected_digest(&digest, expected_digest)?;
1267        let manifest = validate_package_file(path.clone(), metadata.len()).await?;
1268        Ok(verified_package(
1269            digest,
1270            metadata.len(),
1271            manifest,
1272            PackageBacking::Borrowed(path),
1273        ))
1274    }
1275
1276    async fn resolve_url(
1277        &self,
1278        raw_url: &str,
1279        expected_digest: Option<&str>,
1280    ) -> Result<VerifiedPackage, ClientError> {
1281        normalize_expected_digest(expected_digest)?;
1282        let mut url = parse_package_url(raw_url)?;
1283        let mut redirect_count = 0usize;
1284
1285        let response = loop {
1286            let (client, label) = self.client_for_url(&url).await?;
1287            let response = client
1288                .get(url.clone())
1289                .header(ACCEPT_ENCODING, "identity")
1290                .send()
1291                .await
1292                .map_err(|error| package_request_error(&label, &error, &self.options))?;
1293            enforce_response_header_limits(response.headers())?;
1294
1295            if is_redirect(response.status()) {
1296                if redirect_count == self.options.max_redirects {
1297                    return Err(ClientError::PackageDownload(format!(
1298                        "package redirect limit {} reached at {label}; raise max_redirects",
1299                        self.options.max_redirects
1300                    )));
1301                }
1302                let location = response.headers().get(LOCATION).ok_or_else(|| {
1303                    ClientError::PackageDownload(format!(
1304                        "package redirect from {label} omitted Location"
1305                    ))
1306                })?;
1307                let location = location.to_str().map_err(|_| {
1308                    ClientError::PackageDownload(format!(
1309                        "package redirect from {label} has a non-text Location"
1310                    ))
1311                })?;
1312                url = url.join(location).map_err(|_| {
1313                    ClientError::PackageDownload(format!(
1314                        "package redirect from {label} has an invalid Location"
1315                    ))
1316                })?;
1317                validate_package_url_shape(&url)?;
1318                redirect_count += 1;
1319                continue;
1320            }
1321            if !response.status().is_success() {
1322                return Err(ClientError::PackageDownload(format!(
1323                    "package request to {label} returned HTTP {}",
1324                    response.status().as_u16()
1325                )));
1326            }
1327            if let Some(size) = response.content_length() {
1328                enforce_package_size(size, self.options.max_package_bytes)?;
1329            }
1330            break response;
1331        };
1332
1333        let named = tempfile::Builder::new()
1334            .prefix("agentos-package-")
1335            .suffix(".aospkg")
1336            .tempfile()
1337            .map_err(|error| {
1338                ClientError::PackageIo(format!("create package staging file: {error}"))
1339            })?;
1340        let (file, temp_path) = named.into_parts();
1341        let mut file = tokio::fs::File::from_std(file);
1342        let mut stream = response.bytes_stream();
1343        let mut hasher = Sha256::new();
1344        let mut size = 0u64;
1345        while let Some(chunk) = stream.next().await {
1346            let chunk = chunk.map_err(|error| {
1347                if error.is_timeout() {
1348                    package_timeout_error(
1349                        "package response body",
1350                        self.options.download_timeout_ms,
1351                        "PackageResolverOptions.download_timeout_ms",
1352                        "session",
1353                    )
1354                } else {
1355                    ClientError::PackageDownload(String::from(
1356                        "package response failed while reading the body",
1357                    ))
1358                }
1359            })?;
1360            size = size
1361                .checked_add(chunk.len() as u64)
1362                .ok_or(ClientError::PackageTooLarge {
1363                    observed: u64::MAX,
1364                    limit: self.options.max_package_bytes,
1365                })?;
1366            enforce_package_size(size, self.options.max_package_bytes)?;
1367            hasher.update(&chunk);
1368            file.write_all(&chunk).await.map_err(|error| {
1369                ClientError::PackageIo(format!("write package staging file: {error}"))
1370            })?;
1371        }
1372        file.flush().await.map_err(|error| {
1373            ClientError::PackageIo(format!("flush package staging file: {error}"))
1374        })?;
1375        drop(file);
1376
1377        let digest = format!("sha256:{}", hex_digest(hasher.finalize().as_slice()));
1378        verify_expected_digest(&digest, expected_digest)?;
1379        let manifest = validate_package_file(temp_path.to_path_buf(), size).await?;
1380        Ok(verified_package(
1381            digest,
1382            size,
1383            manifest,
1384            PackageBacking::Owned(temp_path),
1385        ))
1386    }
1387
1388    async fn validate_url_addresses(
1389        &self,
1390        url: &Url,
1391    ) -> Result<Vec<std::net::SocketAddr>, ClientError> {
1392        validate_package_url_shape(url)?;
1393        let label = redacted_url(url);
1394        let host = url.host_str().ok_or_else(|| {
1395            ClientError::InvalidPackageSource(String::from("package URL must include a host"))
1396        })?;
1397        let port = url.port_or_known_default().ok_or_else(|| {
1398            ClientError::InvalidPackageSource(String::from("package URL has no usable port"))
1399        })?;
1400        let addresses = resolve_addresses(host, port, self.options.connect_timeout_ms).await?;
1401        let local_http = url.scheme() == "http";
1402        if local_http
1403            && (!self.options.allow_insecure_local_http
1404                || addresses.iter().any(|address| !address.ip().is_loopback()))
1405        {
1406            return Err(ClientError::InvalidPackageSource(String::from(
1407                "plain HTTP packages are allowed only for loopback addresses when allow_insecure_local_http is enabled",
1408            )));
1409        }
1410        if addresses.iter().any(|address| {
1411            !is_public_ip(address.ip())
1412                && !(self.options.allow_insecure_local_http && address.ip().is_loopback())
1413        }) {
1414            return Err(ClientError::InvalidPackageSource(format!(
1415                "package URL {label} resolves to a private or special-purpose address"
1416            )));
1417        }
1418        Ok(addresses)
1419    }
1420
1421    async fn client_for_url(&self, url: &Url) -> Result<(reqwest::Client, String), ClientError> {
1422        let addresses = self.validate_url_addresses(url).await?;
1423        let label = redacted_url(url);
1424        let host = url.host_str().expect("validated package URL host");
1425        let mut builder = reqwest::Client::builder()
1426            .connect_timeout(Duration::from_millis(self.options.connect_timeout_ms))
1427            .timeout(Duration::from_millis(self.options.download_timeout_ms))
1428            .redirect(redirect::Policy::none())
1429            .no_proxy();
1430        if host.parse::<IpAddr>().is_err() {
1431            builder = builder.resolve_to_addrs(host, &addresses);
1432        }
1433        let client = builder.build().map_err(|_| {
1434            ClientError::PackageDownload(String::from("build isolated package HTTP client"))
1435        })?;
1436        Ok((client, label))
1437    }
1438}
1439
1440fn verified_package(
1441    digest: String,
1442    size: u64,
1443    manifest: PackageManifestInfo,
1444    backing: PackageBacking,
1445) -> VerifiedPackage {
1446    VerifiedPackage {
1447        package_id: digest.clone(),
1448        digest,
1449        size,
1450        manifest,
1451        backing: Arc::new(backing),
1452    }
1453}
1454
1455async fn digest_file(path: &Path, limit: u64) -> Result<String, ClientError> {
1456    let mut file = tokio::fs::File::open(path)
1457        .await
1458        .map_err(|error| ClientError::PackageIo(format!("open package file: {error}")))?;
1459    let mut buffer = vec![0u8; DOWNLOAD_CHUNK_BYTES];
1460    let mut hasher = Sha256::new();
1461    let mut size = 0u64;
1462    loop {
1463        let count = file
1464            .read(&mut buffer)
1465            .await
1466            .map_err(|error| ClientError::PackageIo(format!("read package file: {error}")))?;
1467        if count == 0 {
1468            break;
1469        }
1470        size = size
1471            .checked_add(count as u64)
1472            .ok_or(ClientError::PackageTooLarge {
1473                observed: u64::MAX,
1474                limit,
1475            })?;
1476        enforce_package_size(size, limit)?;
1477        hasher.update(&buffer[..count]);
1478    }
1479    Ok(format!(
1480        "sha256:{}",
1481        hex_digest(hasher.finalize().as_slice())
1482    ))
1483}
1484
1485fn digest_file_sync(path: &Path, limit: u64) -> Result<String, ClientError> {
1486    let mut file = std::fs::File::open(path)
1487        .map_err(|error| ClientError::PackageIo(format!("open package file: {error}")))?;
1488    let mut buffer = vec![0u8; DOWNLOAD_CHUNK_BYTES];
1489    let mut hasher = Sha256::new();
1490    let mut size = 0u64;
1491    loop {
1492        let count = file
1493            .read(&mut buffer)
1494            .map_err(|error| ClientError::PackageIo(format!("read package file: {error}")))?;
1495        if count == 0 {
1496            break;
1497        }
1498        size = size
1499            .checked_add(count as u64)
1500            .ok_or(ClientError::PackageTooLarge {
1501                observed: u64::MAX,
1502                limit,
1503            })?;
1504        enforce_package_size(size, limit)?;
1505        hasher.update(&buffer[..count]);
1506    }
1507    Ok(format!(
1508        "sha256:{}",
1509        hex_digest(hasher.finalize().as_slice())
1510    ))
1511}
1512
1513async fn validate_package_file(
1514    path: PathBuf,
1515    size: u64,
1516) -> Result<PackageManifestInfo, ClientError> {
1517    tokio::task::spawn_blocking(move || validate_package_file_sync(&path, size))
1518        .await
1519        .map_err(|error| {
1520            ClientError::PackageIo(format!("package validation task failed: {error}"))
1521        })?
1522}
1523
1524fn validate_package_file_sync(path: &Path, size: u64) -> Result<PackageManifestInfo, ClientError> {
1525    let mut file = std::fs::File::open(path)
1526        .map_err(|error| ClientError::PackageIo(format!("open verified package: {error}")))?;
1527    let mut prefix = [0u8; vfs::package_format::AOSPKG_HEADER_LEN];
1528    file.read_exact(&mut prefix).map_err(|error| {
1529        ClientError::InvalidPackageFormat(format!("read .aospkg header: {error}"))
1530    })?;
1531    let size_usize = usize::try_from(size).map_err(|_| {
1532        ClientError::InvalidPackageFormat(String::from(
1533            ".aospkg size cannot be represented on this platform",
1534        ))
1535    })?;
1536    let header = vfs::package_format::parse_aospkg_header_from_prefix(&prefix, size_usize)
1537        .map_err(|error| ClientError::InvalidPackageFormat(error.to_string()))?;
1538    if header.manifest.len() > MAX_PACKAGE_MANIFEST_BYTES {
1539        return Err(ClientError::InvalidPackageFormat(format!(
1540            ".aospkg manifest is {} bytes; limit is {MAX_PACKAGE_MANIFEST_BYTES}",
1541            header.manifest.len()
1542        )));
1543    }
1544    if header.index.len() > MAX_PACKAGE_INDEX_BYTES {
1545        return Err(ClientError::InvalidPackageFormat(format!(
1546            ".aospkg mount index is {} bytes; limit is {MAX_PACKAGE_INDEX_BYTES}",
1547            header.index.len()
1548        )));
1549    }
1550    // Decode and validate the bounded mount index before immutable bytes
1551    // enter the shared cache. Projection reuses the same VFS parser and
1552    // mmap path, so cache hits cannot defer malformed-index failures until
1553    // a VM mutation.
1554    vfs::posix::TarFileSystem::open(path)
1555        .map_err(|error| ClientError::InvalidPackageFormat(error.to_string()))?;
1556    let manifest = vfs::package_format::read_manifest_chunk_from_file(path)
1557        .map_err(|error| ClientError::InvalidPackageFormat(error.to_string()))?;
1558    validate_manifest_component("name", &manifest.name, MAX_PACKAGE_NAME_BYTES)?;
1559    validate_manifest_component("version", &manifest.version, MAX_PACKAGE_VERSION_BYTES)?;
1560    if manifest.commands.len() > MAX_PACKAGE_COMMANDS {
1561        return Err(ClientError::InvalidPackageFormat(format!(
1562            "package manifest commands exceeds limit of {MAX_PACKAGE_COMMANDS}"
1563        )));
1564    }
1565    for command in &manifest.commands {
1566        // `[` is the POSIX name of `test`. It contains no path separator.
1567        if command.command != "[" {
1568            validate_manifest_component("command", &command.command, MAX_PACKAGE_COMMAND_BYTES)?;
1569        }
1570        validate_relative_manifest_path("command entry", &command.entry)?;
1571    }
1572    if let Some(provides) = &manifest.provides {
1573        if provides.env.len() > MAX_PACKAGE_PROVIDES_ENV {
1574            return Err(ClientError::InvalidPackageFormat(format!(
1575                "package provides.env exceeds limit of {MAX_PACKAGE_PROVIDES_ENV}"
1576            )));
1577        }
1578        if provides.files.len() > MAX_PACKAGE_PROVIDES_FILES {
1579            return Err(ClientError::InvalidPackageFormat(format!(
1580                "package provides.files exceeds limit of {MAX_PACKAGE_PROVIDES_FILES}"
1581            )));
1582        }
1583        for file in &provides.files {
1584            validate_relative_manifest_path("provided file source", &file.source)?;
1585            validate_absolute_manifest_path("provided file target", &file.target)?;
1586        }
1587    }
1588    Ok(PackageManifestInfo {
1589        name: manifest.name,
1590        version: manifest.version,
1591        commands: manifest
1592            .commands
1593            .into_iter()
1594            .map(|command| command.command)
1595            .collect(),
1596    })
1597}
1598
1599fn validate_manifest_component(name: &str, value: &str, limit: usize) -> Result<(), ClientError> {
1600    if value.is_empty() || value.len() > limit {
1601        return Err(ClientError::InvalidPackageFormat(format!(
1602            "package manifest {name} must contain 1..={limit} bytes"
1603        )));
1604    }
1605    if value == "."
1606        || value == ".."
1607        || !value
1608            .bytes()
1609            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@'))
1610    {
1611        return Err(ClientError::InvalidPackageFormat(format!(
1612            "package manifest {name} contains unsafe path characters"
1613        )));
1614    }
1615    Ok(())
1616}
1617
1618fn validate_relative_manifest_path(name: &str, value: &str) -> Result<(), ClientError> {
1619    if value.is_empty() || value.len() > MAX_PACKAGE_ENTRY_BYTES || value.starts_with('/') {
1620        return Err(ClientError::InvalidPackageFormat(format!(
1621            "package manifest {name} must be a non-empty relative path of at most {MAX_PACKAGE_ENTRY_BYTES} bytes"
1622        )));
1623    }
1624    if value
1625        .split('/')
1626        .any(|segment| segment.is_empty() || segment == "." || segment == "..")
1627    {
1628        return Err(ClientError::InvalidPackageFormat(format!(
1629            "package manifest {name} must be normalized and cannot traverse"
1630        )));
1631    }
1632    Ok(())
1633}
1634
1635fn validate_absolute_manifest_path(name: &str, value: &str) -> Result<(), ClientError> {
1636    if value.is_empty()
1637        || value.len() > MAX_PACKAGE_ENTRY_BYTES
1638        || !value.starts_with('/')
1639        || value == "/"
1640        || value.ends_with('/')
1641    {
1642        return Err(ClientError::InvalidPackageFormat(format!(
1643            "package manifest {name} must be a normalized non-root absolute path of at most {MAX_PACKAGE_ENTRY_BYTES} bytes"
1644        )));
1645    }
1646    if value
1647        .split('/')
1648        .skip(1)
1649        .any(|segment| segment.is_empty() || segment == "." || segment == "..")
1650    {
1651        return Err(ClientError::InvalidPackageFormat(format!(
1652            "package manifest {name} must be normalized and cannot traverse"
1653        )));
1654    }
1655    Ok(())
1656}
1657
1658fn parse_package_url(raw: &str) -> Result<Url, ClientError> {
1659    if raw.is_empty() || raw.len() > MAX_PACKAGE_URL_BYTES {
1660        return Err(ClientError::InvalidPackageSource(format!(
1661            "package URL must contain 1..={MAX_PACKAGE_URL_BYTES} bytes"
1662        )));
1663    }
1664    let url = Url::parse(raw)
1665        .map_err(|_| ClientError::InvalidPackageSource(String::from("package URL is invalid")))?;
1666    validate_package_url_shape(&url)?;
1667    Ok(url)
1668}
1669
1670fn validate_package_url_shape(url: &Url) -> Result<(), ClientError> {
1671    if !matches!(url.scheme(), "https" | "http") {
1672        return Err(ClientError::InvalidPackageSource(String::from(
1673            "package URL scheme must be https (or loopback http under local-test policy)",
1674        )));
1675    }
1676    if !url.username().is_empty() || url.password().is_some() {
1677        return Err(ClientError::InvalidPackageSource(String::from(
1678            "package URL must not contain userinfo",
1679        )));
1680    }
1681    if url.fragment().is_some() {
1682        return Err(ClientError::InvalidPackageSource(String::from(
1683            "package URL must not contain a fragment",
1684        )));
1685    }
1686    if url.host_str().is_none() {
1687        return Err(ClientError::InvalidPackageSource(String::from(
1688            "package URL must include a host",
1689        )));
1690    }
1691    Ok(())
1692}
1693
1694async fn resolve_addresses(
1695    host: &str,
1696    port: u16,
1697    timeout_ms: u64,
1698) -> Result<Vec<SocketAddr>, ClientError> {
1699    if let Ok(ip) = host.parse::<IpAddr>() {
1700        return Ok(vec![SocketAddr::new(ip, port)]);
1701    }
1702    let resolved = tokio::time::timeout(
1703        Duration::from_millis(timeout_ms),
1704        tokio::net::lookup_host((host, port)),
1705    )
1706    .await
1707    .map_err(|_| {
1708        package_timeout_error(
1709            "package DNS lookup",
1710            timeout_ms,
1711            "PackageResolverOptions.connect_timeout_ms",
1712            "session",
1713        )
1714    })?
1715    .map_err(|_| ClientError::PackageDownload(String::from("package DNS lookup failed")))?;
1716    let mut addresses = resolved.take(16).collect::<Vec<_>>();
1717    addresses.sort();
1718    addresses.dedup();
1719    if addresses.is_empty() {
1720        return Err(ClientError::PackageDownload(String::from(
1721            "package DNS lookup returned no addresses",
1722        )));
1723    }
1724    Ok(addresses)
1725}
1726
1727fn is_public_ip(ip: IpAddr) -> bool {
1728    match ip {
1729        IpAddr::V4(ip) => is_public_ipv4(ip),
1730        IpAddr::V6(ip) => is_public_ipv6(ip),
1731    }
1732}
1733
1734fn is_public_ipv4(ip: Ipv4Addr) -> bool {
1735    let [a, b, c, _] = ip.octets();
1736    !(a == 0
1737        || a == 10
1738        || a == 127
1739        || (a == 100 && (64..=127).contains(&b))
1740        || (a == 169 && b == 254)
1741        || (a == 172 && (16..=31).contains(&b))
1742        || (a == 192 && b == 0 && c == 0)
1743        || (a == 192 && b == 0 && c == 2)
1744        || (a == 192 && b == 168)
1745        || (a == 198 && (b == 18 || b == 19))
1746        || (a == 198 && b == 51 && c == 100)
1747        || (a == 203 && b == 0 && c == 113)
1748        || a >= 224)
1749}
1750
1751fn is_public_ipv6(ip: Ipv6Addr) -> bool {
1752    if let Some(v4) = ip.to_ipv4_mapped() {
1753        return is_public_ipv4(v4);
1754    }
1755    let segments = ip.segments();
1756    (segments[0] & 0xe000) == 0x2000 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1757}
1758
1759fn is_redirect(status: StatusCode) -> bool {
1760    matches!(
1761        status,
1762        StatusCode::MOVED_PERMANENTLY
1763            | StatusCode::FOUND
1764            | StatusCode::SEE_OTHER
1765            | StatusCode::TEMPORARY_REDIRECT
1766            | StatusCode::PERMANENT_REDIRECT
1767    )
1768}
1769
1770fn enforce_package_size(size: u64, limit: u64) -> Result<(), ClientError> {
1771    if size > limit {
1772        return Err(ClientError::PackageTooLarge {
1773            observed: size,
1774            limit,
1775        });
1776    }
1777    Ok(())
1778}
1779
1780fn enforce_response_header_limits(headers: &reqwest::header::HeaderMap) -> Result<(), ClientError> {
1781    if headers.len() > MAX_PACKAGE_RESPONSE_HEADERS {
1782        return Err(ClientError::PackageDownload(format!(
1783            "package response has {} headers; limit is {MAX_PACKAGE_RESPONSE_HEADERS}",
1784            headers.len()
1785        )));
1786    }
1787    let bytes = headers.iter().try_fold(0usize, |total, (name, value)| {
1788        total
1789            .checked_add(name.as_str().len())
1790            .and_then(|total| total.checked_add(value.as_bytes().len()))
1791    });
1792    if bytes.is_none_or(|bytes| bytes > MAX_PACKAGE_RESPONSE_HEADER_BYTES) {
1793        return Err(ClientError::PackageDownload(format!(
1794            "package response headers exceed {MAX_PACKAGE_RESPONSE_HEADER_BYTES} bytes"
1795        )));
1796    }
1797    Ok(())
1798}
1799
1800fn verify_expected_digest(actual: &str, expected: Option<&str>) -> Result<(), ClientError> {
1801    let Some(expected) = normalize_expected_digest(expected)? else {
1802        return Ok(());
1803    };
1804    if expected != actual {
1805        return Err(ClientError::PackageDigestMismatch {
1806            expected,
1807            actual: actual.to_owned(),
1808        });
1809    }
1810    Ok(())
1811}
1812
1813fn normalize_expected_digest(value: Option<&str>) -> Result<Option<String>, ClientError> {
1814    let Some(value) = value else {
1815        return Ok(None);
1816    };
1817    let Some(hex) = value.strip_prefix("sha256:") else {
1818        return Err(ClientError::InvalidPackageSource(String::from(
1819            "package digest must use the sha256:<64 lowercase hex> form",
1820        )));
1821    };
1822    if hex.len() != 64
1823        || !hex.bytes().all(|byte| byte.is_ascii_hexdigit())
1824        || hex.bytes().any(|byte| byte.is_ascii_uppercase())
1825    {
1826        return Err(ClientError::InvalidPackageSource(String::from(
1827            "package digest must use the sha256:<64 lowercase hex> form",
1828        )));
1829    }
1830    Ok(Some(value.to_owned()))
1831}
1832
1833fn hex_digest(bytes: &[u8]) -> String {
1834    const HEX: &[u8; 16] = b"0123456789abcdef";
1835    let mut output = String::with_capacity(bytes.len() * 2);
1836    for byte in bytes {
1837        output.push(HEX[(byte >> 4) as usize] as char);
1838        output.push(HEX[(byte & 0x0f) as usize] as char);
1839    }
1840    output
1841}
1842
1843fn redacted_url(url: &Url) -> String {
1844    let mut redacted = url.clone();
1845    redacted.set_query(url.query().map(|_| "<redacted>"));
1846    redacted.set_fragment(None);
1847    redacted.to_string()
1848}
1849
1850fn package_timeout_error(label: &str, timeout_ms: u64, path: &str, scope: &str) -> ClientError {
1851    ClientError::OperationTimedOut {
1852        message: format!(
1853            "{label} exceeded {timeout_ms}ms; raise {path}; completion is unconfirmed"
1854        ),
1855        details: Box::new(crate::error::ResourceLimitDetails {
1856            limit_name: Some("packageAcquisitionTimeMs".into()),
1857            configured_limit: Some(timeout_ms),
1858            unit: Some("milliseconds".into()),
1859            scope: Some(scope.into()),
1860            operation: Some("package.acquire".into()),
1861            configuration_path: Some(path.into()),
1862            retryable: Some(false),
1863            errno: Some("ETIMEDOUT".into()),
1864            ..Default::default()
1865        }),
1866    }
1867}
1868
1869fn package_request_error(
1870    label: &str,
1871    error: &reqwest::Error,
1872    options: &PackageResolverOptions,
1873) -> ClientError {
1874    if error.is_timeout() {
1875        let (timeout_ms, path) =
1876            if error.is_connect() && options.connect_timeout_ms < options.download_timeout_ms {
1877                (
1878                    options.connect_timeout_ms,
1879                    "PackageResolverOptions.connect_timeout_ms",
1880                )
1881            } else {
1882                (
1883                    options.download_timeout_ms,
1884                    "PackageResolverOptions.download_timeout_ms",
1885                )
1886            };
1887        return package_timeout_error(
1888            &format!("package request to {label}"),
1889            timeout_ms,
1890            path,
1891            "session",
1892        );
1893    }
1894    let reason = if error.is_connect() {
1895        "failed to connect"
1896    } else if error.is_request() {
1897        "request failed"
1898    } else {
1899        "transport failed"
1900    };
1901    ClientError::PackageDownload(format!("package request to {label} {reason}"))
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906    use super::*;
1907    use std::sync::atomic::{AtomicUsize, Ordering};
1908
1909    fn test_package() -> Vec<u8> {
1910        test_package_with_version("1.0.0")
1911    }
1912
1913    fn test_package_with_version(version: &str) -> Vec<u8> {
1914        test_package_with(version, "demo")
1915    }
1916
1917    fn test_package_with(version: &str, command_name: &str) -> Vec<u8> {
1918        let mut builder = tar::Builder::new(Vec::<u8>::new());
1919        let manifest = format!(r#"{{"name":"demo","version":"{version}"}}"#);
1920        let mut header = tar::Header::new_gnu();
1921        header.set_size(manifest.len() as u64);
1922        header.set_mode(0o644);
1923        header.set_cksum();
1924        builder
1925            .append_data(&mut header, "agentos-package.json", manifest.as_bytes())
1926            .unwrap();
1927        let command = b"#!/bin/sh\necho demo\n";
1928        let mut header = tar::Header::new_gnu();
1929        header.set_size(command.len() as u64);
1930        header.set_mode(0o755);
1931        header.set_cksum();
1932        builder
1933            .append_data(&mut header, format!("bin/{command_name}"), &command[..])
1934            .unwrap();
1935        let tar = builder.into_inner().unwrap();
1936        vfs::package_format::pack::pack_aospkg_from_tar_bytes(&tar)
1937            .unwrap()
1938            .0
1939    }
1940
1941    async fn uncached_package(path: PathBuf) -> Result<VerifiedPackage, ClientError> {
1942        let size = tokio::fs::metadata(&path)
1943            .await
1944            .map_err(|error| ClientError::PackageIo(error.to_string()))?
1945            .len();
1946        let digest = digest_file(&path, DEFAULT_MAX_PACKAGE_BYTES).await?;
1947        let manifest = validate_package_file(path.clone(), size).await?;
1948        Ok(verified_package(
1949            digest,
1950            size,
1951            manifest,
1952            PackageBacking::Borrowed(path),
1953        ))
1954    }
1955
1956    fn test_cache_options() -> ProcessPackageCacheOptions {
1957        ProcessPackageCacheOptions {
1958            root: None,
1959            min_free_bytes: 0,
1960            max_bytes: 16 * 1024 * 1024,
1961            max_entries: 8,
1962            max_concurrent_acquisitions: 2,
1963            max_pending_acquisitions: 128,
1964            acquisition_timeout_ms: 30_000,
1965            max_source_entries: 32,
1966            source_ttl_ms: 5_000,
1967        }
1968    }
1969
1970    #[tokio::test]
1971    async fn persistent_cache_recovers_verified_entries_and_cleans_crash_files() {
1972        let root = tempfile::tempdir().unwrap();
1973        let source = tempfile::NamedTempFile::new().unwrap();
1974        std::fs::write(source.path(), test_package()).unwrap();
1975        let mut options = test_cache_options();
1976        options.root = Some(root.path().to_path_buf());
1977        let cache = Arc::new(ProcessPackageCache::new(options.clone()).unwrap());
1978        let installed = cache
1979            .get_or_acquire(
1980                String::from("source"),
1981                None,
1982                uncached_package(source.path().to_path_buf()),
1983            )
1984            .await
1985            .unwrap();
1986        let digest = installed.digest.clone();
1987        drop(installed);
1988        drop(cache);
1989
1990        let stale_staging = root.path().join(".package-staging-abandoned");
1991        std::fs::write(&stale_staging, b"partial").unwrap();
1992        let invalid = root.path().join(format!("{}.aospkg", "0".repeat(64)));
1993        std::fs::write(&invalid, b"invalid").unwrap();
1994
1995        let recovered = Arc::new(ProcessPackageCache::new(options).unwrap());
1996        assert_eq!(recovered.stats().await.entries, 1);
1997        assert_eq!(recovered.get(&digest).await.unwrap().digest, digest);
1998        assert!(!stale_staging.exists());
1999        assert!(!invalid.exists());
2000    }
2001
2002    #[test]
2003    fn package_cache_reserves_operator_configured_free_disk_space() {
2004        let root = tempfile::tempdir().unwrap();
2005        let error = ensure_cache_disk_reserve(root.path(), 1, u64::MAX)
2006            .expect_err("a reserve greater than available disk must reject an insert");
2007        assert!(matches!(error, ClientError::PackageCacheConfiguration(_)));
2008    }
2009
2010    #[test]
2011    fn persistent_cache_rejects_concurrent_owners_before_recovery() {
2012        let root = tempfile::tempdir().unwrap();
2013        let mut options = test_cache_options();
2014        options.root = Some(root.path().to_path_buf());
2015        let first = ProcessPackageCache::new(options.clone()).unwrap();
2016        let staging = root.path().join(".package-staging-live");
2017        std::fs::write(&staging, b"in progress").unwrap();
2018        let error = ProcessPackageCache::new(options.clone())
2019            .err()
2020            .expect("the process owning live staging files must retain exclusive ownership");
2021        assert!(error.to_string().contains("package-cache-dir"));
2022        assert!(staging.exists());
2023        drop(first);
2024        let recovered = ProcessPackageCache::new(options).unwrap();
2025        assert!(!staging.exists());
2026        drop(recovered);
2027    }
2028
2029    #[test]
2030    fn digest_form_is_strict() {
2031        let valid = format!("sha256:{}", "a".repeat(64));
2032        assert_eq!(
2033            normalize_expected_digest(Some(&valid)).unwrap(),
2034            Some(valid)
2035        );
2036        for invalid in ["abc", "sha256:ABC", "sha256:00"] {
2037            assert!(normalize_expected_digest(Some(invalid)).is_err());
2038        }
2039    }
2040
2041    #[test]
2042    fn url_shape_rejects_ambient_credentials_and_non_http_schemes() {
2043        for invalid in [
2044            "file:///tmp/package.aospkg",
2045            "https://user:secret@example.com/package.aospkg",
2046            "https://example.com/package.aospkg#fragment",
2047        ] {
2048            assert!(parse_package_url(invalid).is_err(), "accepted {invalid}");
2049        }
2050    }
2051
2052    #[test]
2053    fn special_addresses_are_not_public() {
2054        for private in ["127.0.0.1", "10.1.2.3", "169.254.169.254", "::1", "fc00::1"] {
2055            assert!(
2056                !is_public_ip(private.parse().unwrap()),
2057                "accepted {private}"
2058            );
2059        }
2060        for public in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
2061            assert!(is_public_ip(public.parse().unwrap()), "rejected {public}");
2062        }
2063    }
2064
2065    #[test]
2066    fn url_redaction_removes_query_values() {
2067        let url = Url::parse("https://example.com/a?token=secret&x=1").unwrap();
2068        let redacted = redacted_url(&url);
2069        assert!(!redacted.contains("secret"));
2070        assert!(redacted.contains("%3Credacted%3E"));
2071    }
2072
2073    #[tokio::test]
2074    async fn path_and_loopback_url_resolve_to_the_same_identity() {
2075        let bytes = test_package();
2076        let package = tempfile::NamedTempFile::new().unwrap();
2077        std::fs::write(package.path(), &bytes).unwrap();
2078        let path_resolver = PackageResolver::new(PackageResolverOptions::default()).unwrap();
2079        let from_path = path_resolver
2080            .resolve(PackageSource::Path {
2081                path: package.path().to_string_lossy().into_owned(),
2082                expected_digest: None,
2083            })
2084            .await
2085            .unwrap();
2086
2087        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2088        let address = listener.local_addr().unwrap();
2089        let server_bytes = bytes.clone();
2090        let server = tokio::spawn(async move {
2091            let (mut stream, _) = listener.accept().await.unwrap();
2092            let mut request = [0u8; 2048];
2093            let _ = stream.read(&mut request).await.unwrap();
2094            let headers = format!(
2095                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2096                server_bytes.len()
2097            );
2098            stream.write_all(headers.as_bytes()).await.unwrap();
2099            stream.write_all(&server_bytes).await.unwrap();
2100        });
2101        let url_resolver = PackageResolver::new(PackageResolverOptions {
2102            allow_insecure_local_http: true,
2103            ..PackageResolverOptions::default()
2104        })
2105        .unwrap();
2106        let from_url = url_resolver
2107            .resolve(PackageSource::Url {
2108                url: format!("http://{address}/demo.aospkg"),
2109                expected_digest: None,
2110            })
2111            .await
2112            .unwrap();
2113        server.await.unwrap();
2114
2115        assert_eq!(from_url.package_id, from_path.package_id);
2116        assert_eq!(from_url.size, from_path.size);
2117        assert_eq!(from_url.manifest, from_path.manifest);
2118        assert_eq!(from_url.manifest.commands, vec!["demo"]);
2119
2120        // Both source aliases and digest-addressed hits must still enforce the
2121        // requesting resolver's policy, even though no download is needed.
2122        for expected_digest in [None, Some(from_url.digest.clone())] {
2123            let error = path_resolver
2124                .resolve(PackageSource::Url {
2125                    url: format!("http://{address}/demo.aospkg"),
2126                    expected_digest,
2127                })
2128                .await
2129                .unwrap_err();
2130            assert!(matches!(error, ClientError::InvalidPackageSource(_)));
2131        }
2132        let error = path_resolver
2133            .resolve(PackageSource::Url {
2134                url: format!("https://{address}/demo.aospkg"),
2135                expected_digest: Some(from_url.digest.clone()),
2136            })
2137            .await
2138            .unwrap_err();
2139        assert!(matches!(error, ClientError::InvalidPackageSource(_)));
2140    }
2141
2142    #[tokio::test]
2143    async fn package_http_timeouts_preserve_limit_details_before_and_after_headers() {
2144        // Leave time for the local HTTP handshake under the parallel unit-test
2145        // runner; 25ms could expire before any request reached the fixture.
2146        const DOWNLOAD_TIMEOUT_MS: u64 = 500;
2147        for send_headers in [false, true] {
2148            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2149            let address = listener.local_addr().unwrap();
2150            let (stop, stopped) = tokio::sync::oneshot::channel();
2151            let server = async {
2152                tokio::select! {
2153                    _ = stopped => {},
2154                    _ = async {
2155                        let (mut stream, _) = listener.accept().await.unwrap();
2156                        let mut request = [0u8; 2048];
2157                        assert!(stream.read(&mut request).await.unwrap() > 0);
2158                        if send_headers {
2159                            stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\n\r\n").await.unwrap();
2160                        }
2161                        std::future::pending::<()>().await;
2162                    } => unreachable!(),
2163                }
2164            };
2165            let acquisition = async {
2166                let resolver = PackageResolver::new(PackageResolverOptions {
2167                    allow_insecure_local_http: true,
2168                    download_timeout_ms: DOWNLOAD_TIMEOUT_MS,
2169                    ..Default::default()
2170                })
2171                .unwrap();
2172                let result = resolver
2173                    .resolve_url(&format!("http://{address}/demo.aospkg"), None)
2174                    .await;
2175                stop.send(()).expect("server stop receiver remains alive");
2176                result
2177            };
2178            let (result, ()) = tokio::join!(acquisition, server);
2179            assert!(
2180                matches!(result, Err(ClientError::OperationTimedOut { details, .. })
2181                if details.configured_limit == Some(DOWNLOAD_TIMEOUT_MS)
2182                    && details.configuration_path.as_deref() == Some("PackageResolverOptions.download_timeout_ms")
2183                    && details.errno.as_deref() == Some("ETIMEDOUT"))
2184            );
2185        }
2186    }
2187
2188    #[tokio::test]
2189    async fn bracket_command_resolves_but_other_bracket_names_are_rejected() {
2190        let resolver = PackageResolver::new(PackageResolverOptions::default()).unwrap();
2191        for (command, accepted) in [("[", true), ("[x", false), ("]", false)] {
2192            let package = tempfile::NamedTempFile::new().unwrap();
2193            std::fs::write(package.path(), test_package_with("1.0.0", command)).unwrap();
2194            let result = resolver
2195                .resolve(PackageSource::Path {
2196                    path: package.path().to_string_lossy().into_owned(),
2197                    expected_digest: None,
2198                })
2199                .await;
2200            if accepted {
2201                let package = result.expect("`[` is a valid command name");
2202                assert_eq!(package.manifest.commands, vec![command.to_owned()]);
2203            } else {
2204                assert!(
2205                    matches!(result, Err(ClientError::InvalidPackageFormat(_))),
2206                    "{command:?} must be rejected, got {result:?}"
2207                );
2208            }
2209        }
2210    }
2211
2212    #[tokio::test]
2213    async fn digest_mismatch_is_typed() {
2214        let package = tempfile::NamedTempFile::new().unwrap();
2215        std::fs::write(package.path(), test_package()).unwrap();
2216        let resolver = PackageResolver::new(PackageResolverOptions::default()).unwrap();
2217        let error = resolver
2218            .resolve(PackageSource::Path {
2219                path: package.path().to_string_lossy().into_owned(),
2220                expected_digest: Some(format!("sha256:{}", "0".repeat(64))),
2221            })
2222            .await
2223            .expect_err("digest mismatch must fail");
2224        assert!(matches!(error, ClientError::PackageDigestMismatch { .. }));
2225    }
2226
2227    #[tokio::test]
2228    async fn concurrent_same_source_uses_one_acquisition() {
2229        const CALLERS: usize = 100;
2230        let package = tempfile::NamedTempFile::new().unwrap();
2231        std::fs::write(package.path(), test_package()).unwrap();
2232        let package_path = package.path().to_path_buf();
2233        let cache = Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap());
2234        let start = Arc::new(tokio::sync::Barrier::new(CALLERS + 1));
2235        let acquisition_gate = Arc::new(Semaphore::new(0));
2236        let acquisitions = Arc::new(AtomicUsize::new(0));
2237        let mut tasks = Vec::with_capacity(CALLERS);
2238        for _ in 0..CALLERS {
2239            let cache = Arc::clone(&cache);
2240            let start = Arc::clone(&start);
2241            let gate = Arc::clone(&acquisition_gate);
2242            let acquisitions = Arc::clone(&acquisitions);
2243            let path = package_path.clone();
2244            tasks.push(tokio::spawn(async move {
2245                start.wait().await;
2246                cache
2247                    .get_or_acquire(
2248                        String::from("url:https://example.test/demo"),
2249                        None,
2250                        async move {
2251                            acquisitions.fetch_add(1, Ordering::SeqCst);
2252                            gate.acquire_owned()
2253                                .await
2254                                .expect("test acquisition gate")
2255                                .forget();
2256                            uncached_package(path).await
2257                        },
2258                    )
2259                    .await
2260            }));
2261        }
2262        start.wait().await;
2263        tokio::time::timeout(Duration::from_secs(2), async {
2264            loop {
2265                if cache.stats().await.coalesced_waiters == (CALLERS - 1) as u64 {
2266                    break;
2267                }
2268                tokio::task::yield_now().await;
2269            }
2270        })
2271        .await
2272        .expect("all callers join one flight");
2273        acquisition_gate.add_permits(1);
2274
2275        let mut resolved = Vec::with_capacity(CALLERS);
2276        for task in tasks {
2277            resolved.push(task.await.unwrap().unwrap());
2278        }
2279        assert_eq!(acquisitions.load(Ordering::SeqCst), 1);
2280        assert!(resolved
2281            .iter()
2282            .all(|package| package.digest == resolved[0].digest));
2283        let stats = cache.stats().await;
2284        assert_eq!(stats.acquisitions, 1);
2285        assert_eq!(stats.coalesced_waiters, (CALLERS - 1) as u64);
2286        assert_eq!(stats.entries, 1);
2287        assert_eq!(stats.pinned_entries, 1);
2288    }
2289
2290    #[tokio::test]
2291    async fn recent_source_resolution_reuses_the_digest_entry() {
2292        let package = tempfile::NamedTempFile::new().unwrap();
2293        std::fs::write(package.path(), test_package()).unwrap();
2294        let cache = Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap());
2295        let acquisitions = Arc::new(AtomicUsize::new(0));
2296        let source_key = String::from("url:https://example.test/demo");
2297
2298        let first_count = Arc::clone(&acquisitions);
2299        let first_path = package.path().to_path_buf();
2300        let first = cache
2301            .get_or_acquire(source_key.clone(), None, async move {
2302                first_count.fetch_add(1, Ordering::SeqCst);
2303                uncached_package(first_path).await
2304            })
2305            .await
2306            .unwrap();
2307        let second_count = Arc::clone(&acquisitions);
2308        let second_path = package.path().to_path_buf();
2309        let second = cache
2310            .get_or_acquire(source_key, None, async move {
2311                second_count.fetch_add(1, Ordering::SeqCst);
2312                uncached_package(second_path).await
2313            })
2314            .await
2315            .unwrap();
2316
2317        assert_eq!(first.digest, second.digest);
2318        assert_eq!(acquisitions.load(Ordering::SeqCst), 1);
2319        let stats = cache.stats().await;
2320        assert_eq!(stats.source_entries, 1);
2321        assert_eq!(stats.hits, 1);
2322    }
2323
2324    #[tokio::test]
2325    async fn local_path_resolution_does_not_reuse_a_source_alias() {
2326        let bytes = test_package();
2327        let package = tempfile::NamedTempFile::new().unwrap();
2328        std::fs::write(package.path(), &bytes).unwrap();
2329        let path = package.path().to_string_lossy().into_owned();
2330        let expected_digest = format!("sha256:{}", hex_digest(Sha256::digest(&bytes).as_slice()));
2331        let resolver = PackageResolver {
2332            options: PackageResolverOptions::default(),
2333            cache: Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap()),
2334        };
2335
2336        resolver
2337            .resolve(PackageSource::Path {
2338                path: path.clone(),
2339                expected_digest: Some(expected_digest.clone()),
2340            })
2341            .await
2342            .unwrap();
2343        std::fs::remove_file(&path).unwrap();
2344        let error = resolver
2345            .resolve(PackageSource::Path {
2346                path,
2347                expected_digest: None,
2348            })
2349            .await
2350            .expect_err("a deleted local path must not resolve from a stale alias");
2351        assert!(matches!(error, ClientError::PackageIo(_)));
2352    }
2353
2354    #[tokio::test]
2355    async fn local_path_resolution_observes_replaced_bytes() {
2356        let package = tempfile::NamedTempFile::new().unwrap();
2357        std::fs::write(package.path(), test_package_with_version("1.0.0")).unwrap();
2358        let path = package.path().to_string_lossy().into_owned();
2359        let resolver = PackageResolver {
2360            options: PackageResolverOptions::default(),
2361            cache: Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap()),
2362        };
2363
2364        let first = resolver
2365            .resolve(PackageSource::Path {
2366                path: path.clone(),
2367                expected_digest: None,
2368            })
2369            .await
2370            .unwrap();
2371        std::fs::write(package.path(), test_package_with_version("2.0.0")).unwrap();
2372        let second = resolver
2373            .resolve(PackageSource::Path {
2374                path,
2375                expected_digest: None,
2376            })
2377            .await
2378            .unwrap();
2379
2380        assert_ne!(first.digest, second.digest);
2381        assert_eq!(second.manifest.version, "2.0.0");
2382    }
2383
2384    #[tokio::test]
2385    async fn failed_flight_is_removed_before_retry() {
2386        let package = tempfile::NamedTempFile::new().unwrap();
2387        std::fs::write(package.path(), test_package()).unwrap();
2388        let cache = Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap());
2389
2390        let error = cache
2391            .get_or_acquire(String::from("failure"), None, async {
2392                Err(ClientError::PackageDownload(String::from(
2393                    "injected acquisition failure",
2394                )))
2395            })
2396            .await
2397            .expect_err("first acquisition must fail");
2398        assert!(matches!(error, ClientError::PackageDownload(_)));
2399
2400        let resolved = cache
2401            .get_or_acquire(
2402                String::from("failure"),
2403                None,
2404                uncached_package(package.path().to_path_buf()),
2405            )
2406            .await
2407            .expect("retry starts a fresh acquisition");
2408        assert_eq!(resolved.manifest.name, "demo");
2409        let stats = cache.stats().await;
2410        assert_eq!(stats.acquisitions, 2);
2411        assert_eq!(stats.pending_acquisitions, 0);
2412    }
2413
2414    #[tokio::test]
2415    async fn timed_out_flight_is_removed_before_retry() {
2416        let mut options = test_cache_options();
2417        options.acquisition_timeout_ms = 25;
2418        let cache = Arc::new(ProcessPackageCache::new(options).unwrap());
2419
2420        let error = cache
2421            .get_or_acquire(String::from("timeout"), None, async {
2422                std::future::pending::<Result<VerifiedPackage, ClientError>>().await
2423            })
2424            .await
2425            .expect_err("acquisition must time out");
2426        assert!(error.to_string().contains("acquisition_timeout_ms"));
2427        assert!(
2428            matches!(&error, ClientError::OperationTimedOut { details, .. }
2429            if details.configured_limit == Some(25)
2430                && details.configuration_path.as_deref() == Some("ProcessPackageCacheOptions.acquisition_timeout_ms")
2431                && details.errno.as_deref() == Some("ETIMEDOUT"))
2432        );
2433
2434        // Prove that the replacement closure runs, without requiring a package
2435        // fsync to finish inside the deliberately tiny timeout under disk load.
2436        // Successful package publication is covered by the cache tests above.
2437        let error = cache
2438            .get_or_acquire(String::from("timeout"), None, async {
2439                Err(ClientError::PackageIo(String::from("retry started")))
2440            })
2441            .await
2442            .expect_err("replacement acquisition returns its own result");
2443        assert!(matches!(error, ClientError::PackageIo(message) if message == "retry started"));
2444        let stats = cache.stats().await;
2445        assert_eq!(stats.acquisitions, 2);
2446        assert_eq!(stats.pending_acquisitions, 0);
2447    }
2448
2449    #[tokio::test]
2450    async fn optional_flight_is_cancelled_after_last_waiter_leaves() {
2451        struct DropProbe(Arc<AtomicUsize>);
2452        impl Drop for DropProbe {
2453            fn drop(&mut self) {
2454                self.0.fetch_add(1, Ordering::SeqCst);
2455            }
2456        }
2457
2458        let cache = Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap());
2459        let dropped = Arc::new(AtomicUsize::new(0));
2460        let task_cache = Arc::clone(&cache);
2461        let task_dropped = Arc::clone(&dropped);
2462        let waiter = tokio::spawn(async move {
2463            task_cache
2464                .get_or_acquire_optional(String::from("optional"), None, async move {
2465                    let _probe = DropProbe(task_dropped);
2466                    std::future::pending::<Result<VerifiedPackage, ClientError>>().await
2467                })
2468                .await
2469        });
2470        tokio::time::timeout(Duration::from_secs(2), async {
2471            while cache.stats().await.acquisitions != 1 {
2472                tokio::task::yield_now().await;
2473            }
2474        })
2475        .await
2476        .expect("optional acquisition starts");
2477
2478        waiter.abort();
2479        let _ = waiter.await;
2480        tokio::time::timeout(Duration::from_secs(2), async {
2481            loop {
2482                let stats = cache.stats().await;
2483                if stats.pending_acquisitions == 0 && stats.cancelled_acquisitions == 1 {
2484                    break;
2485                }
2486                tokio::task::yield_now().await;
2487            }
2488        })
2489        .await
2490        .expect("orphaned optional acquisition is cancelled");
2491        assert_eq!(dropped.load(Ordering::SeqCst), 1);
2492    }
2493
2494    #[tokio::test]
2495    async fn required_waiter_keeps_optional_flight_alive() {
2496        let package = tempfile::NamedTempFile::new().unwrap();
2497        std::fs::write(package.path(), test_package()).unwrap();
2498        let cache = Arc::new(ProcessPackageCache::new(test_cache_options()).unwrap());
2499        let gate = Arc::new(Semaphore::new(0));
2500
2501        let optional_cache = Arc::clone(&cache);
2502        let optional_gate = Arc::clone(&gate);
2503        let path = package.path().to_path_buf();
2504        let optional = tokio::spawn(async move {
2505            optional_cache
2506                .get_or_acquire_optional(String::from("shared"), None, async move {
2507                    optional_gate
2508                        .acquire_owned()
2509                        .await
2510                        .expect("optional gate")
2511                        .forget();
2512                    uncached_package(path).await
2513                })
2514                .await
2515        });
2516        tokio::time::timeout(Duration::from_secs(2), async {
2517            while cache.stats().await.acquisitions != 1 {
2518                tokio::task::yield_now().await;
2519            }
2520        })
2521        .await
2522        .expect("optional acquisition starts");
2523
2524        let required_cache = Arc::clone(&cache);
2525        let required = tokio::spawn(async move {
2526            required_cache
2527                .get_or_acquire(String::from("shared"), None, async {
2528                    panic!("required waiter must coalesce with the optional flight")
2529                })
2530                .await
2531        });
2532        tokio::time::timeout(Duration::from_secs(2), async {
2533            while cache.stats().await.coalesced_waiters != 1 {
2534                tokio::task::yield_now().await;
2535            }
2536        })
2537        .await
2538        .expect("required waiter joins the flight");
2539
2540        optional.abort();
2541        let _ = optional.await;
2542        gate.add_permits(1);
2543        required.await.unwrap().unwrap();
2544        assert_eq!(cache.stats().await.cancelled_acquisitions, 0);
2545    }
2546
2547    #[tokio::test]
2548    async fn pinned_entries_block_eviction_then_lru_evicts_after_release() {
2549        let first = tempfile::NamedTempFile::new().unwrap();
2550        let second = tempfile::NamedTempFile::new().unwrap();
2551        std::fs::write(first.path(), test_package_with_version("1.0.0")).unwrap();
2552        std::fs::write(second.path(), test_package_with_version("2.0.0")).unwrap();
2553        let mut options = test_cache_options();
2554        options.max_entries = 1;
2555        let cache = Arc::new(ProcessPackageCache::new(options).unwrap());
2556
2557        let installed_first = cache
2558            .get_or_acquire(
2559                String::from("first"),
2560                None,
2561                uncached_package(first.path().to_path_buf()),
2562            )
2563            .await
2564            .unwrap();
2565        let first_digest = installed_first.digest.clone();
2566        let error = cache
2567            .get_or_acquire(
2568                String::from("second"),
2569                None,
2570                uncached_package(second.path().to_path_buf()),
2571            )
2572            .await
2573            .expect_err("a live package pin must block eviction");
2574        assert!(matches!(
2575            error,
2576            ClientError::PackageCacheEntryCapacity { .. }
2577        ));
2578
2579        drop(installed_first);
2580        let installed_second = cache
2581            .get_or_acquire(
2582                String::from("second-retry"),
2583                None,
2584                uncached_package(second.path().to_path_buf()),
2585            )
2586            .await
2587            .unwrap();
2588        assert_ne!(installed_second.digest, first_digest);
2589        assert!(cache.get(&first_digest).await.is_none());
2590        let stats = cache.stats().await;
2591        assert_eq!(stats.entries, 1);
2592        assert_eq!(stats.evictions, 1);
2593        assert_eq!(stats.capacity_failures, 1);
2594    }
2595
2596    #[tokio::test]
2597    async fn distinct_pending_source_keys_are_bounded() {
2598        let package = tempfile::NamedTempFile::new().unwrap();
2599        std::fs::write(package.path(), test_package()).unwrap();
2600        let mut options = test_cache_options();
2601        options.max_concurrent_acquisitions = 1;
2602        options.max_pending_acquisitions = 1;
2603        let cache = Arc::new(ProcessPackageCache::new(options).unwrap());
2604        let gate = Arc::new(Semaphore::new(0));
2605        let leader_cache = Arc::clone(&cache);
2606        let leader_gate = Arc::clone(&gate);
2607        let path = package.path().to_path_buf();
2608        let leader = tokio::spawn(async move {
2609            leader_cache
2610                .get_or_acquire(String::from("first"), None, async move {
2611                    leader_gate
2612                        .acquire_owned()
2613                        .await
2614                        .expect("test pending gate")
2615                        .forget();
2616                    uncached_package(path).await
2617                })
2618                .await
2619        });
2620        tokio::time::timeout(Duration::from_secs(2), async {
2621            while cache.stats().await.pending_acquisitions != 1 {
2622                tokio::task::yield_now().await;
2623            }
2624        })
2625        .await
2626        .expect("leader registers its flight");
2627
2628        let error = cache
2629            .get_or_acquire(
2630                String::from("second"),
2631                None,
2632                uncached_package(package.path().to_path_buf()),
2633            )
2634            .await
2635            .expect_err("distinct pending source must hit the bound");
2636        assert!(matches!(
2637            error,
2638            ClientError::PackageCachePendingLimit { limit: 1 }
2639        ));
2640        gate.add_permits(1);
2641        leader.await.unwrap().unwrap();
2642    }
2643}