A fork of pulp-os for the xteink4 adding custom apps
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix EPUB reader OOM panic and support missing container.xml

- Replace Vec::clear() with Vec::new() in chapter cache to actually
deallocate memory, preventing heap exhaustion on reopen
- Make central directory allocation fallible with try_reserve_exact()
instead of panicking
- Add epub::find_opf_in_zip() fallback for EPUBs without container.xml

hans 1067c946 47f20dfb

+426 -16
+1 -1
assets/upload.html
··· 119 119 </style> 120 120 </head> 121 121 <body> 122 - <h1>Pulp</h1> 122 + <h1>pulp manager</h1> 123 123 124 124 <div id="drop" onclick="fin.click()"> 125 125 <p>Drop files here or tap to browse</p>
+20
smol-epub/src/epub.rs
··· 231 231 found_len.ok_or("epub: no rootfile full-path in container.xml") 232 232 } 233 233 234 + /// Scan ZIP entries for a `.opf` file and return its path. 235 + /// 236 + /// This is a fallback for EPUBs that lack `META-INF/container.xml`. 237 + /// Writes the first `.opf` entry name into `out` and returns its byte length. 238 + pub fn find_opf_in_zip( 239 + zip: &ZipIndex, 240 + out: &mut [u8; OPF_PATH_CAP], 241 + ) -> Result<usize, &'static str> { 242 + for i in 0..zip.count() { 243 + let name = zip.entry_name(i); 244 + let bytes = name.as_bytes(); 245 + if bytes.len() >= 5 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".opf") { 246 + let n = bytes.len().min(OPF_PATH_CAP); 247 + out[..n].copy_from_slice(&bytes[..n]); 248 + return Ok(n); 249 + } 250 + } 251 + Err("epub: no .opf file found in archive") 252 + } 253 + 234 254 /// Parse an OPF document: extract metadata and build the reading-order spine. 235 255 /// 236 256 /// Two-pass, zero heap: phase 1 collects `idref` byte offsets
+19 -15
src/apps/reader.rs
··· 7 7 8 8 use crate::fonts::bitmap::{self, BitmapFont}; 9 9 10 - use alloc::vec; 11 10 use alloc::vec::Vec; 12 11 use core::fmt::Write; 13 12 ··· 856 855 epub_size 857 856 ); 858 857 859 - let mut cd_buf = vec![0u8; cd_size as usize]; 858 + let mut cd_buf = Vec::new(); 859 + cd_buf 860 + .try_reserve_exact(cd_size as usize) 861 + .map_err(|_| "epub: CD too large for memory")?; 862 + cd_buf.resize(cd_size as usize, 0); 860 863 read_full(svc, name, cd_offset, &mut cd_buf)?; 861 864 self.zip.clear(); 862 865 self.zip.parse_central_directory(&cd_buf)?; ··· 875 878 let (nb, nl) = self.name_copy(); 876 879 let name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 877 880 878 - let container_idx = self 879 - .zip 880 - .find("META-INF/container.xml") 881 - .ok_or("epub: no container.xml")?; 882 - let container_data = extract_zip_entry(svc, name, &self.zip, container_idx)?; 883 - 884 881 let mut opf_path_buf = [0u8; epub::OPF_PATH_CAP]; 885 - let opf_path_len = epub::parse_container(&container_data, &mut opf_path_buf)?; 886 - drop(container_data); 882 + let opf_path_len = if let Some(container_idx) = self.zip.find("META-INF/container.xml") { 883 + let container_data = extract_zip_entry(svc, name, &self.zip, container_idx)?; 884 + let len = epub::parse_container(&container_data, &mut opf_path_buf)?; 885 + drop(container_data); 886 + len 887 + } else { 888 + log::warn!("epub: no container.xml, scanning for .opf"); 889 + epub::find_opf_in_zip(&self.zip, &mut opf_path_buf)? 890 + }; 887 891 888 892 let opf_path = core::str::from_utf8(&opf_path_buf[..opf_path_len]) 889 893 .map_err(|_| "epub: bad opf path")?; ··· 1066 1070 }; 1067 1071 1068 1072 if ch_size == 0 || ch_size > CHAPTER_CACHE_MAX { 1069 - self.ch_cache.clear(); 1073 + self.ch_cache = Vec::new(); 1070 1074 return false; 1071 1075 } 1072 1076 ··· 1078 1082 } 1079 1083 1080 1084 // reserve exact capacity; bail on OOM 1081 - self.ch_cache.clear(); 1085 + self.ch_cache = Vec::new(); 1082 1086 if self.ch_cache.try_reserve_exact(ch_size).is_err() { 1083 1087 log::info!("chapter cache: OOM for {} bytes", ch_size); 1084 1088 return false; ··· 1103 1107 Ok(_) => break, 1104 1108 Err(e) => { 1105 1109 log::info!("chapter cache: SD read failed at {}: {}", pos, e); 1106 - self.ch_cache.clear(); 1110 + self.ch_cache = Vec::new(); 1107 1111 return false; 1108 1112 } 1109 1113 } ··· 1707 1711 self.is_epub = epub::is_epub_filename(self.name()); 1708 1712 self.rebuild_quick_actions(); 1709 1713 self.reset_paging(); 1710 - self.ch_cache.clear(); 1714 + self.ch_cache = Vec::new(); 1711 1715 self.file_size = 0; 1712 1716 self.chapter = 0; 1713 1717 self.error = None; ··· 1731 1735 self.prefetch_len = 0; 1732 1736 self.restore_offset = None; 1733 1737 self.show_position = false; 1734 - self.ch_cache.clear(); 1738 + self.ch_cache = Vec::new(); 1735 1739 self.page_img = None; 1736 1740 1737 1741 if self.is_epub {
+356
src/apps/recent.rs
··· 1 + // RAM-resident bookmark cache: 16 slots x 48 bytes held permanently in RAM (~780B). 2 + // Loaded from SD once; reads served from RAM with zero SD I/O. 3 + // Writes mark dirty; flush() persists to SD only when dirty. 4 + // 5 + // Record layout (little-endian, 48 bytes per slot): 6 + // [0..4) name_hash u32 7 + // [4..8) byte_offset u32 font-independent file/chapter position 8 + // [8..10) chapter u16 epub chapter; 0 for txt 9 + // [10..12) flags u16 bit 0 = valid 10 + // [12..14) generation u16 LRU counter (higher = more recent) 11 + // [14] name_len u8 12 + // [15] _pad u8 13 + // [16..48) filename [u8;32] 14 + 15 + use crate::drivers::sdcard::SdStorage; 16 + use crate::drivers::storage; 17 + pub use smol_epub::cache::fnv1a; 18 + 19 + // case-insensitive FNV-1a; FAT filenames are case-insensitive 20 + fn fnv1a_icase(data: &[u8]) -> u32 { 21 + let mut h: u32 = 0x811c_9dc5; 22 + for &b in data { 23 + h ^= b.to_ascii_lowercase() as u32; 24 + h = h.wrapping_mul(0x0100_0193); 25 + } 26 + h 27 + } 28 + 29 + pub const BOOKMARK_FILE: &str = "BKMK.BIN"; 30 + pub const SLOTS: usize = 16; 31 + pub const RECORD_LEN: usize = 48; 32 + pub const FILE_LEN: usize = SLOTS * RECORD_LEN; // 768B 33 + pub const FILENAME_CAP: usize = 32; 34 + 35 + #[derive(Clone, Copy)] 36 + pub struct BookmarkSlot { 37 + pub name_hash: u32, 38 + pub byte_offset: u32, 39 + pub chapter: u16, 40 + pub valid: bool, 41 + pub generation: u16, 42 + pub name_len: u8, 43 + pub filename: [u8; FILENAME_CAP], 44 + } 45 + 46 + impl BookmarkSlot { 47 + pub const EMPTY: Self = Self { 48 + name_hash: 0, 49 + byte_offset: 0, 50 + chapter: 0, 51 + valid: false, 52 + generation: 0, 53 + name_len: 0, 54 + filename: [0u8; FILENAME_CAP], 55 + }; 56 + 57 + pub fn filename_str(&self) -> &str { 58 + core::str::from_utf8(&self.filename[..self.name_len as usize]).unwrap_or("?") 59 + } 60 + 61 + fn decode(rec: &[u8]) -> Self { 62 + if rec.len() < RECORD_LEN { 63 + return Self::EMPTY; 64 + } 65 + let name_hash = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]); 66 + let byte_offset = u32::from_le_bytes([rec[4], rec[5], rec[6], rec[7]]); 67 + let chapter = u16::from_le_bytes([rec[8], rec[9]]); 68 + let flags = u16::from_le_bytes([rec[10], rec[11]]); 69 + let generation = u16::from_le_bytes([rec[12], rec[13]]); 70 + let name_len = rec[14].min(FILENAME_CAP as u8); 71 + 72 + let mut filename = [0u8; FILENAME_CAP]; 73 + let n = name_len as usize; 74 + filename[..n].copy_from_slice(&rec[16..16 + n]); 75 + 76 + Self { 77 + name_hash, 78 + byte_offset, 79 + chapter, 80 + valid: flags & 1 != 0, 81 + generation, 82 + name_len, 83 + filename, 84 + } 85 + } 86 + 87 + fn encode(&self) -> [u8; RECORD_LEN] { 88 + let flags: u16 = if self.valid { 1 } else { 0 }; 89 + let mut rec = [0u8; RECORD_LEN]; 90 + rec[0..4].copy_from_slice(&self.name_hash.to_le_bytes()); 91 + rec[4..8].copy_from_slice(&self.byte_offset.to_le_bytes()); 92 + rec[8..10].copy_from_slice(&self.chapter.to_le_bytes()); 93 + rec[10..12].copy_from_slice(&flags.to_le_bytes()); 94 + rec[12..14].copy_from_slice(&self.generation.to_le_bytes()); 95 + rec[14] = self.name_len; 96 + rec[15] = 0; 97 + let n = self.name_len as usize; 98 + rec[16..16 + n].copy_from_slice(&self.filename[..n]); 99 + rec 100 + } 101 + 102 + fn matches_name(&self, name: &[u8]) -> bool { 103 + self.name_len as usize == name.len() 104 + && self.filename[..self.name_len as usize].eq_ignore_ascii_case(name) 105 + } 106 + } 107 + 108 + // lightweight bookmark list entry (35B vs 48B for BookmarkSlot) 109 + #[derive(Clone, Copy)] 110 + pub struct BmListEntry { 111 + pub filename: [u8; FILENAME_CAP], 112 + pub name_len: u8, 113 + pub chapter: u16, 114 + } 115 + 116 + impl BmListEntry { 117 + pub const EMPTY: Self = Self { 118 + filename: [0u8; FILENAME_CAP], 119 + name_len: 0, 120 + chapter: 0, 121 + }; 122 + 123 + pub fn filename_str(&self) -> &str { 124 + core::str::from_utf8(&self.filename[..self.name_len as usize]).unwrap_or("?") 125 + } 126 + } 127 + 128 + // in-memory bookmark table; loaded once, reads from RAM, writes mark dirty (~780B) 129 + pub struct BookmarkCache { 130 + slots: [BookmarkSlot; SLOTS], 131 + count: usize, // slots present in file; new saves past this extend count 132 + dirty: bool, 133 + loaded: bool, 134 + } 135 + 136 + impl Default for BookmarkCache { 137 + fn default() -> Self { 138 + Self::new() 139 + } 140 + } 141 + 142 + impl BookmarkCache { 143 + pub const fn new() -> Self { 144 + Self { 145 + slots: [BookmarkSlot::EMPTY; SLOTS], 146 + count: 0, 147 + dirty: false, 148 + loaded: false, 149 + } 150 + } 151 + 152 + // true if in-memory state has changed since the last flush 153 + pub fn is_dirty(&self) -> bool { 154 + self.dirty 155 + } 156 + 157 + pub fn is_loaded(&self) -> bool { 158 + self.loaded 159 + } 160 + 161 + // read bookmark file from SD; idempotent; no-op if already loaded 162 + pub fn ensure_loaded<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 163 + if self.loaded { 164 + return; 165 + } 166 + self.force_load(sd); 167 + } 168 + 169 + // reload from SD, discarding in-memory changes 170 + pub fn force_load<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 171 + let mut buf = [0u8; FILE_LEN]; 172 + let slot_count = match storage::read_pulp_file_start(sd, BOOKMARK_FILE, &mut buf) { 173 + Ok((_, n)) => (n / RECORD_LEN).min(SLOTS), 174 + Err(_) => 0, 175 + }; 176 + 177 + for i in 0..slot_count { 178 + let base = i * RECORD_LEN; 179 + self.slots[i] = BookmarkSlot::decode(&buf[base..base + RECORD_LEN]); 180 + } 181 + for i in slot_count..SLOTS { 182 + self.slots[i] = BookmarkSlot::EMPTY; 183 + } 184 + 185 + self.count = slot_count; 186 + self.dirty = false; 187 + self.loaded = true; 188 + 189 + log::info!("bookmarks: loaded {} slots from SD", slot_count); 190 + } 191 + 192 + // find bookmark by filename; None if not found or not loaded 193 + pub fn find(&self, filename: &[u8]) -> Option<BookmarkSlot> { 194 + if !self.loaded { 195 + return None; 196 + } 197 + 198 + let key = fnv1a_icase(filename); 199 + for i in 0..self.count { 200 + let slot = &self.slots[i]; 201 + if slot.valid && slot.name_hash == key && slot.matches_name(filename) { 202 + return Some(*slot); 203 + } 204 + } 205 + None 206 + } 207 + 208 + // copy valid bookmarks into out, sorted by generation descending; return count written 209 + pub fn load_all(&self, out: &mut [BmListEntry]) -> usize { 210 + if !self.loaded { 211 + return 0; 212 + } 213 + 214 + // collect valid entries with their generation for sorting 215 + let mut gens = [0u16; SLOTS]; 216 + let mut count = 0usize; 217 + 218 + for i in 0..self.count { 219 + if count >= out.len() { 220 + break; 221 + } 222 + let slot = &self.slots[i]; 223 + if slot.valid && slot.name_len > 0 { 224 + gens[count] = slot.generation; 225 + out[count] = BmListEntry { 226 + filename: slot.filename, 227 + name_len: slot.name_len, 228 + chapter: slot.chapter, 229 + }; 230 + count += 1; 231 + } 232 + } 233 + 234 + // insertion sort by generation descending (most recent first) 235 + for i in 1..count { 236 + let key_gen = gens[i]; 237 + let key_entry = out[i]; 238 + let mut j = i; 239 + while j > 0 && gens[j - 1] < key_gen { 240 + gens[j] = gens[j - 1]; 241 + out[j] = out[j - 1]; 242 + j -= 1; 243 + } 244 + gens[j] = key_gen; 245 + out[j] = key_entry; 246 + } 247 + 248 + count 249 + } 250 + 251 + // save bookmark; update cache + mark dirty; call flush() to persist. 252 + // handles LRU eviction, generation increment, hash+name matching. 253 + pub fn save(&mut self, filename: &[u8], byte_offset: u32, chapter: u16) { 254 + if !self.loaded { 255 + log::warn!("bookmarks: save called before load, ignoring"); 256 + return; 257 + } 258 + 259 + let key = fnv1a_icase(filename); 260 + 261 + // scan for: target slot, max generation, first free, LRU 262 + let mut max_gen: u16 = 0; 263 + let mut target: Option<usize> = None; 264 + let mut first_free: Option<usize> = None; 265 + let mut lru_slot: usize = 0; 266 + let mut lru_gen: u16 = u16::MAX; 267 + 268 + for i in 0..self.count { 269 + let slot = &self.slots[i]; 270 + 271 + if !slot.valid { 272 + if first_free.is_none() { 273 + first_free = Some(i); 274 + } 275 + continue; 276 + } 277 + 278 + if slot.generation > max_gen { 279 + max_gen = slot.generation; 280 + } 281 + if slot.generation < lru_gen { 282 + lru_gen = slot.generation; 283 + lru_slot = i; 284 + } 285 + 286 + if slot.name_hash == key && slot.matches_name(filename) { 287 + target = Some(i); 288 + break; 289 + } 290 + } 291 + 292 + let write_slot = target.or(first_free).unwrap_or(if self.count >= SLOTS { 293 + lru_slot 294 + } else { 295 + self.count 296 + }); 297 + 298 + let generation = max_gen.wrapping_add(1); 299 + let name_len = filename.len().min(FILENAME_CAP); 300 + 301 + let mut new_slot = BookmarkSlot { 302 + name_hash: key, 303 + byte_offset, 304 + chapter, 305 + valid: true, 306 + generation, 307 + name_len: name_len as u8, 308 + filename: [0u8; FILENAME_CAP], 309 + }; 310 + new_slot.filename[..name_len].copy_from_slice(&filename[..name_len]); 311 + 312 + self.slots[write_slot] = new_slot; 313 + 314 + // extend count if we wrote past the current end 315 + if write_slot >= self.count { 316 + self.count = write_slot + 1; 317 + } 318 + 319 + self.dirty = true; 320 + 321 + log::info!( 322 + "bookmark: cached off={} ch={} gen={} for {:?}", 323 + byte_offset, 324 + chapter, 325 + generation, 326 + core::str::from_utf8(filename).unwrap_or("?"), 327 + ); 328 + } 329 + 330 + // write cache to SD if dirty; no-op if clean; 768B stack buffer 331 + pub fn flush<SPI: embedded_hal::spi::SpiDevice>(&mut self, sd: &SdStorage<SPI>) { 332 + if !self.dirty || !self.loaded { 333 + return; 334 + } 335 + 336 + let file_len = self.count * RECORD_LEN; 337 + let mut buf = [0u8; FILE_LEN]; 338 + 339 + for i in 0..self.count { 340 + let base = i * RECORD_LEN; 341 + let rec = self.slots[i].encode(); 342 + buf[base..base + RECORD_LEN].copy_from_slice(&rec); 343 + } 344 + 345 + match storage::write_pulp_file(sd, BOOKMARK_FILE, &buf[..file_len]) { 346 + Ok(_) => { 347 + self.dirty = false; 348 + log::info!("bookmarks: flushed {} slots to SD", self.count); 349 + } 350 + Err(e) => { 351 + log::warn!("bookmarks: flush failed: {}", e); 352 + // leave dirty=true so we retry next time 353 + } 354 + } 355 + } 356 + }
+30
src/drivers/storage.rs
··· 217 217 if matches!(entry.name.base_name()[0], b'.' | b'_') { 218 218 return; 219 219 } 220 + // hide files the device cannot open 221 + if !entry.attributes.is_directory() && !is_supported_ext(entry.name.extension()) { 222 + return; 223 + } 220 224 if count < MAX_DIR_ENTRIES { 221 225 let mut name_buf = [0u8; 13]; 222 226 let name_len = format_83_name(&entry.name, &mut name_buf); ··· 348 352 } 349 353 350 354 // insertion sort: dirs first, then filenames case-insensitive 355 + /// Check whether a FAT 8.3 extension (3 bytes, space-padded) belongs to a 356 + /// file type the device can open. 357 + /// Supported: TXT, MD, EPU(B), JPG, JPE(G), PNG. 358 + fn is_supported_ext(ext: &[u8]) -> bool { 359 + if ext.len() < 3 { 360 + // 2-char extension: only "MD" (padded with space) 361 + return ext.len() == 2 362 + && ext[0].to_ascii_uppercase() == b'M' 363 + && ext[1].to_ascii_uppercase() == b'D'; 364 + } 365 + let e = [ 366 + ext[0].to_ascii_uppercase(), 367 + ext[1].to_ascii_uppercase(), 368 + ext[2].to_ascii_uppercase(), 369 + ]; 370 + matches!( 371 + e, 372 + [b'T', b'X', b'T'] 373 + | [b'M', b'D', b' '] 374 + | [b'E', b'P', b'U'] 375 + | [b'J', b'P', b'G'] 376 + | [b'J', b'P', b'E'] 377 + | [b'P', b'N', b'G'] 378 + ) 379 + } 380 + 351 381 fn sort_entries(entries: &mut [DirEntry]) { 352 382 for i in 1..entries.len() { 353 383 let key = entries[i];