use crate::error::{SkillError, SkillResult};
use std::collections::BTreeMap;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use zip::ZipArchive;
pub const MAX_ARCHIVE_BYTES: u64 = 10 * 1024 * 1024;
pub const MAX_ENTRIES: usize = 10_000;
pub const MAX_UNCOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
pub const MAX_COMPRESSION_RATIO: u64 = 100;
pub const MAX_DIRECTORY_DEPTH: usize = 8;
pub fn extract_skill_archive(bytes: &[u8]) -> SkillResult<BTreeMap<String, Vec<u8>>> {
if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
return Err(SkillError::ArchiveTooLarge {
size: bytes.len() as u64,
limit: MAX_ARCHIVE_BYTES,
});
}
let names = central_directory_names(bytes)?;
if names.len() > MAX_ENTRIES {
return Err(SkillError::ArchiveTooManyEntries { count: names.len(), limit: MAX_ENTRIES });
}
let mut seen = std::collections::BTreeSet::new();
for name in &names {
let normalized = name.trim_end_matches(['/', '\\']);
if !seen.insert(normalized) {
return Err(SkillError::ArchiveDuplicateEntry { name: name.clone() });
}
}
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(format_error)?;
let mut declared_total: u64 = 0;
for index in 0..archive.len() {
let entry = archive.by_index_raw(index).map_err(format_error)?;
let name = entry.name().to_string();
validate_entry_name(&name)?;
if entry.is_symlink() {
return Err(SkillError::ArchiveSymlink { name });
}
if entry.is_dir() {
continue;
}
let declared = entry.size();
declared_total = declared_total.saturating_add(declared);
if declared_total > MAX_UNCOMPRESSED_BYTES {
return Err(SkillError::ArchiveUncompressedTooLarge {
total: declared_total,
limit: MAX_UNCOMPRESSED_BYTES,
});
}
let compressed = entry.compressed_size();
if compressed > 0 && declared > compressed.saturating_mul(MAX_COMPRESSION_RATIO) {
return Err(SkillError::ArchiveCompressionRatio {
name,
ratio: declared / compressed,
limit: MAX_COMPRESSION_RATIO,
});
}
}
let mut files = BTreeMap::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(format_error)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let declared = entry.size();
let mut contents = Vec::with_capacity(usize::try_from(declared).unwrap_or(0));
let read = std::io::copy(&mut (&mut entry).take(declared + 1), &mut contents)
.map_err(|error| SkillError::ArchiveFormat { message: error.to_string() })?;
if read != declared {
return Err(SkillError::ArchiveFormat {
message: format!(
"entry `{name}` decompressed to {read} bytes but declared {declared}"
),
});
}
files.insert(name, contents);
}
Ok(files)
}
pub fn extract_skill_archive_to_dir(bytes: &[u8], dir: &Path) -> SkillResult<Vec<PathBuf>> {
let files = extract_skill_archive(bytes)?;
write_files_to_dir(&files, dir)
}
pub(crate) fn write_files_to_dir(
files: &BTreeMap<String, Vec<u8>>,
dir: &Path,
) -> SkillResult<Vec<PathBuf>> {
let mut written = Vec::with_capacity(files.len());
for (name, contents) in files {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, contents)?;
written.push(path);
}
Ok(written)
}
fn format_error(error: zip::result::ZipError) -> SkillError {
SkillError::ArchiveFormat { message: error.to_string() }
}
fn central_directory_names(bytes: &[u8]) -> SkillResult<Vec<String>> {
const EOCD_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
const CENTRAL_HEADER_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
const EOCD_LEN: usize = 22;
const CENTRAL_HEADER_LEN: usize = 46;
const MAX_COMMENT_LEN: usize = u16::MAX as usize;
let read_u16 = |offset: usize| u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) as usize;
let read_u32 = |offset: usize| {
u32::from_le_bytes([bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]])
as usize
};
if bytes.len() < EOCD_LEN {
return Err(SkillError::ArchiveFormat {
message: "missing end-of-central-directory record".to_string(),
});
}
let search_start = bytes.len().saturating_sub(EOCD_LEN + MAX_COMMENT_LEN);
let eocd = (search_start..=bytes.len() - EOCD_LEN)
.rev()
.find(|&offset| bytes[offset..offset + 4] == EOCD_SIGNATURE)
.ok_or_else(|| SkillError::ArchiveFormat {
message: "missing end-of-central-directory record".to_string(),
})?;
let total_entries = read_u16(eocd + 10);
let directory_size = read_u32(eocd + 12);
let directory_offset = read_u32(eocd + 16);
if total_entries == u16::MAX as usize
|| directory_size == u32::MAX as usize
|| directory_offset == u32::MAX as usize
{
return Err(SkillError::ArchiveFormat {
message: "zip64 archives are not supported; repackage without zip64".to_string(),
});
}
let directory_end = directory_offset
.checked_add(directory_size)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| SkillError::ArchiveFormat {
message: "central directory extends past the end of the archive".to_string(),
})?;
let truncated =
|| SkillError::ArchiveFormat { message: "truncated central directory record".to_string() };
let mut names = Vec::with_capacity(total_entries.min(MAX_ENTRIES + 1));
let mut cursor = directory_offset;
for _ in 0..total_entries {
if cursor + CENTRAL_HEADER_LEN > directory_end {
return Err(truncated());
}
if bytes[cursor..cursor + 4] != CENTRAL_HEADER_SIGNATURE {
return Err(SkillError::ArchiveFormat {
message: "malformed central directory header".to_string(),
});
}
let name_len = read_u16(cursor + 28);
let extra_len = read_u16(cursor + 30);
let comment_len = read_u16(cursor + 32);
let name_start = cursor + CENTRAL_HEADER_LEN;
let name_end = name_start + name_len;
if name_end > directory_end {
return Err(truncated());
}
names.push(String::from_utf8_lossy(&bytes[name_start..name_end]).into_owned());
cursor = name_end + extra_len + comment_len;
}
Ok(names)
}
fn validate_entry_name(name: &str) -> SkillResult<()> {
if name.is_empty() {
return Err(SkillError::ArchiveFormat { message: "entry with an empty name".to_string() });
}
if name.starts_with('/') || name.starts_with('\\') {
return Err(SkillError::ArchiveAbsolutePath { name: name.to_string() });
}
let components: Vec<&str> =
name.split(['/', '\\']).filter(|component| !component.is_empty()).collect();
if components.contains(&"..") {
return Err(SkillError::ArchivePathTraversal { name: name.to_string() });
}
let is_dir_entry = name.ends_with('/') || name.ends_with('\\');
let depth = if is_dir_entry { components.len() } else { components.len().saturating_sub(1) };
if depth > MAX_DIRECTORY_DEPTH {
return Err(SkillError::ArchiveDepthExceeded {
name: name.to_string(),
depth,
limit: MAX_DIRECTORY_DEPTH,
});
}
Ok(())
}