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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProcessPackageCacheOptions {
53 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#[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
125pub 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#[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 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 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 _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
1095pub 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 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 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 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 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 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 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 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 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 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 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}