use std::collections::{HashMap, HashSet};
use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_compression::tokio::bufread::{BzDecoder, ZstdDecoder};
use async_http_range_reader::{
AsyncHttpRangeReader, AsyncHttpRangeReaderError, CheckSupportMethod,
};
use async_zip::Compression;
use async_zip::base::read::seek::ZipFileReader;
use futures_util::TryStreamExt;
use http::HeaderMap;
use http::header::{ETAG, IF_RANGE, LAST_MODIFIED, RANGE};
use rattler_conda_types::package::{CondaArchiveType, PackageFile};
use reqwest_middleware::ClientWithMiddleware;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio_util::compat::TokioAsyncReadCompatExt;
use tokio_util::io::StreamReader;
use tracing::debug;
use url::Url;
use crate::ExtractError;
const TAIL_SIZE: u64 = 64 * 1024;
const STREAM_BUF_SIZE: usize = 128 * 1024;
const LOCAL_HEADER_MAGIC: [u8; 4] = [0x50, 0x4b, 0x03, 0x04];
const MAX_PREALLOC: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Section {
Info,
Content,
}
impl Section {
pub(crate) fn containing(path: &Path) -> Section {
let first = path
.components()
.find(|c| !matches!(c, std::path::Component::CurDir));
match first {
Some(std::path::Component::Normal(first)) if first == "info" => Section::Info,
_ => Section::Content,
}
}
pub(crate) fn zip_prefix(self) -> &'static str {
match self {
Section::Info => "info-",
Section::Content => "pkg-",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SparsePolicy {
#[default]
Prefer,
Require,
Disable,
}
#[derive(Debug, Clone, Default)]
pub struct RemoteArchiveOptions {
sparse_policy: SparsePolicy,
max_spool_size: Option<u64>,
}
impl RemoteArchiveOptions {
pub const fn new() -> Self {
Self {
sparse_policy: SparsePolicy::Prefer,
max_spool_size: None,
}
}
pub const fn with_sparse_policy(mut self, policy: SparsePolicy) -> Self {
self.sparse_policy = policy;
self
}
pub const fn with_max_spool_size(mut self, max_size: u64) -> Self {
self.max_spool_size = Some(max_size);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ArchiveAccess {
Sparse,
Local,
Spooled,
}
#[derive(Debug, Clone)]
struct MemberSpan {
name: String,
header_offset: u64,
size: u64,
end: u64,
}
enum Backend {
Conda {
source: CondaSource,
members: Vec<MemberSpan>,
},
TarBz2 {
path: PathBuf,
temp: Option<tempfile::TempPath>,
},
}
enum CondaSource {
Sparse {
client: ClientWithMiddleware,
url: Url,
validator: Option<http::HeaderValue>,
tail_offset: u64,
tail: bytes::Bytes,
},
Local {
path: PathBuf,
temp: Option<tempfile::TempPath>,
},
}
#[derive(Clone)]
pub struct PackageArchive {
backend: Arc<Backend>,
}
type DynReader = Box<dyn AsyncRead + Send + Unpin>;
type RawSectionEntry = tokio_tar::Entry<tokio_tar::Archive<DynReader>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ArchiveEntryKind {
File,
Directory,
Symlink,
Hardlink,
Other,
}
impl ArchiveEntryKind {
pub fn is_link(self) -> bool {
matches!(self, Self::Symlink | Self::Hardlink)
}
}
pub struct SectionEntry {
inner: RawSectionEntry,
path: PathBuf,
}
impl SectionEntry {
pub fn path(&self) -> &Path {
&self.path
}
pub fn kind(&self) -> ArchiveEntryKind {
let kind = self.inner.header().entry_type();
if kind.is_file() {
ArchiveEntryKind::File
} else if kind.is_dir() {
ArchiveEntryKind::Directory
} else if kind.is_symlink() {
ArchiveEntryKind::Symlink
} else if kind.is_hard_link() {
ArchiveEntryKind::Hardlink
} else {
ArchiveEntryKind::Other
}
}
pub fn size(&self) -> Result<u64, ExtractError> {
Ok(self.inner.header().size()?)
}
pub fn link_target(&self) -> Result<Option<PathBuf>, ExtractError> {
Ok(self.inner.link_name()?.map(std::borrow::Cow::into_owned))
}
pub async fn read(&mut self) -> Result<Vec<u8>, ExtractError> {
if let Some(link) = describe_link(self)? {
return Err(ExtractError::LinksNotFollowed(vec![link]));
}
read_raw_entry_contents(&mut self.inner).await
}
}
impl PackageArchive {
pub async fn from_url(client: ClientWithMiddleware, url: Url) -> Result<Self, ExtractError> {
Self::from_url_with_options(client, url, RemoteArchiveOptions::default()).await
}
pub async fn from_url_with_options(
client: ClientWithMiddleware,
url: Url,
options: RemoteArchiveOptions,
) -> Result<Self, ExtractError> {
let archive_type = CondaArchiveType::try_from(Path::new(url.path()))
.ok_or(ExtractError::UnsupportedArchiveType)?;
if archive_type == CondaArchiveType::Conda && options.sparse_policy != SparsePolicy::Disable
{
if let Some(archive) = Self::try_open_sparse(client.clone(), url.clone()).await? {
return Ok(archive);
}
if options.sparse_policy == SparsePolicy::Require {
return Err(ExtractError::SparseAccessUnsupported);
}
} else if options.sparse_policy == SparsePolicy::Require {
return Err(ExtractError::SparseAccessUnsupported);
}
Self::open_spooled(client, url, archive_type, options.max_spool_size).await
}
pub async fn from_path(path: impl AsRef<Path>) -> Result<Self, ExtractError> {
let path = path.as_ref();
let archive_type =
CondaArchiveType::try_from(path).ok_or(ExtractError::UnsupportedArchiveType)?;
Self::open_local(path.to_owned(), archive_type, None).await
}
pub fn access(&self) -> ArchiveAccess {
let temp = match &*self.backend {
Backend::Conda {
source: CondaSource::Sparse { .. },
..
} => return ArchiveAccess::Sparse,
Backend::Conda {
source: CondaSource::Local { temp, .. },
..
}
| Backend::TarBz2 { temp, .. } => temp,
};
if temp.is_some() {
ArchiveAccess::Spooled
} else {
ArchiveAccess::Local
}
}
pub async fn read_file(&self, path: impl AsRef<Path>) -> Result<Option<Vec<u8>>, ExtractError> {
let path = normalize(path.as_ref())?.into_owned();
let mut result = self.read_files([path.clone()]).await?;
Ok(result.remove(&path).flatten())
}
pub async fn read_files(
&self,
paths: impl IntoIterator<Item = impl Into<PathBuf>>,
) -> Result<HashMap<PathBuf, Option<Vec<u8>>>, ExtractError> {
let paths: Vec<PathBuf> = paths
.into_iter()
.map(|path| {
let path: PathBuf = path.into();
normalize(&path).map(std::borrow::Cow::into_owned)
})
.collect::<Result<_, _>>()?;
if paths.is_empty() {
return Ok(HashMap::new());
}
if let Backend::TarBz2 { path, .. } = &*self.backend {
let mut stream = Self::tar_bz2_stream(path, None).await?;
return scan_stream(&mut stream, paths).await;
}
let mut groups: HashMap<Section, Vec<PathBuf>> = HashMap::new();
for path in paths {
groups
.entry(Section::containing(&path))
.or_default()
.push(path);
}
let passes = groups.into_iter().map(|(section, group)| async move {
match self.stream(section).await {
Ok(mut stream) => scan_stream(&mut stream, group).await,
Err(ExtractError::MissingComponent) => {
Ok(group.into_iter().map(|path| (path, None)).collect())
}
Err(err) => Err(err),
}
});
let results = futures::future::try_join_all(passes).await?;
Ok(results.into_iter().flatten().collect())
}
pub async fn try_read_package_file<P: PackageFile>(&self) -> Result<Option<P>, ExtractError> {
match self.read_file(P::package_path()).await? {
None => Ok(None),
Some(bytes) => parse_package_file(&bytes).map(Some),
}
}
pub async fn read_package_file<P: PackageFile>(&self) -> Result<P, ExtractError> {
self.try_read_package_file()
.await?
.ok_or(ExtractError::MissingComponent)
}
pub async fn list_files(&self, section: Section) -> Result<Vec<PathBuf>, ExtractError> {
let mut stream = self.stream(section).await?;
let mut paths = Vec::new();
while let Some(entry) = stream.next_entry().await? {
if matches!(
entry.kind(),
ArchiveEntryKind::File | ArchiveEntryKind::Symlink | ArchiveEntryKind::Hardlink
) {
paths.push(entry.path().to_owned());
}
}
Ok(paths)
}
pub async fn stream(&self, section: Section) -> Result<SectionStream, ExtractError> {
match &*self.backend {
Backend::Conda { source, members } => {
let span = find_section_member(members, section)?;
let raw = Self::conda_member_reader(source, span).await?;
let decoder =
ZstdDecoder::new(tokio::io::BufReader::with_capacity(STREAM_BUF_SIZE, raw));
Ok(SectionStream::new(Box::new(decoder), None))
}
Backend::TarBz2 { path, .. } => Self::tar_bz2_stream(path, Some(section)).await,
}
}
pub(crate) async fn try_open_sparse(
client: ClientWithMiddleware,
url: Url,
) -> Result<Option<Self>, ExtractError> {
match Self::open_sparse(client, url).await {
Ok(archive) => Ok(Some(archive)),
Err(err) if sparse_unsupported(&err) => {
debug!("sparse access unavailable ({err}), falling back to full download");
Ok(None)
}
Err(err) => Err(err),
}
}
pub(crate) async fn open_sparse(
client: ClientWithMiddleware,
url: Url,
) -> Result<Self, ExtractError> {
let (reader, headers) = AsyncHttpRangeReader::new(
client.clone(),
url.clone(),
CheckSupportMethod::NegativeRangeRequest(TAIL_SIZE),
HeaderMap::default(),
)
.await?;
let validator = headers
.get(ETAG)
.filter(|v| !v.as_bytes().starts_with(b"W/"))
.or_else(|| headers.get(LAST_MODIFIED))
.cloned();
let size = reader.len();
debug!("opened remote archive ({size} bytes) with a {TAIL_SIZE} byte tail request");
let buf_reader = futures::io::BufReader::new(reader.compat());
let zip = ZipFileReader::new(buf_reader).await?;
let members = collect_members(zip.file(), size)?;
let mut reader = zip.into_inner().into_inner().into_inner();
let tail_offset = size.saturating_sub(TAIL_SIZE);
let mut tail = vec![0u8; (size - tail_offset) as usize];
reader.seek(SeekFrom::Start(tail_offset)).await?;
reader.read_exact(&mut tail).await?;
Ok(Self {
backend: Arc::new(Backend::Conda {
source: CondaSource::Sparse {
client,
url,
validator,
tail_offset,
tail: tail.into(),
},
members,
}),
})
}
async fn open_spooled(
client: ClientWithMiddleware,
url: Url,
archive_type: CondaArchiveType,
max_spool_size: Option<u64>,
) -> Result<Self, ExtractError> {
let response = client
.get(url.clone())
.send()
.await?
.error_for_status()
.map_err(|e| ExtractError::ReqwestError(e.into()))?;
if let (Some(limit), Some(content_length)) = (max_spool_size, response.content_length())
&& content_length > limit
{
return Err(ExtractError::SpoolLimitExceeded { limit });
}
let temp = tempfile::NamedTempFile::new()?;
let (file, temp_path) = temp.into_parts();
let mut file = tokio::fs::File::from_std(file);
let body = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other));
let copied = if let Some(limit) = max_spool_size {
let mut body = body.take(limit.saturating_add(1));
tokio::io::copy(&mut body, &mut file).await?
} else {
let mut body = body;
tokio::io::copy(&mut body, &mut file).await?
};
if let Some(limit) = max_spool_size
&& copied > limit
{
return Err(ExtractError::SpoolLimitExceeded { limit });
}
file.flush().await?;
Self::open_local(temp_path.to_path_buf(), archive_type, Some(temp_path)).await
}
async fn open_local(
path: PathBuf,
archive_type: CondaArchiveType,
temp: Option<tempfile::TempPath>,
) -> Result<Self, ExtractError> {
let backend = match archive_type {
CondaArchiveType::Conda => {
let file = tokio::fs::File::open(&path).await?;
let size = file.metadata().await?.len();
let buf_reader =
futures::io::BufReader::new(tokio::io::BufReader::new(file).compat());
let zip = ZipFileReader::new(buf_reader).await?;
let members = collect_members(zip.file(), size)?;
Backend::Conda {
source: CondaSource::Local { path, temp },
members,
}
}
CondaArchiveType::TarBz2 => Backend::TarBz2 { path, temp },
};
Ok(Self {
backend: Arc::new(backend),
})
}
async fn conda_member_reader(
source: &CondaSource,
span: &MemberSpan,
) -> Result<DynReader, ExtractError> {
match source {
CondaSource::Sparse {
client,
url,
validator,
tail_offset,
tail,
} => {
if span.header_offset >= *tail_offset {
let rel = (span.header_offset - tail_offset) as usize;
if let Some(range) = member_data_range(&tail[rel..], span.size) {
debug!("serving member {} from the cached tail", span.name);
let data = tail.slice(rel + range.start..rel + range.end);
return Ok(Box::new(std::io::Cursor::new(data)));
}
}
debug!(
"requesting range {}-{} for member {}",
span.header_offset,
span.end - 1,
span.name
);
let mut request = client
.get(url.clone())
.header(
RANGE,
format!("bytes={}-{}", span.header_offset, span.end - 1),
)
.header(http::header::ACCEPT_ENCODING, "identity");
if let Some(validator) = validator {
request = request.header(IF_RANGE, validator);
}
let response = request
.send()
.await?
.error_for_status()
.map_err(|e| ExtractError::ReqwestError(e.into()))?;
if response.status() != ::reqwest::StatusCode::PARTIAL_CONTENT {
return Err(ExtractError::RemoteArchiveChanged);
}
let mut reader =
StreamReader::new(response.bytes_stream().map_err(std::io::Error::other));
skip_local_header(&mut reader).await?;
Ok(Box::new(reader.take(span.size)))
}
CondaSource::Local { path, .. } => {
let mut file = tokio::fs::File::open(path).await?;
file.seek(SeekFrom::Start(span.header_offset)).await?;
let mut reader = tokio::io::BufReader::new(file);
skip_local_header(&mut reader).await?;
Ok(Box::new(reader.take(span.size)))
}
}
}
async fn tar_bz2_stream(
path: &Path,
section: Option<Section>,
) -> Result<SectionStream, ExtractError> {
let file = tokio::fs::File::open(path).await?;
let decoder = BzDecoder::new(tokio::io::BufReader::with_capacity(STREAM_BUF_SIZE, file));
Ok(SectionStream::new(Box::new(decoder), section))
}
}
pub struct SectionStream {
entries: tokio_tar::Entries<DynReader>,
filter: Option<Section>,
}
impl SectionStream {
fn new(reader: DynReader, filter: Option<Section>) -> Self {
let mut archive = tokio_tar::Archive::new(reader);
let entries = archive
.entries()
.expect("entries() cannot fail on a fresh archive");
Self { entries, filter }
}
pub async fn next_entry(&mut self) -> Result<Option<SectionEntry>, ExtractError> {
use futures_util::StreamExt;
while let Some(entry) = self.entries.next().await {
let entry = entry?;
let path = {
let path = entry.path()?;
normalize(&path)?.into_owned()
};
if let Some(section) = self.filter
&& Section::containing(&path) != section
{
continue;
}
return Ok(Some(SectionEntry { inner: entry, path }));
}
Ok(None)
}
}
async fn scan_stream(
stream: &mut SectionStream,
paths: Vec<PathBuf>,
) -> Result<HashMap<PathBuf, Option<Vec<u8>>>, ExtractError> {
let mut remaining: HashSet<PathBuf> = paths.into_iter().collect();
let mut out = HashMap::with_capacity(remaining.len());
let mut links: Vec<String> = Vec::new();
while !remaining.is_empty() {
let Some(mut entry) = stream.next_entry().await? else {
break;
};
let path = entry.path().to_owned();
if remaining.remove(&path) {
if let Some(link) = describe_link(&entry)? {
links.push(link);
continue;
}
let buf = entry.read().await?;
out.insert(path, Some(buf));
}
}
if !links.is_empty() {
return Err(ExtractError::LinksNotFollowed(links));
}
for path in remaining {
out.insert(path, None);
}
Ok(out)
}
fn collect_members(
zip: &async_zip::ZipFile,
archive_size: u64,
) -> Result<Vec<MemberSpan>, ExtractError> {
let entries = zip.entries();
let mut members = Vec::with_capacity(entries.len());
for entry in entries {
let name = entry
.filename()
.as_str()
.map_err(|e| {
ExtractError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})?
.to_owned();
if name.ends_with(".tar.zst") && entry.compression() != Compression::Stored {
return Err(ExtractError::UnsupportedCompressionMethod);
}
members.push(MemberSpan {
name,
header_offset: entry.header_offset(),
size: entry.compressed_size(),
end: archive_size,
});
}
members.sort_unstable_by_key(|m| m.header_offset);
for i in 1..members.len() {
members[i - 1].end = members[i].header_offset;
}
Ok(members)
}
fn find_section_member(
members: &[MemberSpan],
section: Section,
) -> Result<&MemberSpan, ExtractError> {
let prefix = section.zip_prefix();
members
.iter()
.find(|m| m.name.starts_with(prefix) && m.name.ends_with(".tar.zst"))
.ok_or(ExtractError::MissingComponent)
}
fn describe_link(entry: &SectionEntry) -> Result<Option<String>, ExtractError> {
if !entry.kind().is_link() {
return Ok(None);
}
let target = entry
.link_target()?
.map(|target| target.display().to_string())
.unwrap_or_default();
Ok(Some(format!(
"'{}' (links to '{target}')",
entry.path().display()
)))
}
pub(crate) async fn read_raw_entry_contents<R: AsyncRead + Unpin>(
entry: &mut tokio_tar::Entry<R>,
) -> Result<Vec<u8>, ExtractError> {
let kind = entry.header().entry_type();
if kind.is_symlink() || kind.is_hard_link() {
let path = normalize(&entry.path()?)?.into_owned();
let target = entry
.link_name()?
.map(std::borrow::Cow::into_owned)
.unwrap_or_default();
return Err(ExtractError::LinksNotFollowed(vec![format!(
"'{}' (links to '{}')",
path.display(),
target.display()
)]));
}
let size = entry.header().size()?;
let mut buf = Vec::with_capacity(size.min(MAX_PREALLOC) as usize);
entry.read_to_end(&mut buf).await?;
Ok(buf)
}
pub(crate) fn parse_package_file<P: PackageFile>(bytes: &[u8]) -> Result<P, ExtractError> {
P::from_slice(bytes)
.map_err(|e| ExtractError::ArchiveMemberParseError(P::package_path().to_owned(), e))
}
pub(crate) fn normalize(path: &Path) -> Result<std::borrow::Cow<'_, Path>, ExtractError> {
let mut needs_normalization = false;
let mut has_component = false;
for component in path.components() {
match component {
std::path::Component::Normal(_) => has_component = true,
std::path::Component::CurDir => needs_normalization = true,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => {
return Err(ExtractError::InvalidArchivePath(path.to_owned()));
}
}
}
if !has_component {
return Err(ExtractError::InvalidArchivePath(path.to_owned()));
}
if needs_normalization {
Ok(std::borrow::Cow::Owned(
path.components()
.filter(|component| !matches!(component, std::path::Component::CurDir))
.collect(),
))
} else {
Ok(std::borrow::Cow::Borrowed(path))
}
}
fn member_data_range(buf: &[u8], size: u64) -> Option<std::ops::Range<usize>> {
if buf.len() < 30 || buf[0..4] != LOCAL_HEADER_MAGIC {
return None;
}
let name_len = u16::from_le_bytes([buf[26], buf[27]]) as usize;
let extra_len = u16::from_le_bytes([buf[28], buf[29]]) as usize;
let data_start = 30 + name_len + extra_len;
let data_end = data_start.checked_add(size as usize)?;
(data_end <= buf.len()).then_some(data_start..data_end)
}
async fn skip_local_header<R: AsyncRead + Unpin>(reader: &mut R) -> Result<(), ExtractError> {
let mut header = [0u8; 30];
reader.read_exact(&mut header).await?;
if header[0..4] != LOCAL_HEADER_MAGIC {
return Err(ExtractError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"expected a ZIP local file header",
)));
}
let name_len = u64::from(u16::from_le_bytes([header[26], header[27]]));
let extra_len = u64::from(u16::from_le_bytes([header[28], header[29]]));
let mut skip = reader.take(name_len + extra_len);
tokio::io::copy(&mut skip, &mut tokio::io::sink()).await?;
Ok(())
}
fn sparse_unsupported(err: &ExtractError) -> bool {
match err {
ExtractError::AsyncHttpRangeReaderError(
AsyncHttpRangeReaderError::HttpRangeRequestUnsupported
| AsyncHttpRangeReaderError::ContentRangeMissing,
) => true,
ExtractError::AsyncHttpRangeReaderError(AsyncHttpRangeReaderError::HttpError(err)) => {
err.status() == Some(::reqwest::StatusCode::RANGE_NOT_SATISFIABLE)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use rattler_conda_types::package::{AboutJson, IndexJson};
use super::*;
use crate::reqwest::test_server;
fn conda_test_file() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/clobber/clobber-fd-1-0.1.0-h4616a5c_0.conda")
}
fn tar_bz2_test_file() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/clobber/clobber-1-0.1.0-h4616a5c_0.tar.bz2")
}
struct RequestCounter(Arc<AtomicUsize>);
#[async_trait::async_trait]
impl reqwest_middleware::Middleware for RequestCounter {
async fn handle(
&self,
req: ::reqwest::Request,
extensions: &mut http::Extensions,
next: reqwest_middleware::Next<'_>,
) -> reqwest_middleware::Result<::reqwest::Response> {
self.0.fetch_add(1, Ordering::Relaxed);
next.run(req, extensions).await
}
}
fn counting_client() -> (ClientWithMiddleware, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let client = reqwest_middleware::ClientBuilder::new(::reqwest::Client::new())
.with(RequestCounter(counter.clone()))
.build();
(client, counter)
}
#[tokio::test]
async fn test_sparse_conda_round_trip() {
let url = test_server::serve_file(conda_test_file()).await;
let (client, requests) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Sparse);
assert_eq!(requests.load(Ordering::Relaxed), 1, "open = 1 request");
let index: IndexJson = archive.read_package_file().await.unwrap();
assert_eq!(index.name.as_normalized(), "clobber-fd-1");
let _about: AboutJson = archive.read_package_file().await.unwrap();
assert_eq!(
requests.load(Ordering::Relaxed),
1,
"metadata reads served from the tail cache"
);
let files = archive
.read_files(["clobber", "info/index.json", "does/not/exist"])
.await
.unwrap();
assert_eq!(
String::from_utf8(files[Path::new("clobber")].clone().unwrap()).unwrap(),
"clobber-fd-1\n"
);
assert!(files[Path::new("info/index.json")].is_some());
assert!(files[Path::new("does/not/exist")].is_none());
assert_eq!(
requests.load(Ordering::Relaxed),
1,
"tiny package: payload also served from the tail cache"
);
}
#[tokio::test]
async fn test_stream_section() {
let url = test_server::serve_file(conda_test_file()).await;
let (client, _) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
let mut names = Vec::new();
let mut stream = archive.stream(Section::Info).await.unwrap();
while let Some(entry) = stream.next_entry().await.unwrap() {
names.push(entry.path().display().to_string());
}
assert!(names.iter().any(|n| n == "info/index.json"), "{names:?}");
}
#[tokio::test]
async fn test_tar_bz2_spooled() {
let url = test_server::serve_file(tar_bz2_test_file()).await;
let (client, requests) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Spooled);
assert_eq!(requests.load(Ordering::Relaxed), 1, "one full download");
let files = archive
.read_files(["info/index.json", "clobber.txt"])
.await
.unwrap();
assert!(files[Path::new("info/index.json")].is_some());
assert!(files[Path::new("clobber.txt")].is_some());
let index: IndexJson = archive.read_package_file().await.unwrap();
assert_eq!(index.name.as_normalized(), "clobber-1");
assert_eq!(
requests.load(Ordering::Relaxed),
1,
"spooled archive is downloaded exactly once"
);
let mut stream = archive.stream(Section::Content).await.unwrap();
let mut names = Vec::new();
while let Some(entry) = stream.next_entry().await.unwrap() {
names.push(entry.path().display().to_string());
}
assert!(names.iter().all(|n| !n.starts_with("info/")), "{names:?}");
assert!(names.iter().any(|n| n == "clobber.txt"), "{names:?}");
}
#[tokio::test]
async fn test_conda_no_range_support_fallback() {
let url = test_server::serve_file_no_ranges(conda_test_file()).await;
let (client, requests) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Spooled);
assert_eq!(
requests.load(Ordering::Relaxed),
2,
"one failed range probe + one full download"
);
let index: IndexJson = archive.read_package_file().await.unwrap();
assert_eq!(index.name.as_normalized(), "clobber-fd-1");
let content = archive.read_file("clobber").await.unwrap().unwrap();
assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n");
assert_eq!(
requests.load(Ordering::Relaxed),
2,
"all reads served from the spool file"
);
}
#[tokio::test]
async fn test_sparse_large_package() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/sparse-test-1.0.0-0.conda");
let url = test_server::serve_file(fixture).await;
let (client, requests) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
assert_eq!(requests.load(Ordering::Relaxed), 1, "open = 1 request");
let index = archive
.read_file("./info/index.json")
.await
.unwrap()
.expect("index.json should exist");
assert!(!index.is_empty());
assert_eq!(requests.load(Ordering::Relaxed), 1);
let files = archive
.read_files(["bin/first-file.txt", "share/last-file.txt"])
.await
.unwrap();
assert_eq!(
files[Path::new("bin/first-file.txt")].as_deref(),
Some(b"first payload file\n".as_slice())
);
assert_eq!(
files[Path::new("share/last-file.txt")].as_deref(),
Some(b"last payload file\n".as_slice())
);
assert_eq!(
requests.load(Ordering::Relaxed),
2,
"payload batch = 1 ranged request"
);
let names = archive.list_files(Section::Content).await.unwrap();
assert_eq!(names.len(), 3, "{names:?}");
}
#[tokio::test]
async fn test_list_files() {
let archive = PackageArchive::from_path(conda_test_file()).await.unwrap();
let info = archive.list_files(Section::Info).await.unwrap();
assert!(
info.iter().any(|p| p == Path::new("info/index.json")),
"{info:?}"
);
let content = archive.list_files(Section::Content).await.unwrap();
assert_eq!(content, vec![PathBuf::from("clobber")]);
}
#[tokio::test]
async fn test_symlinks_surfaced_not_followed() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/symlink-test-1.0.0-0.conda");
let archive = PackageArchive::from_path(&fixture).await.unwrap();
let files = archive.list_files(Section::Content).await.unwrap();
assert!(
files.contains(&PathBuf::from("lib/liblink.so")),
"{files:?}"
);
assert!(
files.contains(&PathBuf::from("lib/libhard.so")),
"{files:?}"
);
assert!(
files.contains(&PathBuf::from("lib/libreal.so.1")),
"{files:?}"
);
let real = archive.read_file("lib/libreal.so.1").await.unwrap();
assert_eq!(real.as_deref(), Some(b"real library bytes".as_slice()));
let mut stream = archive.stream(Section::Content).await.unwrap();
let mut kinds = HashMap::new();
while let Some(entry) = stream.next_entry().await.unwrap() {
kinds.insert(entry.path().to_owned(), entry.kind());
}
assert_eq!(
kinds[Path::new("lib/liblink.so")],
ArchiveEntryKind::Symlink
);
assert_eq!(
kinds[Path::new("lib/libhard.so")],
ArchiveEntryKind::Hardlink
);
for link in ["lib/liblink.so", "lib/libhard.so"] {
let err = archive.read_file(link).await.unwrap_err();
assert!(err.to_string().contains("links are not followed"), "{err}");
}
}
#[tokio::test]
async fn test_missing_section_reads_as_none() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/info-only-1.0.0-0.conda");
let archive = PackageArchive::from_path(&fixture).await.unwrap();
let files = archive
.read_files(["bin/missing", "info/index.json"])
.await
.unwrap();
assert!(files[Path::new("bin/missing")].is_none());
assert!(files[Path::new("info/index.json")].is_some());
assert!(matches!(
archive.stream(Section::Content).await,
Err(ExtractError::MissingComponent)
));
}
#[tokio::test]
async fn test_zip64_local_headers() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/zip64-test-1.0.0-0.conda");
let url = test_server::serve_file(fixture).await;
let (client, _) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
let content = archive.read_file("bin/hello.txt").await.unwrap();
assert_eq!(content.as_deref(), Some(b"zip64 payload\n".as_slice()));
}
#[tokio::test]
async fn test_tar_bz2_list_files() {
let archive = PackageArchive::from_path(tar_bz2_test_file())
.await
.unwrap();
let info = archive.list_files(Section::Info).await.unwrap();
assert!(info.contains(&PathBuf::from("info/index.json")), "{info:?}");
let content = archive.list_files(Section::Content).await.unwrap();
assert!(
content.contains(&PathBuf::from("clobber.txt")),
"{content:?}"
);
assert!(
content.iter().all(|p| !p.starts_with("info")),
"{content:?}"
);
}
#[tokio::test]
async fn test_try_read_package_file_absent() {
use rattler_conda_types::package::RunExportsJson;
let archive = PackageArchive::from_path(conda_test_file()).await.unwrap();
let run_exports: Option<RunExportsJson> = archive.try_read_package_file().await.unwrap();
assert!(run_exports.is_none());
}
#[tokio::test]
async fn test_conda_416_suffix_fallback() {
let url = test_server::serve_file_416_suffix(conda_test_file()).await;
let (client, requests) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Spooled);
assert_eq!(
requests.load(Ordering::Relaxed),
2,
"rejected range probe + one full download"
);
let content = archive.read_file("clobber").await.unwrap().unwrap();
assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n");
}
#[tokio::test]
async fn test_archive_replaced_mid_read_errors() {
let dir = tempfile::tempdir().unwrap();
let served = dir.path().join("replaced-test-1.0.0-0.conda");
std::fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/sparse-test-1.0.0-0.conda"),
&served,
)
.unwrap();
let url = test_server::serve_file(&served).await;
let (client, _) = counting_client();
let archive = PackageArchive::from_url(client, url).await.unwrap();
std::fs::copy(conda_test_file(), &served).unwrap();
assert!(archive.read_file("bin/first-file.txt").await.is_err());
}
#[tokio::test]
async fn test_dot_slash_entries_round_trip() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/dotslash-test-1.0.0-0.conda");
let archive = PackageArchive::from_path(&fixture).await.unwrap();
let files = archive.list_files(Section::Content).await.unwrap();
assert_eq!(files, vec![PathBuf::from("lib/data.txt")]);
for spelling in ["lib/data.txt", "./lib/data.txt"] {
let content = archive.read_file(spelling).await.unwrap();
assert_eq!(
content.as_deref(),
Some(b"dot slash payload\n".as_slice()),
"{spelling}"
);
}
assert!(
archive
.read_file("info/index.json")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn test_link_error_names_offending_path() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test-data/sparse/symlink-test-1.0.0-0.conda");
let archive = PackageArchive::from_path(&fixture).await.unwrap();
let err = archive
.read_files(["lib/libreal.so.1", "lib/liblink.so"])
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("'lib/liblink.so'"), "{message}");
assert!(
!message.contains("'lib/libreal.so.1'"),
"the regular file must not be reported as offending: {message}"
);
}
#[test]
fn test_section_containing() {
assert_eq!(
Section::containing(Path::new("info/index.json")),
Section::Info
);
assert_eq!(
Section::containing(Path::new("./info/index.json")),
Section::Info
);
assert_eq!(Section::containing(Path::new("info")), Section::Info);
assert_eq!(
Section::containing(Path::new("info-custom.txt")),
Section::Content
);
assert_eq!(
Section::containing(Path::new("information/file")),
Section::Content
);
assert_eq!(
Section::containing(Path::new("lib/libz.so")),
Section::Content
);
}
#[tokio::test]
async fn test_remote_access_policy() {
let url = test_server::serve_file_no_ranges(conda_test_file()).await;
let (client, requests) = counting_client();
let options = RemoteArchiveOptions::new().with_sparse_policy(SparsePolicy::Require);
let error = match PackageArchive::from_url_with_options(client, url, options).await {
Ok(_) => panic!("range support should have been required"),
Err(error) => error,
};
assert!(matches!(error, ExtractError::SparseAccessUnsupported));
assert_eq!(requests.load(Ordering::Relaxed), 1);
let url = test_server::serve_file(conda_test_file()).await;
let (client, requests) = counting_client();
let options = RemoteArchiveOptions::new().with_sparse_policy(SparsePolicy::Disable);
let archive = PackageArchive::from_url_with_options(client, url, options)
.await
.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Spooled);
assert_eq!(requests.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_spool_size_limit() {
let url = test_server::serve_file_no_ranges(conda_test_file()).await;
let (client, _) = counting_client();
let options = RemoteArchiveOptions::new().with_max_spool_size(1);
let error = match PackageArchive::from_url_with_options(client, url, options).await {
Ok(_) => panic!("the spool limit should have rejected the download"),
Err(error) => error,
};
assert!(matches!(
error,
ExtractError::SpoolLimitExceeded { limit: 1 }
));
}
#[tokio::test]
async fn test_invalid_archive_paths() {
let archive = PackageArchive::from_path(conda_test_file()).await.unwrap();
for path in ["", ".", "../clobber", "/clobber"] {
assert!(matches!(
archive.read_file(path).await,
Err(ExtractError::InvalidArchivePath(_))
));
}
}
#[tokio::test]
async fn test_local_conda() {
let archive = PackageArchive::from_path(conda_test_file()).await.unwrap();
assert_eq!(archive.access(), ArchiveAccess::Local);
let index: IndexJson = archive.read_package_file().await.unwrap();
assert_eq!(index.name.as_normalized(), "clobber-fd-1");
let content = archive.read_file("clobber").await.unwrap().unwrap();
assert_eq!(String::from_utf8(content).unwrap(), "clobber-fd-1\n");
}
}