mod utf8_reader;
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::{File, OpenOptions};
use std::io::{BufReader, BufWriter, ErrorKind, Read, Seek, Write};
use std::path::Path;
use std::sync::{Arc, LazyLock};
use std::{fmt, io};
use parking_lot::Mutex;
use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use zip::{write::SimpleFileOptions, ZipWriter};
use symbolic_common::{Arch, AsSelf, CodeId, DebugId, SourceLinkMappings};
use self::utf8_reader::Utf8Reader;
use crate::base::*;
use crate::js::{
discover_debug_id, discover_sourcemap_embedded_debug_id, discover_sourcemaps_location,
};
static BUNDLE_MAGIC: [u8; 4] = *b"SYSB";
static BUNDLE_VERSION: u32 = 2;
static MANIFEST_PATH: &str = "manifest.json";
static FILES_PATH: &str = "files";
static SANE_PATH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r":?[/\\]+").unwrap());
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SourceBundleErrorKind {
BadZip,
BadManifest,
BadDebugFile,
WriteFailed,
ReadFailed,
}
impl fmt::Display for SourceBundleErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadZip => write!(f, "malformed zip archive"),
Self::BadManifest => write!(f, "failed to read/write source bundle manifest"),
Self::BadDebugFile => write!(f, "malformed debug info file"),
Self::WriteFailed => write!(f, "failed to write source bundle"),
Self::ReadFailed => write!(f, "file could not be read as UTF-8"),
}
}
}
#[derive(Debug, Error)]
#[error("{kind}")]
pub struct SourceBundleError {
kind: SourceBundleErrorKind,
#[source]
source: Option<Box<dyn Error + Send + Sync + 'static>>,
}
impl SourceBundleError {
pub fn new<E>(kind: SourceBundleErrorKind, source: E) -> Self
where
E: Into<Box<dyn Error + Send + Sync>>,
{
let source = Some(source.into());
Self { kind, source }
}
pub fn kind(&self) -> SourceBundleErrorKind {
self.kind
}
}
impl From<SourceBundleErrorKind> for SourceBundleError {
fn from(kind: SourceBundleErrorKind) -> Self {
Self { kind, source: None }
}
}
fn trim_end_matches<F>(string: &mut String, pat: F)
where
F: FnMut(char) -> bool,
{
let cutoff = string.trim_end_matches(pat).len();
string.truncate(cutoff);
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SourceFileType {
Source,
MinifiedSource,
SourceMap,
IndexedRamBundle,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SourceFileInfo {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
ty: Option<SourceFileType>,
#[serde(default, skip_serializing_if = "String::is_empty")]
path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
url: String,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "deserialize_headers"
)]
headers: BTreeMap<String, String>,
}
fn deserialize_headers<'de, D>(deserializer: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let rv: BTreeMap<String, String> = Deserialize::deserialize(deserializer)?;
if rv.is_empty()
|| rv
.keys()
.all(|x| !x.chars().any(|c| c.is_ascii_uppercase()))
{
Ok(rv)
} else {
Ok(rv
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect())
}
}
impl SourceFileInfo {
pub fn new() -> Self {
Self::default()
}
pub fn ty(&self) -> Option<SourceFileType> {
self.ty
}
pub fn set_ty(&mut self, ty: SourceFileType) {
self.ty = Some(ty);
}
pub fn path(&self) -> Option<&str> {
match self.path.as_str() {
"" => None,
path => Some(path),
}
}
pub fn set_path(&mut self, path: String) {
self.path = path;
}
pub fn url(&self) -> Option<&str> {
match self.url.as_str() {
"" => None,
url => Some(url),
}
}
pub fn set_url(&mut self, url: String) {
self.url = url;
}
pub fn headers(&self) -> impl Iterator<Item = (&str, &str)> {
self.headers.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn header(&self, header: &str) -> Option<&str> {
if !header.chars().any(|x| x.is_ascii_uppercase()) {
self.headers.get(header).map(String::as_str)
} else {
self.headers.iter().find_map(|(k, v)| {
if k.eq_ignore_ascii_case(header) {
Some(v.as_str())
} else {
None
}
})
}
}
pub fn add_header(&mut self, header: String, value: String) {
let mut header = header;
if header.chars().any(|x| x.is_ascii_uppercase()) {
header = header.to_ascii_lowercase();
}
self.headers.insert(header, value);
}
pub fn debug_id(&self) -> Option<DebugId> {
self.header("debug-id").and_then(|x| x.parse().ok())
}
pub fn source_mapping_url(&self) -> Option<&str> {
self.header("sourcemap")
.or_else(|| self.header("x-sourcemap"))
}
pub fn is_empty(&self) -> bool {
self.path.is_empty() && self.ty.is_none() && self.headers.is_empty()
}
}
pub struct SourceFileDescriptor<'a> {
contents: Option<Cow<'a, str>>,
remote_url: Option<Cow<'a, str>>,
file_info: Option<&'a SourceFileInfo>,
}
impl<'a> SourceFileDescriptor<'a> {
pub(crate) fn new_embedded(
content: Cow<'a, str>,
file_info: Option<&'a SourceFileInfo>,
) -> SourceFileDescriptor<'a> {
SourceFileDescriptor {
contents: Some(content),
remote_url: None,
file_info,
}
}
pub(crate) fn new_remote(remote_url: Cow<'a, str>) -> SourceFileDescriptor<'a> {
SourceFileDescriptor {
contents: None,
remote_url: Some(remote_url),
file_info: None,
}
}
pub fn ty(&self) -> SourceFileType {
self.file_info
.and_then(|x| x.ty())
.unwrap_or(SourceFileType::Source)
}
pub fn contents(&self) -> Option<&str> {
self.contents.as_deref()
}
pub fn into_contents(self) -> Option<Cow<'a, str>> {
self.contents
}
pub fn url(&self) -> Option<&str> {
if let Some(ref url) = self.remote_url {
Some(url)
} else {
self.file_info.and_then(|x| x.url())
}
}
pub fn path(&self) -> Option<&str> {
self.file_info.and_then(|x| x.path())
}
pub fn debug_id(&self) -> Option<DebugId> {
self.file_info.and_then(|x| x.debug_id()).or_else(|| {
if matches!(
self.ty(),
SourceFileType::Source | SourceFileType::MinifiedSource
) {
self.contents().and_then(discover_debug_id)
} else if matches!(self.ty(), SourceFileType::SourceMap) {
self.contents()
.and_then(discover_sourcemap_embedded_debug_id)
} else {
None
}
})
}
pub fn source_mapping_url(&self) -> Option<&str> {
self.file_info
.and_then(|x| x.source_mapping_url())
.or_else(|| {
if matches!(
self.ty(),
SourceFileType::Source | SourceFileType::MinifiedSource
) {
self.contents().and_then(discover_sourcemaps_location)
} else {
None
}
})
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct SourceBundleVersion(pub u32);
impl SourceBundleVersion {
pub fn new(version: u32) -> Self {
Self(version)
}
pub fn is_valid(self) -> bool {
self.0 <= BUNDLE_VERSION
}
pub fn is_latest(self) -> bool {
self.0 == BUNDLE_VERSION
}
}
impl Default for SourceBundleVersion {
fn default() -> Self {
Self(BUNDLE_VERSION)
}
}
#[repr(C, packed)]
#[derive(Clone, Copy, Debug)]
struct SourceBundleHeader {
pub magic: [u8; 4],
pub version: u32,
}
impl SourceBundleHeader {
fn as_bytes(&self) -> &[u8] {
let ptr = self as *const Self as *const u8;
unsafe { std::slice::from_raw_parts(ptr, std::mem::size_of::<Self>()) }
}
}
impl Default for SourceBundleHeader {
fn default() -> Self {
SourceBundleHeader {
magic: BUNDLE_MAGIC,
version: BUNDLE_VERSION,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct SourceBundleManifest {
#[serde(default)]
pub files: BTreeMap<String, SourceFileInfo>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub source_links: BTreeMap<String, String>,
#[serde(flatten)]
pub attributes: BTreeMap<String, String>,
}
struct SourceBundleIndex<'data> {
manifest: SourceBundleManifest,
indexed_files: HashMap<FileKey<'data>, Arc<String>>,
}
impl<'data> SourceBundleIndex<'data> {
pub fn parse(
archive: &mut zip::read::ZipArchive<std::io::Cursor<&'data [u8]>>,
) -> Result<Self, SourceBundleError> {
let manifest_file = archive
.by_name("manifest.json")
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadZip, e))?;
let manifest: SourceBundleManifest = serde_json::from_reader(manifest_file)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadManifest, e))?;
let files = &manifest.files;
let mut indexed_files = HashMap::with_capacity(files.len());
for (zip_path, file_info) in files {
let zip_path = Arc::new(zip_path.clone());
if !file_info.path.is_empty() {
indexed_files.insert(
FileKey::Path(normalize_path(&file_info.path).into()),
zip_path.clone(),
);
}
if !file_info.url.is_empty() {
indexed_files.insert(FileKey::Url(file_info.url.clone().into()), zip_path.clone());
}
if let (Some(debug_id), Some(ty)) = (file_info.debug_id(), file_info.ty()) {
indexed_files.insert(FileKey::DebugId(debug_id, ty), zip_path.clone());
}
}
Ok(Self {
manifest,
indexed_files,
})
}
}
pub struct SourceBundle<'data> {
data: &'data [u8],
archive: zip::read::ZipArchive<std::io::Cursor<&'data [u8]>>,
index: Arc<SourceBundleIndex<'data>>,
}
impl fmt::Debug for SourceBundle<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SourceBundle")
.field("code_id", &self.code_id())
.field("debug_id", &self.debug_id())
.field("arch", &self.arch())
.field("kind", &self.kind())
.field("load_address", &format_args!("{:#x}", self.load_address()))
.field("has_symbols", &self.has_symbols())
.field("has_debug_info", &self.has_debug_info())
.field("has_unwind_info", &self.has_unwind_info())
.field("has_sources", &self.has_sources())
.field("is_malformed", &self.is_malformed())
.finish()
}
}
impl<'data> SourceBundle<'data> {
pub fn test(bytes: &[u8]) -> bool {
bytes.starts_with(&BUNDLE_MAGIC)
}
pub fn parse(data: &'data [u8]) -> Result<SourceBundle<'data>, SourceBundleError> {
let mut archive = zip::read::ZipArchive::new(std::io::Cursor::new(data))
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadZip, e))?;
let index = Arc::new(SourceBundleIndex::parse(&mut archive)?);
Ok(SourceBundle {
archive,
data,
index,
})
}
pub fn version(&self) -> SourceBundleVersion {
SourceBundleVersion(BUNDLE_VERSION)
}
pub fn file_format(&self) -> FileFormat {
FileFormat::SourceBundle
}
pub fn code_id(&self) -> Option<CodeId> {
self.index
.manifest
.attributes
.get("code_id")
.and_then(|x| x.parse().ok())
}
pub fn debug_id(&self) -> DebugId {
self.index
.manifest
.attributes
.get("debug_id")
.and_then(|x| x.parse().ok())
.unwrap_or_default()
}
pub fn name(&self) -> Option<&str> {
self.index
.manifest
.attributes
.get("object_name")
.map(|x| x.as_str())
}
pub fn arch(&self) -> Arch {
self.index
.manifest
.attributes
.get("arch")
.and_then(|s| s.parse().ok())
.unwrap_or_default()
}
fn kind(&self) -> ObjectKind {
ObjectKind::Sources
}
pub fn load_address(&self) -> u64 {
0
}
pub fn has_symbols(&self) -> bool {
false
}
pub fn symbols(&self) -> SourceBundleSymbolIterator<'data> {
std::iter::empty()
}
pub fn symbol_map(&self) -> SymbolMap<'data> {
self.symbols().collect()
}
pub fn has_debug_info(&self) -> bool {
false
}
pub fn debug_session(&self) -> Result<SourceBundleDebugSession<'data>, SourceBundleError> {
let archive = Mutex::new(self.archive.clone());
let source_links = SourceLinkMappings::new(
self.index
.manifest
.source_links
.iter()
.map(|(k, v)| (&k[..], &v[..])),
);
Ok(SourceBundleDebugSession {
index: Arc::clone(&self.index),
archive,
source_links,
})
}
pub fn has_unwind_info(&self) -> bool {
false
}
pub fn has_sources(&self) -> bool {
true
}
pub fn is_malformed(&self) -> bool {
false
}
pub fn data(&self) -> &'data [u8] {
self.data
}
pub fn is_empty(&self) -> bool {
self.index.manifest.files.is_empty()
}
}
impl<'slf, 'data: 'slf> AsSelf<'slf> for SourceBundle<'data> {
type Ref = SourceBundle<'slf>;
fn as_self(&'slf self) -> &'slf Self::Ref {
unsafe { std::mem::transmute(self) }
}
}
impl<'data> Parse<'data> for SourceBundle<'data> {
type Error = SourceBundleError;
fn parse(data: &'data [u8]) -> Result<Self, Self::Error> {
SourceBundle::parse(data)
}
fn test(data: &'data [u8]) -> bool {
SourceBundle::test(data)
}
}
impl<'data: 'object, 'object> ObjectLike<'data, 'object> for SourceBundle<'data> {
type Error = SourceBundleError;
type Session = SourceBundleDebugSession<'data>;
type SymbolIterator = SourceBundleSymbolIterator<'data>;
fn file_format(&self) -> FileFormat {
self.file_format()
}
fn code_id(&self) -> Option<CodeId> {
self.code_id()
}
fn debug_id(&self) -> DebugId {
self.debug_id()
}
fn arch(&self) -> Arch {
self.arch()
}
fn kind(&self) -> ObjectKind {
self.kind()
}
fn load_address(&self) -> u64 {
self.load_address()
}
fn has_symbols(&self) -> bool {
self.has_symbols()
}
fn symbol_map(&self) -> SymbolMap<'data> {
self.symbol_map()
}
fn symbols(&self) -> Self::SymbolIterator {
self.symbols()
}
fn has_debug_info(&self) -> bool {
self.has_debug_info()
}
fn debug_session(&self) -> Result<Self::Session, Self::Error> {
self.debug_session()
}
fn has_unwind_info(&self) -> bool {
self.has_unwind_info()
}
fn has_sources(&self) -> bool {
self.has_sources()
}
fn is_malformed(&self) -> bool {
self.is_malformed()
}
}
pub type SourceBundleSymbolIterator<'data> = std::iter::Empty<Symbol<'data>>;
#[derive(Debug, Hash, PartialEq, Eq)]
enum FileKey<'a> {
Path(Cow<'a, str>),
Url(Cow<'a, str>),
DebugId(DebugId, SourceFileType),
}
pub struct SourceBundleDebugSession<'data> {
archive: Mutex<zip::read::ZipArchive<std::io::Cursor<&'data [u8]>>>,
index: Arc<SourceBundleIndex<'data>>,
source_links: SourceLinkMappings,
}
impl SourceBundleDebugSession<'_> {
pub fn files(&self) -> SourceBundleFileIterator<'_> {
SourceBundleFileIterator {
files: self.index.manifest.files.values(),
}
}
pub fn functions(&self) -> SourceBundleFunctionIterator<'_> {
std::iter::empty()
}
fn source_by_zip_path(&self, zip_path: &str) -> Result<String, SourceBundleError> {
let mut archive = self.archive.lock();
let mut file = archive
.by_name(zip_path)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadZip, e))?;
let mut source_content = String::new();
file.read_to_string(&mut source_content)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadZip, e))?;
Ok(source_content)
}
fn get_source_file_descriptor(
&self,
key: FileKey,
) -> Result<Option<SourceFileDescriptor<'_>>, SourceBundleError> {
if let Some(zip_path) = self.index.indexed_files.get(&key) {
let zip_path = zip_path.as_str();
let content = Cow::Owned(self.source_by_zip_path(zip_path)?);
let info = self.index.manifest.files.get(zip_path);
let descriptor = SourceFileDescriptor::new_embedded(content, info);
return Ok(Some(descriptor));
}
let FileKey::Path(path) = key else {
return Ok(None);
};
Ok(self
.source_links
.resolve(&path)
.map(|s| SourceFileDescriptor::new_remote(s.into())))
}
pub fn source_by_path(
&self,
path: &str,
) -> Result<Option<SourceFileDescriptor<'_>>, SourceBundleError> {
self.get_source_file_descriptor(FileKey::Path(normalize_path(path).into()))
}
pub fn source_by_url(
&self,
url: &str,
) -> Result<Option<SourceFileDescriptor<'_>>, SourceBundleError> {
self.get_source_file_descriptor(FileKey::Url(url.into()))
}
pub fn source_by_debug_id(
&self,
debug_id: DebugId,
ty: SourceFileType,
) -> Result<Option<SourceFileDescriptor<'_>>, SourceBundleError> {
self.get_source_file_descriptor(FileKey::DebugId(debug_id, ty))
}
}
impl<'session> DebugSession<'session> for SourceBundleDebugSession<'_> {
type Error = SourceBundleError;
type FunctionIterator = SourceBundleFunctionIterator<'session>;
type FileIterator = SourceBundleFileIterator<'session>;
fn functions(&'session self) -> Self::FunctionIterator {
self.functions()
}
fn files(&'session self) -> Self::FileIterator {
self.files()
}
fn source_by_path(&self, path: &str) -> Result<Option<SourceFileDescriptor<'_>>, Self::Error> {
self.source_by_path(path)
}
}
impl<'slf, 'data: 'slf> AsSelf<'slf> for SourceBundleDebugSession<'data> {
type Ref = SourceBundleDebugSession<'slf>;
fn as_self(&'slf self) -> &'slf Self::Ref {
unsafe { std::mem::transmute(self) }
}
}
pub struct SourceBundleFileIterator<'s> {
files: std::collections::btree_map::Values<'s, String, SourceFileInfo>,
}
impl<'s> Iterator for SourceBundleFileIterator<'s> {
type Item = Result<FileEntry<'s>, SourceBundleError>;
fn next(&mut self) -> Option<Self::Item> {
let source_file = self.files.next()?;
Some(Ok(FileEntry::new(
Cow::default(),
FileInfo::from_path(source_file.path.as_bytes()),
)))
}
}
pub type SourceBundleFunctionIterator<'s> =
std::iter::Empty<Result<Function<'s>, SourceBundleError>>;
impl SourceBundleManifest {
pub fn new() -> Self {
Self::default()
}
}
fn sanitize_bundle_path(path: &str) -> String {
let mut sanitized = SANE_PATH_RE.replace_all(path, "/").into_owned();
if sanitized.starts_with('/') {
sanitized.remove(0);
}
sanitized
}
fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
#[derive(Debug)]
pub struct SkippedFileInfo<'a> {
path: &'a str,
reason: &'a str,
}
impl<'a> SkippedFileInfo<'a> {
fn new(path: &'a str, reason: &'a str) -> Self {
Self { path, reason }
}
pub fn path(&self) -> &str {
self.path
}
pub fn reason(&self) -> &str {
self.reason
}
}
impl Display for SkippedFileInfo<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "Skipped file {} due to: {}", self.path, self.reason)
}
}
pub struct SourceBundleWriter<W>
where
W: Seek + Write,
{
manifest: SourceBundleManifest,
writer: ZipWriter<W>,
collect_il2cpp: bool,
skipped_file_callback: Box<dyn FnMut(SkippedFileInfo)>,
}
fn default_file_options() -> SimpleFileOptions {
SimpleFileOptions::default().last_modified_time(zip::DateTime::default())
}
impl<W> SourceBundleWriter<W>
where
W: Seek + Write,
{
pub fn start(mut writer: W) -> Result<Self, SourceBundleError> {
let header = SourceBundleHeader::default();
writer
.write_all(header.as_bytes())
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
Ok(SourceBundleWriter {
manifest: SourceBundleManifest::new(),
writer: ZipWriter::new(writer),
collect_il2cpp: false,
skipped_file_callback: Box::new(|_| ()),
})
}
pub fn is_empty(&self) -> bool {
self.manifest.files.is_empty()
}
pub fn collect_il2cpp_sources(&mut self, collect_il2cpp: bool) {
self.collect_il2cpp = collect_il2cpp;
}
pub fn set_attribute<K, V>(&mut self, key: K, value: V) -> Option<String>
where
K: Into<String>,
V: Into<String>,
{
self.manifest.attributes.insert(key.into(), value.into())
}
pub fn remove_attribute<K>(&mut self, key: K) -> Option<String>
where
K: AsRef<str>,
{
self.manifest.attributes.remove(key.as_ref())
}
pub fn attribute<K>(&mut self, key: K) -> Option<&str>
where
K: AsRef<str>,
{
self.manifest
.attributes
.get(key.as_ref())
.map(String::as_str)
}
pub fn has_file<S>(&self, path: S) -> bool
where
S: AsRef<str>,
{
let full_path = &self.file_path(path.as_ref());
self.manifest.files.contains_key(full_path)
}
pub fn add_file<S, R>(
&mut self,
path: S,
file: R,
info: SourceFileInfo,
) -> Result<(), SourceBundleError>
where
S: AsRef<str>,
R: Read,
{
let mut file_reader = Utf8Reader::new(file);
let full_path = self.file_path(path.as_ref());
let unique_path = self.unique_path(full_path);
self.writer
.start_file(unique_path.clone(), default_file_options())
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
match io::copy(&mut file_reader, &mut self.writer) {
Err(e) => {
self.writer
.abort_file()
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
let error_kind = match e.kind() {
ErrorKind::InvalidData => SourceBundleErrorKind::ReadFailed,
_ => SourceBundleErrorKind::WriteFailed,
};
Err(SourceBundleError::new(error_kind, e))
}
Ok(_) => {
self.manifest.files.insert(unique_path, info);
Ok(())
}
}
}
fn add_file_skip_read_failed<S, R>(
&mut self,
path: S,
file: R,
info: SourceFileInfo,
) -> Result<(), SourceBundleError>
where
S: AsRef<str>,
R: Read,
{
let result = self.add_file(&path, file, info);
if let Err(e) = &result {
if e.kind == SourceBundleErrorKind::ReadFailed {
let reason = e.to_string();
let skipped_info = SkippedFileInfo::new(path.as_ref(), &reason);
(self.skipped_file_callback)(skipped_info);
return Ok(());
}
}
result
}
pub fn with_skipped_file_callback(
mut self,
callback: impl FnMut(SkippedFileInfo) + 'static,
) -> Self {
self.skipped_file_callback = Box::new(callback);
self
}
pub fn write_object<'data, 'object, O, E>(
self,
object: &'object O,
object_name: &str,
) -> Result<bool, SourceBundleError>
where
O: ObjectLike<'data, 'object, Error = E>,
E: std::error::Error + Send + Sync + 'static,
{
self.write_object_with_filter(object, object_name, |_, _| true)
}
pub fn write_object_with_filter<'data, 'object, O, E, F>(
mut self,
object: &'object O,
object_name: &str,
mut filter: F,
) -> Result<bool, SourceBundleError>
where
O: ObjectLike<'data, 'object, Error = E>,
E: std::error::Error + Send + Sync + 'static,
F: FnMut(&FileEntry, &Option<SourceFileDescriptor<'_>>) -> bool,
{
let mut files_handled = BTreeSet::new();
let mut referenced_files = BTreeSet::new();
let session = object
.debug_session()
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadDebugFile, e))?;
self.set_attribute("arch", object.arch().to_string());
self.set_attribute("debug_id", object.debug_id().to_string());
self.set_attribute("object_name", object_name);
if let Some(code_id) = object.code_id() {
self.set_attribute("code_id", code_id.to_string());
}
for file_result in session.files() {
let file = file_result
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadDebugFile, e))?;
let filename = file.abs_path_str();
if files_handled.contains(&filename) {
continue;
}
let source = if filename.starts_with('<') && filename.ends_with('>') {
None
} else {
let source_from_object = session
.source_by_path(&filename)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadDebugFile, e))?;
if filter(&file, &source_from_object) {
std::fs::read(&filename).ok()
} else {
None
}
};
if let Some(source) = source {
let bundle_path = sanitize_bundle_path(&filename);
let mut info = SourceFileInfo::new();
info.set_ty(SourceFileType::Source);
info.set_path(filename.clone());
if self.collect_il2cpp {
collect_il2cpp_sources(&source, &mut referenced_files);
}
self.add_file_skip_read_failed(bundle_path, source.as_slice(), info)?;
}
files_handled.insert(filename);
}
for filename in referenced_files {
if files_handled.contains(&filename) {
continue;
}
if let Some(source) = File::open(&filename).ok().map(BufReader::new) {
let bundle_path = sanitize_bundle_path(&filename);
let mut info = SourceFileInfo::new();
info.set_ty(SourceFileType::Source);
info.set_path(filename.clone());
self.add_file_skip_read_failed(bundle_path, source, info)?
}
}
let is_empty = self.is_empty();
self.finish()?;
Ok(!is_empty)
}
pub fn finish(mut self) -> Result<(), SourceBundleError> {
self.write_manifest()?;
self.writer
.finish()
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
Ok(())
}
fn file_path(&self, path: &str) -> String {
format!("{FILES_PATH}/{path}")
}
fn unique_path(&self, mut path: String) -> String {
let mut duplicates = 0;
while self.manifest.files.contains_key(&path) {
duplicates += 1;
match duplicates {
1 => path.push_str(".1"),
_ => {
use std::fmt::Write;
trim_end_matches(&mut path, char::is_numeric);
write!(path, ".{duplicates}").unwrap();
}
}
}
path
}
fn write_manifest(&mut self) -> Result<(), SourceBundleError> {
self.writer
.start_file(MANIFEST_PATH, default_file_options())
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
serde_json::to_writer(&mut self.writer, &self.manifest)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::BadManifest, e))?;
Ok(())
}
}
fn collect_il2cpp_sources(source: &[u8], referenced_files: &mut BTreeSet<String>) {
if let Ok(source) = std::str::from_utf8(source) {
for line in source.lines() {
let line = line.trim();
if let Some(source_ref) = line.strip_prefix("//<source_info:") {
if let Some((file, _line)) = source_ref.rsplit_once(':') {
if !referenced_files.contains(file) {
referenced_files.insert(file.to_string());
}
}
}
}
}
}
impl SourceBundleWriter<BufWriter<File>> {
pub fn create<P>(path: P) -> Result<SourceBundleWriter<BufWriter<File>>, SourceBundleError>
where
P: AsRef<Path>,
{
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|e| SourceBundleError::new(SourceBundleErrorKind::WriteFailed, e))?;
Self::start(BufWriter::new(file))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use similar_asserts::assert_eq;
use tempfile::NamedTempFile;
#[test]
fn test_has_file() -> Result<(), SourceBundleError> {
let writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(writer)?;
bundle.add_file("bar.txt", &b"filecontents"[..], SourceFileInfo::default())?;
assert!(bundle.has_file("bar.txt"));
bundle.finish()?;
Ok(())
}
#[test]
fn test_non_utf8() -> Result<(), SourceBundleError> {
let writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(writer)?;
assert!(bundle
.add_file(
"bar.txt",
&[0, 159, 146, 150][..],
SourceFileInfo::default()
)
.is_err());
Ok(())
}
#[test]
fn test_duplicate_files() -> Result<(), SourceBundleError> {
let writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(writer)?;
bundle.add_file("bar.txt", &b"filecontents"[..], SourceFileInfo::default())?;
bundle.add_file("bar.txt", &b"othercontents"[..], SourceFileInfo::default())?;
assert!(bundle.has_file("bar.txt"));
assert!(bundle.has_file("bar.txt.1"));
bundle.finish()?;
Ok(())
}
#[test]
fn debugsession_is_sendsync() {
fn is_sendsync<T: Send + Sync>() {}
is_sendsync::<SourceBundleDebugSession>();
}
#[test]
fn test_normalize_paths() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
for filename in &[
"C:\\users\\martin\\mydebugfile.cs",
"/usr/martin/mydebugfile.h",
] {
let mut info = SourceFileInfo::new();
info.set_ty(SourceFileType::Source);
info.set_path(filename.to_string());
bundle.add_file_skip_read_failed(
sanitize_bundle_path(filename),
&b"somerandomdata"[..],
info,
)?;
}
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let session = bundle.debug_session().unwrap();
assert!(session
.source_by_path("C:\\users\\martin\\mydebugfile.cs")?
.is_some());
assert!(session
.source_by_path("C:/users/martin/mydebugfile.cs")?
.is_some());
assert!(session
.source_by_path("C:\\users\\martin/mydebugfile.cs")?
.is_some());
assert!(session
.source_by_path("/usr/martin/mydebugfile.h")?
.is_some());
assert!(session
.source_by_path("\\usr\\martin\\mydebugfile.h")?
.is_some());
Ok(())
}
#[test]
fn test_source_descriptor() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let mut info = SourceFileInfo::default();
info.set_url("https://example.com/bar.js.min".into());
info.set_path("/files/bar.js.min".into());
info.set_ty(SourceFileType::MinifiedSource);
info.add_header(
"debug-id".into(),
"5e618b9f-54a9-4389-b196-519819dd7c47".into(),
);
info.add_header("sourcemap".into(), "bar.js.map".into());
bundle.add_file("bar.js", &b"filecontents"[..], info)?;
assert!(bundle.has_file("bar.js"));
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let sess = bundle.debug_session().unwrap();
let f = sess
.source_by_debug_id(
"5e618b9f-54a9-4389-b196-519819dd7c47".parse().unwrap(),
SourceFileType::MinifiedSource,
)
.unwrap()
.expect("should exist");
assert_eq!(f.contents(), Some("filecontents"));
assert_eq!(f.ty(), SourceFileType::MinifiedSource);
assert_eq!(f.url(), Some("https://example.com/bar.js.min"));
assert_eq!(f.path(), Some("/files/bar.js.min"));
assert_eq!(f.source_mapping_url(), Some("bar.js.map"));
assert!(sess
.source_by_debug_id(
"5e618b9f-54a9-4389-b196-519819dd7c47".parse().unwrap(),
SourceFileType::Source
)
.unwrap()
.is_none());
Ok(())
}
#[test]
fn test_source_mapping_url() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let mut info = SourceFileInfo::default();
info.set_url("https://example.com/bar.min.js".into());
info.set_ty(SourceFileType::MinifiedSource);
bundle.add_file(
"bar.js",
&b"filecontents\n//# sourceMappingURL=bar.js.map"[..],
info,
)?;
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let sess = bundle.debug_session().unwrap();
let f = sess
.source_by_url("https://example.com/bar.min.js")
.unwrap()
.expect("should exist");
assert_eq!(f.ty(), SourceFileType::MinifiedSource);
assert_eq!(f.url(), Some("https://example.com/bar.min.js"));
assert_eq!(f.source_mapping_url(), Some("bar.js.map"));
Ok(())
}
#[test]
fn test_source_embedded_debug_id() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let mut info = SourceFileInfo::default();
info.set_url("https://example.com/bar.min.js".into());
info.set_ty(SourceFileType::MinifiedSource);
bundle.add_file(
"bar.js",
&b"filecontents\n//# debugId=5b65abfb23384f0bb3b964c8f734d43f"[..],
info,
)?;
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let sess = bundle.debug_session().unwrap();
let f = sess
.source_by_url("https://example.com/bar.min.js")
.unwrap()
.expect("should exist");
assert_eq!(f.ty(), SourceFileType::MinifiedSource);
assert_eq!(
f.debug_id(),
Some("5b65abfb-2338-4f0b-b3b9-64c8f734d43f".parse().unwrap())
);
Ok(())
}
#[test]
fn test_sourcemap_embedded_debug_id() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let mut info = SourceFileInfo::default();
info.set_url("https://example.com/bar.js.map".into());
info.set_ty(SourceFileType::SourceMap);
bundle.add_file(
"bar.js.map",
&br#"{"debug_id": "5b65abfb-2338-4f0b-b3b9-64c8f734d43f"}"#[..],
info,
)?;
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let sess = bundle.debug_session().unwrap();
let f = sess
.source_by_url("https://example.com/bar.js.map")
.unwrap()
.expect("should exist");
assert_eq!(f.ty(), SourceFileType::SourceMap);
assert_eq!(
f.debug_id(),
Some("5b65abfb-2338-4f0b-b3b9-64c8f734d43f".parse().unwrap())
);
Ok(())
}
#[test]
fn test_il2cpp_reference() -> Result<(), Box<dyn std::error::Error>> {
let mut cpp_file = NamedTempFile::new()?;
let mut cs_file = NamedTempFile::new()?;
let cpp_contents = format!("foo\n//<source_info:{}:111>\nbar", cs_file.path().display());
let object_buf = {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let path = cpp_file.path().to_string_lossy();
let mut info = SourceFileInfo::new();
info.set_ty(SourceFileType::Source);
info.set_path(path.to_string());
bundle.add_file(path, cpp_contents.as_bytes(), info)?;
bundle.finish()?;
writer.into_inner()
};
let object = SourceBundle::parse(&object_buf)?;
cpp_file.write_all(cpp_contents.as_bytes())?;
cs_file.write_all(b"some C# source")?;
let mut output_buf = Cursor::new(Vec::new());
let mut writer = SourceBundleWriter::start(&mut output_buf)?;
writer.collect_il2cpp_sources(true);
let written = writer.write_object(&object, "whatever")?;
assert!(written);
let output_buf = output_buf.into_inner();
let source_bundle = SourceBundle::parse(&output_buf)?;
let session = source_bundle.debug_session()?;
let actual_files: BTreeMap<_, _> = session
.files()
.flatten()
.flat_map(|f| {
let path = f.abs_path_str();
session
.source_by_path(&path)
.ok()
.flatten()
.map(|source| (path, source.contents().unwrap().to_string()))
})
.collect();
let mut expected_files = BTreeMap::new();
expected_files.insert(cpp_file.path().to_string_lossy().into_owned(), cpp_contents);
expected_files.insert(
cs_file.path().to_string_lossy().into_owned(),
String::from("some C# source"),
);
assert_eq!(actual_files, expected_files);
Ok(())
}
#[test]
fn test_bundle_paths() {
assert_eq!(sanitize_bundle_path("foo"), "foo");
assert_eq!(sanitize_bundle_path("foo/bar"), "foo/bar");
assert_eq!(sanitize_bundle_path("/foo/bar"), "foo/bar");
assert_eq!(sanitize_bundle_path("C:/foo/bar"), "C/foo/bar");
assert_eq!(sanitize_bundle_path("\\foo\\bar"), "foo/bar");
assert_eq!(sanitize_bundle_path("\\\\UNC\\foo\\bar"), "UNC/foo/bar");
}
#[test]
fn test_source_links() -> Result<(), SourceBundleError> {
let mut writer = Cursor::new(Vec::new());
let mut bundle = SourceBundleWriter::start(&mut writer)?;
let mut info = SourceFileInfo::default();
info.set_url("https://example.com/bar/index.min.js".into());
info.set_path("/files/bar/index.min.js".into());
info.set_ty(SourceFileType::MinifiedSource);
bundle.add_file("bar/index.js", &b"filecontents"[..], info)?;
assert!(bundle.has_file("bar/index.js"));
bundle
.manifest
.source_links
.insert("/files/bar/*".to_string(), "https://nope.com/*".into());
bundle
.manifest
.source_links
.insert("/files/foo/*".to_string(), "https://example.com/*".into());
bundle.finish()?;
let bundle_bytes = writer.into_inner();
let bundle = SourceBundle::parse(&bundle_bytes)?;
let sess = bundle.debug_session().unwrap();
let foo = sess
.source_by_path("/files/foo/index.min.js")
.unwrap()
.expect("should exist");
assert_eq!(foo.contents(), None);
assert_eq!(foo.ty(), SourceFileType::Source);
assert_eq!(foo.url(), Some("https://example.com/index.min.js"));
assert_eq!(foo.path(), None);
let bar = sess
.source_by_path("/files/bar/index.min.js")
.unwrap()
.expect("should exist");
assert_eq!(bar.contents(), Some("filecontents"));
assert_eq!(bar.ty(), SourceFileType::MinifiedSource);
assert_eq!(bar.url(), Some("https://example.com/bar/index.min.js"));
assert_eq!(bar.path(), Some("/files/bar/index.min.js"));
Ok(())
}
}