use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::io::{Read, Cursor};
use zip::ZipArchive;
use plist::Value;
use crate::iwa::archive::{Archive, ArchiveObject};
use crate::iwa::snappy::SnappyStream;
use crate::iwa::{Error, Result};
#[derive(Debug)]
pub struct Bundle {
bundle_path: PathBuf,
archives: HashMap<String, Archive>,
metadata: BundleMetadata,
}
impl Bundle {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let bundle_path = path.as_ref().to_path_buf();
if bundle_path.is_dir() {
Self::open_directory_bundle(&bundle_path)
} else if bundle_path.is_file() {
Self::open_file_bundle(&bundle_path)
} else {
Err(Error::Bundle("Path does not exist".to_string()))
}
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let archives = Self::parse_zip_bytes(bytes)?;
let metadata = BundleMetadata {
has_properties: true, has_build_version_history: true,
has_document_identifier: true,
detected_application: None,
properties: HashMap::new(),
build_versions: Vec::new(),
document_id: None,
};
Ok(Bundle {
bundle_path: std::path::PathBuf::from("<bytes>"), archives,
metadata,
})
}
fn open_directory_bundle(bundle_path: &Path) -> Result<Self> {
Self::validate_bundle_structure(bundle_path)?;
let archives = Self::parse_index_zip(bundle_path)?;
let metadata = Self::parse_metadata(bundle_path)?;
Ok(Bundle {
bundle_path: bundle_path.to_path_buf(),
archives,
metadata,
})
}
fn open_file_bundle(bundle_path: &Path) -> Result<Self> {
let archives = Self::parse_zip_bundle(bundle_path)?;
let metadata = BundleMetadata {
has_properties: true, has_build_version_history: true,
has_document_identifier: true,
detected_application: None,
properties: HashMap::new(),
build_versions: Vec::new(),
document_id: None,
};
Ok(Bundle {
bundle_path: bundle_path.to_path_buf(),
archives,
metadata,
})
}
fn validate_bundle_structure(bundle_path: &Path) -> Result<()> {
let index_zip = bundle_path.join("Index.zip");
if !index_zip.exists() {
return Err(Error::Bundle("Index.zip not found in bundle".to_string()));
}
let metadata_dir = bundle_path.join("Metadata");
if !metadata_dir.exists() || !metadata_dir.is_dir() {
}
Ok(())
}
fn parse_index_zip(bundle_path: &Path) -> Result<HashMap<String, Archive>> {
let index_zip_path = bundle_path.join("Index.zip");
let file = fs::File::open(&index_zip_path)
.map_err(Error::Io)?;
let mut zip_archive = ZipArchive::new(file)
.map_err(|e| Error::Bundle(format!("Failed to open Index.zip: {}", e)))?;
Self::parse_iwa_files_from_zip(&mut zip_archive)
}
fn parse_zip_bundle(bundle_path: &Path) -> Result<HashMap<String, Archive>> {
let file = fs::File::open(bundle_path)
.map_err(Error::Io)?;
let mut zip_archive = ZipArchive::new(file)
.map_err(|e| Error::Bundle(format!("Failed to open bundle file: {}", e)))?;
Self::parse_iwa_files_from_zip(&mut zip_archive)
}
fn parse_zip_bytes(bytes: &[u8]) -> Result<HashMap<String, Archive>> {
let cursor = Cursor::new(bytes);
let mut zip_archive = ZipArchive::new(cursor)
.map_err(|e| Error::Bundle(format!("Failed to open ZIP archive from bytes: {}", e)))?;
Self::parse_iwa_files_from_zip(&mut zip_archive)
}
fn parse_iwa_files_from_zip<R: Read + std::io::Seek>(zip_archive: &mut ZipArchive<R>) -> Result<HashMap<String, Archive>> {
let mut archives = HashMap::new();
for i in 0..zip_archive.len() {
let mut zip_file = zip_archive.by_index(i)
.map_err(|e| Error::Bundle(format!("Failed to read zip entry: {}", e)))?;
if zip_file.name().ends_with(".iwa") {
let mut compressed_data = Vec::new();
zip_file.read_to_end(&mut compressed_data)
.map_err(Error::Io)?;
let mut cursor = Cursor::new(&compressed_data);
let decompressed = SnappyStream::decompress(&mut cursor)?;
let archive = Archive::parse(decompressed.data())?;
let name = zip_file.name().to_string();
archives.insert(name, archive);
}
}
Ok(archives)
}
fn parse_metadata(bundle_path: &Path) -> Result<BundleMetadata> {
let metadata_dir = bundle_path.join("Metadata");
let mut metadata = BundleMetadata::default();
if !metadata_dir.exists() {
return Ok(metadata);
}
let properties_path = metadata_dir.join("Properties.plist");
if properties_path.exists() {
metadata.has_properties = true;
if let Ok(value) = Value::from_file(&properties_path) {
metadata.properties = Self::parse_plist_value(&value);
if let Some(PropertyValue::String(app_name)) = metadata.properties.get("Application") {
metadata.detected_application = Some(app_name.clone());
}
}
}
let build_version_path = metadata_dir.join("BuildVersionHistory.plist");
if build_version_path.exists() {
metadata.has_build_version_history = true;
if let Ok(value) = Value::from_file(&build_version_path) {
metadata.build_versions = Self::parse_build_versions(&value);
}
}
let doc_id_path = metadata_dir.join("DocumentIdentifier");
if doc_id_path.exists() {
metadata.has_document_identifier = true;
if let Ok(id) = fs::read_to_string(&doc_id_path) {
metadata.document_id = Some(id.trim().to_string());
}
}
Ok(metadata)
}
fn parse_plist_value(value: &Value) -> HashMap<String, PropertyValue> {
let mut result = HashMap::new();
if let Value::Dictionary(dict) = value {
for (key, val) in dict {
result.insert(key.clone(), Self::convert_plist_value(val));
}
}
result
}
fn convert_plist_value(value: &Value) -> PropertyValue {
match value {
Value::String(s) => PropertyValue::String(s.clone()),
Value::Integer(i) => PropertyValue::Integer(i.as_signed().unwrap_or(0)),
Value::Real(r) => PropertyValue::Real(*r),
Value::Boolean(b) => PropertyValue::Boolean(*b),
Value::Date(d) => PropertyValue::Date(format!("{:?}", d)),
Value::Array(arr) => {
PropertyValue::Array(arr.iter().map(Self::convert_plist_value).collect())
}
Value::Dictionary(dict) => {
let mut map = HashMap::new();
for (k, v) in dict {
map.insert(k.clone(), Self::convert_plist_value(v));
}
PropertyValue::Dictionary(map)
}
Value::Data(_) => PropertyValue::String("<binary data>".to_string()),
_ => PropertyValue::String("<unknown>".to_string()),
}
}
fn parse_build_versions(value: &Value) -> Vec<String> {
let mut versions = Vec::new();
if let Value::Array(arr) = value {
for item in arr {
if let Value::String(version) = item {
versions.push(version.clone());
} else if let Value::Dictionary(dict) = item {
if let Some(Value::String(version)) = dict.get("Version") {
versions.push(version.clone());
} else if let Some(Value::String(build)) = dict.get("Build") {
versions.push(build.clone());
}
}
}
}
versions
}
pub fn archives(&self) -> &HashMap<String, Archive> {
&self.archives
}
pub fn get_archive(&self, name: &str) -> Option<&Archive> {
self.archives.get(name)
}
pub fn metadata(&self) -> &BundleMetadata {
&self.metadata
}
pub fn path(&self) -> &Path {
&self.bundle_path
}
pub fn extract_text(&self) -> Result<String> {
let mut text_parts = Vec::new();
for archive in self.archives.values() {
for object in &archive.objects {
text_parts.extend(object.extract_text());
}
}
Ok(text_parts.join("\n"))
}
pub fn all_objects(&self) -> Vec<(&str, &ArchiveObject)> {
let mut objects = Vec::new();
for (archive_name, archive) in &self.archives {
for object in &archive.objects {
objects.push((archive_name.as_str(), object));
}
}
objects
}
pub fn find_objects_by_type(&self, message_type: u32) -> Vec<(&str, &ArchiveObject)> {
let mut matching_objects = Vec::new();
for (archive_name, archive) in &self.archives {
for object in &archive.objects {
if object.messages.iter().any(|msg| msg.type_ == message_type) {
matching_objects.push((archive_name.as_str(), object));
}
}
}
matching_objects
}
}
#[derive(Debug, Clone, Default)]
pub struct BundleMetadata {
pub has_properties: bool,
pub has_build_version_history: bool,
pub has_document_identifier: bool,
pub detected_application: Option<String>,
pub properties: HashMap<String, PropertyValue>,
pub build_versions: Vec<String>,
pub document_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PropertyValue {
String(String),
Integer(i64),
Real(f64),
Boolean(bool),
Date(String),
Array(Vec<PropertyValue>),
Dictionary(HashMap<String, PropertyValue>),
}
impl BundleMetadata {
pub fn summary(&self) -> String {
format!(
"Properties: {}, BuildVersion: {}, DocumentID: {}, App: {}",
self.has_properties,
self.has_build_version_history,
self.has_document_identifier,
self.detected_application.as_deref().unwrap_or("unknown")
)
}
pub fn get_property_string(&self, key: &str) -> Option<String> {
match self.properties.get(key)? {
PropertyValue::String(s) => Some(s.clone()),
PropertyValue::Integer(i) => Some(i.to_string()),
PropertyValue::Real(r) => Some(r.to_string()),
PropertyValue::Boolean(b) => Some(b.to_string()),
PropertyValue::Date(d) => Some(d.clone()),
_ => None,
}
}
pub fn get_property_int(&self, key: &str) -> Option<i64> {
match self.properties.get(key)? {
PropertyValue::Integer(i) => Some(*i),
_ => None,
}
}
pub fn get_property_bool(&self, key: &str) -> Option<bool> {
match self.properties.get(key)? {
PropertyValue::Boolean(b) => Some(*b),
_ => None,
}
}
pub fn document_identifier(&self) -> Option<&str> {
self.document_id.as_deref()
}
pub fn build_version_history(&self) -> &[String] {
&self.build_versions
}
pub fn latest_build_version(&self) -> Option<&str> {
self.build_versions.last().map(|s| s.as_str())
}
}
pub fn detect_application_type<P: AsRef<Path>>(bundle_path: P) -> Result<String> {
let path = bundle_path.as_ref();
if let Some(extension) = path.extension() {
match extension.to_str() {
Some("pages") => return Ok("Pages".to_string()),
Some("key") => return Ok("Keynote".to_string()),
Some("numbers") => return Ok("Numbers".to_string()),
_ => {}
}
}
if path.is_dir() {
let index_zip = path.join("Index.zip");
if index_zip.exists() {
}
}
Ok("Unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bundle_validation() {
let bundle_path = std::path::Path::new("non_existent_bundle");
assert!(Bundle::open(bundle_path).is_err());
let bundle_path = std::path::Path::new("test.pages");
if bundle_path.exists() {
let result = Bundle::open(bundle_path);
assert!(result.is_ok(), "Failed to open test.pages: {:?}", result.err());
}
}
#[test]
fn test_bundle_parsing() {
let bundle_path = std::path::Path::new("test.pages");
if !bundle_path.exists() {
return;
}
let bundle = Bundle::open(bundle_path).expect("Failed to open test.pages");
assert!(!bundle.archives().is_empty(), "Bundle should contain archives");
assert!(bundle.get_archive("Index/Document.iwa").is_some(),
"Bundle should contain Document.iwa");
assert!(bundle.get_archive("Index/Metadata.iwa").is_some(),
"Bundle should contain Metadata.iwa");
let metadata = bundle.metadata();
assert!(metadata.has_properties || metadata.has_build_version_history,
"Bundle should have some metadata");
let text_result = bundle.extract_text();
assert!(text_result.is_ok());
}
#[test]
fn test_numbers_bundle_parsing() {
let bundle_path = std::path::Path::new("test.numbers");
if !bundle_path.exists() {
return;
}
let bundle = Bundle::open(bundle_path).expect("Failed to open test.numbers");
assert!(!bundle.archives().is_empty(), "Bundle should contain archives");
assert!(bundle.get_archive("Index/Document.iwa").is_some(),
"Bundle should contain Document.iwa");
assert!(bundle.get_archive("Index/CalculationEngine.iwa").is_some(),
"Numbers bundle should contain CalculationEngine.iwa");
}
#[test]
fn test_metadata_summary() {
let mut properties = HashMap::new();
properties.insert("Title".to_string(), PropertyValue::String("Test Doc".to_string()));
let metadata = BundleMetadata {
has_properties: true,
has_build_version_history: true,
has_document_identifier: false,
detected_application: Some("Pages".to_string()),
properties,
build_versions: vec!["7029".to_string()],
document_id: None,
};
let summary = metadata.summary();
assert!(summary.contains("Properties: true"));
assert!(summary.contains("BuildVersion: true"));
assert!(summary.contains("DocumentID: false"));
assert!(summary.contains("App: Pages"));
assert_eq!(metadata.get_property_string("Title"), Some("Test Doc".to_string()));
assert_eq!(metadata.latest_build_version(), Some("7029"));
assert_eq!(metadata.document_identifier(), None);
}
}