use std::{io::Read as _, path::Path};
use flate2::read::GzDecoder;
use quick_xml::Reader;
use quick_xml::escape::unescape;
use quick_xml::events::Event;
use tar::Archive;
use crate::error::FetchError;
#[derive(Debug, Clone)]
pub struct PlistInfo {
pub identifier: String,
pub name: String,
pub index_file: String,
}
pub fn read_dsidx_entries(
dsidx_path: &Path,
) -> Result<Vec<(String, String, String)>, rusqlite::Error> {
let conn = rusqlite::Connection::open(dsidx_path)?;
let mut stmt = conn.prepare("SELECT name, type, path FROM searchIndex")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
rows.collect()
}
fn parse_plist(xml: &str) -> Result<PlistInfo, FetchError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut current_tag: Vec<u8> = Vec::new();
let mut current_key = String::new();
let mut identifier = String::new();
let mut name = String::new();
let mut index_file = String::new();
loop {
match reader.read_event()? {
Event::Start(e) => {
current_tag = e.name().as_ref().to_vec();
}
Event::Text(e) => {
let decoded = e.decode().map_err(quick_xml::Error::from)?;
let text = unescape(&decoded)
.map_err(quick_xml::Error::from)?
.into_owned();
match current_tag.as_slice() {
b"key" => current_key = text,
b"string" => match current_key.as_str() {
"CFBundleIdentifier" => identifier = text,
"CFBundleName" => name = text,
"dashIndexFilePath" => index_file = text,
_ => {}
},
_ => {}
}
}
Event::End(_) | Event::Empty(_) => current_tag.clear(),
Event::Eof => break,
_ => {}
}
}
if identifier.is_empty() || name.is_empty() {
return Err(FetchError::PlistParse(
"missing required fields (CFBundleIdentifier or CFBundleName)".into(),
));
}
Ok(PlistInfo {
identifier,
name,
index_file,
})
}
fn doc_relative_path(full_path: &str) -> &str {
const MARKER: &str = "Documents/";
full_path
.find(MARKER)
.map(|i| &full_path[i + MARKER.len()..])
.unwrap_or(full_path)
}
fn is_safe_archive_path(path: &str) -> bool {
if path.is_empty() || path.starts_with('/') || path.starts_with('\\') {
return false;
}
if path.len() >= 2 && path.as_bytes()[1] == b':' {
return false;
}
!path.split(['/', '\\']).any(|segment| segment == "..")
}
pub fn extract_docset<F>(
path: &Path,
mut on_html: F,
) -> Result<(PlistInfo, Option<Vec<u8>>), FetchError>
where
F: FnMut(&str, &[u8]),
{
let file = std::fs::File::open(path)?;
let gz = GzDecoder::new(file);
let mut archive = Archive::new(gz);
let mut plist: Option<PlistInfo> = None;
let mut dsidx: Option<Vec<u8>> = None;
for entry in archive.entries()? {
let mut entry = entry?;
let path_str = entry.path()?.to_string_lossy().into_owned();
if !is_safe_archive_path(&path_str) {
tracing::warn!(path = %path_str, "skipping unsafe archive entry");
continue;
}
if path_str.ends_with("Contents/Info.plist") {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
plist = Some(parse_plist(&String::from_utf8_lossy(&buf))?);
} else if path_str.ends_with(".dsidx") {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
dsidx = Some(buf);
} else if path_str.contains("Documents/")
&& (path_str.ends_with(".html") || path_str.ends_with(".htm"))
{
let rel = doc_relative_path(&path_str).to_owned();
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
on_html(&rel, &buf);
}
}
let info =
plist.ok_or_else(|| FetchError::PlistParse("Info.plist not found in archive".into()))?;
Ok((info, dsidx))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>rust</string>
<key>CFBundleName</key>
<string>Rust</string>
<key>dashIndexFilePath</key>
<string>doc.rust-lang.org/1.94.0/book/ch00-00-introduction.html</string>
<key>DocSetPlatformFamily</key>
<string>rust</string>
<key>isDashDocset</key>
<true/>
<key>isJavaScriptEnabled</key>
<true/>
</dict>
</plist>"#;
#[test]
fn plist_identifier() {
let info = parse_plist(SAMPLE_PLIST).unwrap();
assert_eq!(info.identifier, "rust");
}
#[test]
fn plist_name() {
let info = parse_plist(SAMPLE_PLIST).unwrap();
assert_eq!(info.name, "Rust");
}
#[test]
fn plist_index_file() {
let info = parse_plist(SAMPLE_PLIST).unwrap();
assert_eq!(
info.index_file,
"doc.rust-lang.org/1.94.0/book/ch00-00-introduction.html"
);
}
#[test]
fn plist_missing_identifier_returns_error() {
let xml = r#"<plist><dict>
<key>CFBundleName</key><string>Rust</string>
</dict></plist>"#;
assert!(parse_plist(xml).is_err());
}
#[test]
fn plist_missing_name_returns_error() {
let xml = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>rust</string>
</dict></plist>"#;
assert!(parse_plist(xml).is_err());
}
#[test]
fn plist_empty_index_file_is_allowed() {
let xml = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>rust</string>
<key>CFBundleName</key><string>Rust</string>
</dict></plist>"#;
let info = parse_plist(xml).unwrap();
assert!(info.index_file.is_empty());
}
#[test]
fn relative_path_strips_prefix() {
let full = "Rust.docset/Contents/Resources/Documents/book/ch01.html";
assert_eq!(doc_relative_path(full), "book/ch01.html");
}
#[test]
fn relative_path_top_level_file() {
let full = "Rust.docset/Contents/Resources/Documents/index.html";
assert_eq!(doc_relative_path(full), "index.html");
}
#[test]
fn relative_path_no_marker_returns_full() {
let full = "some/other/path/file.html";
assert_eq!(doc_relative_path(full), full);
}
#[test]
fn safe_path_allows_normal_docset_entries() {
assert!(is_safe_archive_path(
"Rust.docset/Contents/Resources/Documents/book/ch01.html"
));
assert!(is_safe_archive_path("Rust.docset/Contents/Info.plist"));
assert!(is_safe_archive_path("a/b/c"));
}
#[test]
fn safe_path_rejects_absolute_unix() {
assert!(!is_safe_archive_path("/etc/passwd"));
}
#[test]
fn safe_path_rejects_absolute_windows_backslash() {
assert!(!is_safe_archive_path("\\Windows\\system32"));
}
#[test]
fn safe_path_rejects_windows_drive_letter() {
assert!(!is_safe_archive_path("C:/Windows/system32"));
assert!(!is_safe_archive_path("D:\\evil.exe"));
}
#[test]
fn safe_path_rejects_parent_dir_segments() {
assert!(!is_safe_archive_path("../etc/passwd"));
assert!(!is_safe_archive_path("foo/../../etc/passwd"));
assert!(!is_safe_archive_path("foo\\..\\bar"));
assert!(!is_safe_archive_path(".."));
}
#[test]
fn safe_path_rejects_empty() {
assert!(!is_safe_archive_path(""));
}
#[test]
fn safe_path_allows_dot_segments() {
assert!(is_safe_archive_path("./foo/bar"));
}
fn make_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
use flate2::{Compression, write::GzEncoder};
let buf = Vec::new();
let gz = GzEncoder::new(buf, Compression::default());
let mut ar = tar::Builder::new(gz);
for (path, data) in files {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
ar.append_data(&mut header, path, *data).unwrap();
}
let gz = ar.into_inner().unwrap();
gz.finish().unwrap()
}
#[test]
fn extract_docset_parses_plist_and_html() {
use std::io::Write as _;
let tgz = make_tgz(&[
("Rust.docset/Contents/Info.plist", SAMPLE_PLIST.as_bytes()),
(
"Rust.docset/Contents/Resources/Documents/index.html",
b"<html><body><main><h1>Hello</h1></main></body></html>",
),
(
"Rust.docset/Contents/Resources/Documents/book/ch01.html",
b"<html><body><p>Chapter 1</p></body></html>",
),
]);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&tgz).unwrap();
let mut collected: Vec<(String, Vec<u8>)> = Vec::new();
let (info, dsidx) = extract_docset(tmp.path(), |rel, bytes| {
collected.push((rel.to_owned(), bytes.to_owned()));
})
.unwrap();
assert_eq!(info.identifier, "rust");
assert!(dsidx.is_none(), "no dsidx in this archive");
assert_eq!(info.name, "Rust");
assert_eq!(collected.len(), 2);
let paths: Vec<&str> = collected.iter().map(|(p, _)| p.as_str()).collect();
assert!(paths.contains(&"index.html"));
assert!(paths.contains(&"book/ch01.html"));
}
#[test]
fn extract_docset_skips_non_html_files() {
use std::io::Write as _;
let tgz = make_tgz(&[
("Rust.docset/Contents/Info.plist", SAMPLE_PLIST.as_bytes()),
(
"Rust.docset/Contents/Resources/Documents/style.css",
b"body {}",
),
(
"Rust.docset/Contents/Resources/Documents/index.html",
b"<html/>",
),
]);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&tgz).unwrap();
let mut count = 0usize;
extract_docset(tmp.path(), |_, _| count += 1).unwrap();
assert_eq!(count, 1, "only the HTML file should trigger the callback");
}
#[test]
fn extract_docset_captures_dsidx_bytes() {
use std::io::Write as _;
let dummy_dsidx = b"SQLite format 3\x00fakedata";
let tgz = make_tgz(&[
("Rust.docset/Contents/Info.plist", SAMPLE_PLIST.as_bytes()),
("Rust.docset/Contents/Resources/docSet.dsidx", dummy_dsidx),
]);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&tgz).unwrap();
let (_info, dsidx) = extract_docset(tmp.path(), |_, _| {}).unwrap();
assert!(dsidx.is_some(), "dsidx bytes must be captured");
assert_eq!(dsidx.unwrap(), dummy_dsidx);
}
#[test]
fn extract_docset_missing_plist_returns_error() {
use std::io::Write as _;
let tgz = make_tgz(&[(
"Rust.docset/Contents/Resources/Documents/index.html",
b"<html/>",
)]);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&tgz).unwrap();
assert!(extract_docset(tmp.path(), |_, _| {}).is_err());
}
fn make_dsidx(path: &Path) {
let conn = rusqlite::Connection::open(path).unwrap();
conn.execute_batch(
"CREATE TABLE searchIndex (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
path TEXT NOT NULL
);
INSERT INTO searchIndex (name, type, path) VALUES
('Vec', 'Struct', 'std/vec/struct.Vec.html'),
('Vec::new', 'Method', 'std/vec/struct.Vec.html#method.new'),
('spawn', 'Function', 'tokio/fn.spawn.html');",
)
.unwrap();
}
#[test]
fn read_dsidx_returns_all_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("docSet.dsidx");
make_dsidx(&path);
let entries = read_dsidx_entries(&path).unwrap();
assert_eq!(entries.len(), 3);
}
#[test]
fn read_dsidx_entry_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("docSet.dsidx");
make_dsidx(&path);
let entries = read_dsidx_entries(&path).unwrap();
let vec_entry = entries.iter().find(|(n, _, _)| n == "Vec").unwrap();
assert_eq!(vec_entry.1, "Struct");
assert_eq!(vec_entry.2, "std/vec/struct.Vec.html");
}
#[test]
fn read_dsidx_missing_file_returns_err() {
let result = read_dsidx_entries(std::path::Path::new("/nonexistent/docSet.dsidx"));
assert!(result.is_err());
}
}