obsidian_lib/
lib.rs

1//! A library for reading and extracting files from Obsidian `.obby` plugin files.
2//!
3//! This crate provides functionality to read `.obby` files, which are archives used
4//! by Obsidian plugins. It allows you to list and extract files from these archives,
5//! with special support for extracting `plugin.json` files.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use obsidian_lib::{ObbyArchive, extract_plugin_json};
11//! use std::path::Path;
12//! use std::fs::File;
13//!
14//! # fn main() -> std::io::Result<()> {
15//! // From a file path
16//! let json = extract_plugin_json(Path::new("plugin.obby"))?;
17//!
18//! // Or from any Read + Seek source
19//! let file = File::open("plugin.obby")?;
20//! let mut archive = ObbyArchive::new(file)?;
21//! let entries = archive.list_entries();
22//! let data = archive.extract_entry("plugin.json")?;
23//! # Ok(())
24//! # }
25//! ```
26
27use std::collections::HashMap;
28use std::fs::File;
29use std::io::{self, Read, Seek, SeekFrom};
30use std::path::Path;
31
32/// Main reader struct for working with .obby files from any source
33///
34/// The `ObbyArchive` struct is used to represent an archive file in the `.obby` format,
35/// which is used by Obsidian plugins. It allows for listing and extracting the files
36/// within the archive, and it handles both reading the metadata and the compressed file data.
37///
38/// # Type Parameters
39///
40/// * `R`: A type that implements both `Read` and `Seek` traits, such as `std::fs::File` or `std::io::Cursor`.
41#[derive(Debug)]
42pub struct ObbyArchive<R: Read + Seek> {
43    entries: HashMap<String, EntryInfo>,
44    reader: R,
45    data_start_pos: u64,
46}
47
48#[derive(Debug)]
49struct EntryInfo {
50    offset: u64,
51    length: i32,
52    compressed_length: i32,
53}
54
55struct BinaryReader<R: Read> {
56    reader: R,
57}
58
59impl<R: Read> BinaryReader<R> {
60    /// Creates a new instance of `BinaryReader`
61    ///
62    /// This function initializes a new binary reader from the provided `reader`.
63    ///
64    /// # Arguments
65    ///
66    /// * `reader` - The reader to be used for reading bytes.
67    ///
68    /// # Returns
69    ///
70    /// A new `BinaryReader` instance.
71    fn new(reader: R) -> Self {
72        BinaryReader { reader }
73    }
74
75    /// Reads a single byte from the reader
76    ///
77    /// # Returns
78    ///
79    /// A `Result` containing the byte if successful, or an error if reading fails.
80    fn read_u8(&mut self) -> io::Result<u8> {
81        let mut byte = [0u8; 1];
82        self.reader.read_exact(&mut byte)?;
83        Ok(byte[0])
84    }
85
86    /// Reads a specific number of bytes from the reader
87    ///
88    /// # Arguments
89    ///
90    /// * `length` - The number of bytes to read.
91    ///
92    /// # Returns
93    ///
94    /// A `Result` containing a `Vec<u8>` of the read bytes if successful, or an error if reading fails.
95    fn read_bytes(&mut self, length: usize) -> io::Result<Vec<u8>> {
96        let mut buffer = vec![0u8; length];
97        self.reader.read_exact(&mut buffer)?;
98        Ok(buffer)
99    }
100
101    /// Reads a 32-bit integer from the reader
102    ///
103    /// # Returns
104    ///
105    /// A `Result` containing the integer if successful, or an error if reading fails.
106    fn read_i32(&mut self) -> io::Result<i32> {
107        let mut bytes = [0u8; 4];
108        self.reader.read_exact(&mut bytes)?;
109        Ok(i32::from_le_bytes(bytes))
110    }
111}
112
113/// Reads a C#-style encoded string from the reader
114///
115/// The string is encoded with a length prefix in variable-length encoding, where the length
116/// is encoded using 7-bit chunks.
117fn read_csharp_string<R: Read>(reader: &mut BinaryReader<R>) -> io::Result<String> {
118    let mut string_len = 0;
119    let mut done = false;
120    let mut step = 0;
121    while !done {
122        let byte = reader.read_u8()?;
123        string_len |= ((byte & 0x7F) as u32) << (step * 7);
124        done = (byte & 0x80) == 0;
125        step += 1;
126    }
127    let buf = reader.read_bytes(string_len as usize)?;
128    Ok(String::from_utf8_lossy(&buf).to_string())
129}
130
131impl<R: Read + Seek> ObbyArchive<R> {
132    /// Creates a new `ObbyArchive` from any source that implements `Read` and `Seek`
133    ///
134    /// This function reads the `.obby` file format and extracts its metadata and entry
135    /// information. It verifies the file header and sets up the internal structure to allow
136    /// for extracting files from the archive.
137    ///
138    /// # Arguments
139    ///
140    /// * `reader` - Any type that implements the `Read` and `Seek` traits (e.g., `File`, `Cursor`).
141    ///
142    /// # Returns
143    ///
144    /// A `Result` containing either the created `ObbyArchive` instance or an `io::Error` if there was an issue reading the archive.
145    ///
146    /// # Example
147    ///
148    /// ```no_run
149    /// use obsidian_lib::ObbyArchive;
150    /// use std::fs::File;
151    ///
152    /// let file = File::open("plugin.obby").unwrap();
153    /// let archive = ObbyArchive::new(file).unwrap();
154    /// ```
155    pub fn new(mut reader: R) -> io::Result<Self> {
156        let mut binary_reader = BinaryReader::new(&mut reader);
157
158        // Verify header
159        let mut header = [0u8; 4];
160        binary_reader.reader.read_exact(&mut header)?;
161        if &header != b"OBBY" {
162            return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid plugin header"));
163        }
164
165        // Read metadata
166        let _api_version = read_csharp_string(&mut binary_reader)?;
167        let _hash = binary_reader.read_bytes(48)?;
168
169        // Read signature (if present)
170        let mut is_signed = [0u8; 1];
171        binary_reader.reader.read_exact(&mut is_signed)?;
172        if is_signed[0] != 0 {
173            let _signature = binary_reader.read_bytes(384)?;
174        }
175
176        // Read data length and plugin info
177        let _data_length = binary_reader.read_i32()?;
178        let _plugin_assembly = read_csharp_string(&mut binary_reader)?;
179        let _plugin_version = read_csharp_string(&mut binary_reader)?;
180
181        // Read entries
182        let entry_count = binary_reader.read_i32()? as usize;
183        let mut entries = HashMap::new();
184        let mut current_offset = 0u64;
185
186        for _ in 0..entry_count {
187            let name = read_csharp_string(&mut binary_reader)?;
188            let length = binary_reader.read_i32()?;
189            let compressed_length = binary_reader.read_i32()?;
190
191            entries.insert(name, EntryInfo {
192                offset: current_offset,
193                length,
194                compressed_length,
195            });
196
197            current_offset += compressed_length as u64;
198        }
199
200        let data_start_pos = reader.stream_position()?;
201
202        Ok(ObbyArchive {
203            entries,
204            reader,
205            data_start_pos,
206        })
207    }
208
209    /// Returns a list of all entries in the archive
210    ///
211    /// This function returns a vector of the entry names in the `.obby` archive.
212    ///
213    /// # Returns
214    ///
215    /// A `Vec<String>` containing the names of all entries.
216    pub fn list_entries(&self) -> Vec<String> {
217        self.entries.keys().cloned().collect()
218    }
219
220    /// Extracts a specific entry by name
221    ///
222    /// This function extracts a specific entry from the `.obby` archive based on its name.
223    /// The entry data is returned as a vector of bytes.
224    ///
225    /// # Arguments
226    ///
227    /// * `entry_name` - The name of the entry to extract.
228    ///
229    /// # Returns
230    ///
231    /// A `Result` containing a `Vec<u8>` of the extracted entry's data if successful, or an `io::Error` if there was an issue extracting it.
232    pub fn extract_entry(&mut self, entry_name: &str) -> io::Result<Vec<u8>> {
233        let entry = self.entries.get(entry_name).ok_or_else(|| {
234            io::Error::new(
235                io::ErrorKind::NotFound,
236                format!("Entry '{}' not found in archive", entry_name),
237            )
238        })?;
239
240        // Seek to the entry's position
241        self.reader.seek(SeekFrom::Start(self.data_start_pos + entry.offset))?;
242
243        // Read the compressed data
244        let mut reader = BinaryReader::new(&mut self.reader);
245        let compressed_data = reader.read_bytes(entry.compressed_length as usize)?;
246
247        // Decompress if necessary
248        if entry.compressed_length != entry.length {
249            let mut decompressed_data = Vec::new();
250            let mut decoder = flate2::read::DeflateDecoder::new(&compressed_data[..]);
251            decoder.read_to_end(&mut decompressed_data)?;
252            Ok(decompressed_data)
253        } else {
254            Ok(compressed_data)
255        }
256    }
257}
258
259/// Opens an .obby file from a path
260///
261/// This is a convenience function that creates an `ObbyArchive` from a file path.
262///
263/// # Arguments
264///
265/// * `path` - The path to the `.obby` file.
266pub fn open<P: AsRef<Path>>(path: P) -> io::Result<ObbyArchive<File>> {
267    let file = File::open(path)?;
268    ObbyArchive::new(file)
269}
270
271#[cfg(feature = "wasm")]
272use wasm_bindgen::prelude::*;
273#[cfg(feature = "wasm")]
274use std::io::Cursor;
275use js_sys::Uint8Array;
276
277/// A wrapper struct for the WebAssembly environment to interact with `.obby` files
278///
279/// This struct provides a WASM-compatible interface for working with `.obby` archives.
280#[wasm_bindgen]
281pub struct WasmObbyArchive {
282    inner: ObbyArchive<Cursor<Vec<u8>>>
283}
284
285#[wasm_bindgen]
286impl WasmObbyArchive {
287    #[wasm_bindgen(constructor)]
288    /// Creates a new `WasmObbyArchive` instance from a byte buffer
289    ///
290    /// # Arguments
291    ///
292    /// * `buffer` - A byte slice representing the `.obby` file contents.
293    ///
294    /// # Returns
295    ///
296    /// A `WasmObbyArchive` instance.
297    pub fn new(buffer: &[u8]) -> Result<WasmObbyArchive, JsValue> {
298        let cursor = Cursor::new(buffer.to_vec());
299        let inner = ObbyArchive::new(cursor)
300            .map_err(|e| JsValue::from_str(&e.to_string()))?;
301
302        Ok(WasmObbyArchive { inner })
303    }
304
305    #[wasm_bindgen]
306    /// Lists all entries in the `.obby` archive
307    ///
308    /// # Returns
309    ///
310    /// A JavaScript array of strings representing the names of all entries.
311    pub fn list_entries(&self) -> Box<[JsValue]> {
312        self.inner
313            .list_entries()
314            .into_iter()
315            .map(JsValue::from)
316            .collect::<Vec<_>>()
317            .into_boxed_slice()
318    }
319
320    #[wasm_bindgen]
321    /// Extracts a specific entry by name
322    ///
323    /// # Arguments
324    ///
325    /// * `entry_name` - The name of the entry to extract.
326    ///
327    /// # Returns
328    ///
329    /// A `Uint8Array` containing the entry's data.
330    pub fn extract_entry(&mut self, entry_name: &str) -> Result<Uint8Array, JsValue> {
331        let data = self.inner
332            .extract_entry(entry_name)
333            .map_err(|e| JsValue::from_str(&e.to_string()))?;
334
335        Ok(Uint8Array::from(&data[..]))
336    }
337
338    #[wasm_bindgen]
339    /// Extracts and returns the contents of the `plugin.json` file from the `.obby` archive
340    ///
341    /// # Returns
342    ///
343    /// A `Result<String, JsValue>` containing the parsed JSON string if successful.
344    pub fn extract_plugin_json(&mut self) -> Result<String, JsValue> {
345        let data = self.extract_entry("plugin.json")?;
346        let text = String::from_utf8(data.to_vec())
347            .map_err(|e| JsValue::from_str(&e.to_string()))?;
348        Ok(text)
349    }
350}
351
352/// Convenience function to extract and parse the `plugin.json` file from an `.obby` archive
353///
354/// This function opens the `.obby` file, extracts the `plugin.json` entry, and returns
355/// the contents of the file as a `String`.
356///
357/// # Arguments
358///
359/// * `path` - Path to the `.obby` file.
360pub fn extract_plugin_json<P: AsRef<Path>>(path: P) -> io::Result<String> {
361    let mut archive = open(path)?;
362    let data = archive.extract_entry("plugin.json")?;
363    String::from_utf8(data)
364        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::io::{Cursor, Write};
371    use tempfile::NamedTempFile;
372    use flate2::{write::DeflateEncoder, Compression};
373
374    fn create_test_plugin_json() -> String {
375        r#"{
376            "id": "test-plugin",
377            "name": "Test Plugin",
378            "version": "1.0.0",
379            "description": "A test plugin"
380        }"#.to_string()
381    }
382
383    fn load_test_obby_bytes() -> Vec<u8> {
384        let mut file = File::open("test_dir/ObsidianPlugin.obby").unwrap();
385        let mut buffer = Vec::new();
386        file.read_to_end(&mut buffer).unwrap();
387
388        buffer
389    }
390
391    #[test]
392    fn test_memory_buffer() {
393        let buffer = load_test_obby_bytes();
394        let cursor = Cursor::new(buffer);
395        let archive = ObbyArchive::new(cursor);
396        assert!(archive.is_ok());
397    }
398}