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}