Something went wrong. Try again.
A Zig library and CLI for reading Mobipocket (.mobi, .prc, .azw) e-books.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Reader for Mobipocket (`.mobi` / `.prc` / `.azw`) files.//!//! A MOBI file is a Palm Database (PDB) whose first record carries the PalmDOC//! and MOBI headers, followed by compressed text records, then images and other//! resources. `parse` reads the headers; `readText` and `imageRecords` get at//! the content.
const MOBI = @This();
const std = @import("std");
pub const palmdoc = @import("palmdoc.zig");pub const huffcdic = @import("huffcdic.zig");pub const encoding = @import("encoding.zig");pub const pc1 = @import("pc1.zig");pub const drm = @import("drm.zig");
pub const Encoding = encoding.Encoding;
/// The whole file, borrowed. All slices handed out point into it.input: []const u8,header: Header,/// Record descriptors in file order; the index is the record number.records: []RecordInfo,palmdoc_header: PalmDocHeader,mobi_header: MobiHeader,exth: ?ExthHeader,full_name: []const u8,/// Bit 0 means text records carry a multibyte overlap byte; the remaining set/// bits each mean one variable-length trailing entry. Zero for old files that/// stop before this field.extra_data_flags: u32,has_drm: bool,/// The book key, once an `unlock` call has recovered it. Null while the text/// records are still encrypted.drm_key: ?pc1.Key,/// The validity window the key came with, when it came from a DRM voucher.drm_validity: ?drm.Unlocked,
pub const Error = error{ EndOfStream, NotAMobiFile, NoRecordZero, BadRecordOffsets, BadExthHeader, BadFullName, /// The book is encrypted and no key has been recovered yet. Locked, /// No voucher opened with the key material supplied. KeyNotFound, InvalidSerial, BadDrmHeader, UnsupportedEncryption, UnsupportedCompression, MissingHuffRecords, InvalidHuffRecord, InvalidCdicRecord, CorruptCompressedData, OutOfMemory,};
pub const Reader = struct { input: []const u8, offset: usize,
pub fn init(input: []const u8) Reader { return .{ .input = input, .offset = 0 }; }
pub fn seek(self: *Reader, offset: usize) !void { if (offset > self.input.len) return error.EndOfStream; self.offset = offset; }
pub fn read(self: *Reader, comptime T: type) !T { const info = @typeInfo(T); switch (info) { .@"struct" => |s| { var tmp: T = undefined; inline for (s.field_names, s.field_types) |name, field_type| { @field(tmp, name) = try self.read(field_type); } return tmp; }, .array => |a| { if (a.child != u8) @compileError("only u8 arrays supported"); if (self.offset + a.len > self.input.len) return error.EndOfStream; var tmp: T = undefined; @memcpy(&tmp, self.input[self.offset .. self.offset + a.len]); self.offset += a.len; return tmp; }, .int => |i| { if (i.bits % 8 != 0) @compileError("integers must be multiple of 8"); const len = @divExact(i.bits, 8); if (self.offset + len > self.input.len) return error.EndOfStream; const tmp: T = std.mem.readVarInt(T, self.input[self.offset .. self.offset + len], .big); self.offset += len; return tmp; }, .@"enum" => |e| { const i = @typeInfo(e.tag_type).int; if (i.bits % 8 != 0) @compileError("integers must be multiple of 8"); const len = @divExact(i.bits, 8); if (self.offset + len > self.input.len) return error.EndOfStream; const tmp: T = @fromBackingInt(std.mem.readVarInt(e.tag_type, self.input[self.offset .. self.offset + len], .big)); self.offset += len; return tmp; }, else => @compileError(@typeName(T) ++ " not supported"), } }
pub fn slice(self: *Reader, len: usize) ![]const u8 { if (self.offset + len > self.input.len) return error.EndOfStream; const s = self.input[self.offset .. self.offset + len]; self.offset += len; return s; }};
pub const Header = struct { name: [32]u8, attributes: i16, version: i16, created: u32, modified: u32, backup: u32, modnum: u32, app_info_id: u32, sort_info_id: u32, type: [4]u8, creator: [4]u8, unique_id_seed: u32, next_record_list_id: u32, number_of_records: u16,};
pub const RecordInfo = struct { offset: u32, attributes: u8, unique_id: u24,};
pub const Compression = enum(u16) { none = 1, palmdoc = 2, huff_cdic = 17480, _,};
/// How the text records are protected. The scheme is orthogonal to the/// compression: an encrypted record is compressed first, then encrypted.pub const Encryption = enum(u16) { none = 0, /// The original Mobipocket scheme. The key is embedded in record zero and /// obfuscated with a fixed vector, so no external key material is needed. legacy = 1, /// Mobipocket DRM. The key sits in a voucher in the DRM header that only /// opens with a PID derived from the reading device's serial number. drm = 2, _,};
pub const PalmDocHeader = struct { compression: Compression, unused: u16, text_length: u32, record_count: u16, record_size: u16, encryption: Encryption, unknown: u16,};
/// The first 200 bytes of the MOBI header, present in every version. Fields/// past this point are read separately, guarded by `header_length`.pub const MobiHeader = struct { identifier: [4]u8, header_length: u32, mobi_type: u32, text_encoding: u32, unique_id: u32, file_version: u32, reserved: [40]u8, first_non_book_index: u32, full_name_offset: u32, full_name_length: u32, language: u32, input_language: u32, output_language: u32, min_version: u32, first_image_index: u32, first_huff_record: u32, huff_record_count: u32, first_datp_record: u32, datp_record_count: u32, exth_flags: u32, unknown_36: [36]u8, drm_offset: u32, drm_count: u32, drm_size: u32, drm_flags: u32, unknown_8: [8]u8, first_content_record: u16, last_content_record: u16, unknown_u32_1: u32, fcis_record: u32, fcis_count: u32, flis_record: u32, flis_count: u32,};
/// Byte offset of `extra_record_data_flags` within the MOBI header.const extra_data_flags_offset = 224;
pub const ExthRecordType = enum(u32) { title = 99, author = 100, publisher = 101, imprint = 102, description = 103, isbn = 104, subject = 105, publishing_date = 106, review = 107, contributor = 108, rights = 109, subjectcode = 110, type = 111, source = 112, asin = 113, version = 114, sample = 115, startreading = 116, adult = 117, price = 118, currency = 119, kf8boundary = 121, fixedlayout = 122, booktype = 123, orientationlock = 124, countresources = 125, origresolution = 126, zerogutter = 127, zeromargin = 128, kf8coveruri = 129, rescoffset = 131, regionmagni = 132, dictname = 200, coveroffset = 201, thumboffset = 202, hasfakecover = 203, creatorsoft = 204, creatormajor = 205, creatorminor = 206, creatorbuild = 207, watermark = 208, tamperkeys = 209, fontsignature = 300, clippinglimit = 401, publisherlimit = 402, unk403 = 403, ttsdisable = 404, readforfree = 405, rental = 406, unk407 = 407, unk450 = 450, unk451 = 451, unk452 = 452, unk453 = 453, doctype = 501, lastupdate = 502, updatedtitle = 503, asin504 = 504, titlefileas = 508, creatorfileas = 517, publisherfileas = 522, language = 524, alignment = 525, creatorstring = 526, pagedir = 527, overridefonts = 528, sorcedesc = 529, dictlangin = 531, dictlangout = 532, inputsource = 534, creatorbuildrev = 535, _,};
pub const ExthHeader = struct { records: std.array_hash_map.Auto(ExthRecordType, std.ArrayList([]const u8)),
/// The first value stored for `t`, or null. pub fn get(self: ExthHeader, t: ExthRecordType) ?[]const u8 { const list = self.records.get(t) orelse return null; if (list.items.len == 0) return null; return list.items[0]; }
/// Every value stored for `t`; empty if there are none. pub fn getAll(self: ExthHeader, t: ExthRecordType) []const []const u8 { const list = self.records.get(t) orelse return &.{}; return list.items; }
/// The first value for `t` interpreted as a big-endian u32, or null. pub fn getInt(self: ExthHeader, t: ExthRecordType) ?u32 { const v = self.get(t) orelse return null; if (v.len != 4) return null; return std.mem.readInt(u32, v[0..4], .big); }};
pub fn parse(gpa: std.mem.Allocator, input: []const u8) Error!MOBI { var reader: Reader = .init(input);
const header = try reader.read(Header);
var tmp: MOBI = .{ .input = input, .header = header, .records = &.{}, .palmdoc_header = undefined, .mobi_header = undefined, .exth = null, .full_name = "", .extra_data_flags = 0, .has_drm = false, .drm_key = null, .drm_validity = null, }; errdefer tmp.deinit(gpa);
const records = try gpa.alloc(RecordInfo, header.number_of_records); tmp.records = records; for (records) |*info| { info.* = try reader.read(RecordInfo); } // Offsets must be non-decreasing for record extents to make sense. for (records[1..], records[0..records.len -| 1]) |next, prev| { if (next.offset < prev.offset) return error.BadRecordOffsets; } if (records.len == 0) return error.NoRecordZero; if (records[records.len - 1].offset > input.len) return error.BadRecordOffsets;
const r0 = records[0].offset; try reader.seek(r0); tmp.palmdoc_header = try reader.read(PalmDocHeader);
const mobi_start = reader.offset; tmp.mobi_header = try reader.read(MobiHeader); if (!std.mem.eql(u8, &tmp.mobi_header.identifier, "MOBI")) return error.NotAMobiFile;
if (tmp.mobi_header.header_length >= extra_data_flags_offset + 4) { var flag_reader: Reader = .init(input); try flag_reader.seek(mobi_start + extra_data_flags_offset); tmp.extra_data_flags = try flag_reader.read(u32); }
const name_start = r0 + tmp.mobi_header.full_name_offset; const name_end = name_start + tmp.mobi_header.full_name_length; if (name_end > input.len or name_start > name_end) return error.BadFullName; tmp.full_name = input[name_start..name_end];
tmp.has_drm = tmp.mobi_header.drm_offset != 0xffff_ffff and tmp.mobi_header.drm_count != 0;
if (tmp.mobi_header.exth_flags & 0x40 != 0) { try reader.seek(mobi_start + tmp.mobi_header.header_length); tmp.exth = try parseExth(gpa, &reader); }
return tmp;}
fn parseExth(gpa: std.mem.Allocator, reader: *Reader) Error!ExthHeader { const identifier = try reader.read([4]u8); if (!std.mem.eql(u8, &identifier, "EXTH")) return error.BadExthHeader; _ = try reader.read(u32); // header length, including these 12 bytes const record_count = try reader.read(u32);
var exth: ExthHeader = .{ .records = .empty }; errdefer deinitExth(&exth, gpa);
for (0..record_count) |_| { const t = try reader.read(ExthRecordType); const len = try reader.read(u32); // The length covers the 4 byte type and 4 byte length fields too. if (len < 8) return error.BadExthHeader; const data = try reader.slice(len - 8);
const gop = try exth.records.getOrPut(gpa, t); if (!gop.found_existing) gop.value_ptr.* = .empty; try gop.value_ptr.append(gpa, data); } return exth;}
fn deinitExth(exth: *ExthHeader, gpa: std.mem.Allocator) void { var it = exth.records.iterator(); while (it.next()) |entry| entry.value_ptr.deinit(gpa); exth.records.deinit(gpa);}
pub fn deinit(self: *MOBI, gpa: std.mem.Allocator) void { gpa.free(self.records); if (self.exth) |*exth| deinitExth(exth, gpa); self.* = undefined;}
// -- Records ---------------------------------------------------------------
pub fn recordCount(self: MOBI) usize { return self.records.len;}
/// The raw bytes of record `index`, which run to the start of the next record.pub fn record(self: MOBI, index: usize) ?[]const u8 { if (index >= self.records.len) return null; const start = self.records[index].offset; const end = if (index + 1 < self.records.len) self.records[index + 1].offset else self.input.len; if (start > end or end > self.input.len) return null; return self.input[start..end];}
// -- Metadata --------------------------------------------------------------
pub fn textEncoding(self: MOBI) Encoding { return @fromBackingInt(self.mobi_header.text_encoding);}
/// The book title. Prefers the EXTH `updatedtitle`/`title` record and falls/// back to the full name stored in record zero.pub fn title(self: MOBI) []const u8 { if (self.exth) |exth| { if (exth.get(.updatedtitle)) |v| return v; if (exth.get(.title)) |v| return v; } return self.full_name;}
pub fn author(self: MOBI) ?[]const u8 { const exth = self.exth orelse return null; return exth.get(.author);}
pub fn publisher(self: MOBI) ?[]const u8 { const exth = self.exth orelse return null; return exth.get(.publisher);}
/// Record index where the KF8 (AZW3) half of a dual-format file begins, if any./// Records at and after this point belong to a second, newer book.pub fn kf8Boundary(self: MOBI) ?u32 { const exth = self.exth orelse return null; const v = exth.getInt(.kf8boundary) orelse return null; if (v == 0xffff_ffff or v >= self.records.len) return null; return v;}
// -- Text ------------------------------------------------------------------
/// Size in bytes of the trailing data entry at the end of `data`, including the/// bytes that encode the size itself. The length is stored backwards, seven/// bits at a time, with the high bit marking the final (leftmost) byte.fn trailingEntrySize(data: []const u8) usize { var result: usize = 0; var bitpos: u6 = 0; var size = data.len; while (size > 0) { const v = data[size - 1]; result |= @as(usize, v & 0x7f) << bitpos; bitpos += 7; size -= 1; if ((v & 0x80) != 0 or bitpos >= 28) break; } return result;}
/// Strip the trailing entries that `extra_data_flags` says a text record carries.fn trimTrailingEntries(self: MOBI, data: []const u8) []const u8 { var out = data; var flags = self.extra_data_flags >> 1; while (flags != 0) : (flags >>= 1) { if (flags & 1 == 0) continue; const n = trailingEntrySize(out); if (n == 0 or n > out.len) return out; out = out[0 .. out.len - n]; } if (self.extra_data_flags & 1 != 0 and out.len > 0) { const n: usize = (out[out.len - 1] & 0x3) + 1; if (n > out.len) return out; out = out[0 .. out.len - n]; } return out;}
/// The number of text records, clamped to what the file actually contains.pub fn textRecordCount(self: MOBI) usize { const declared: usize = self.palmdoc_header.record_count; const available = self.records.len -| 1; return @min(declared, available);}
/// Decompress the whole book text. The result is in the file's own encoding —/// use `readTextUtf8` for UTF-8. The caller owns the returned bytes./// A text record's payload: trailing entries removed and, when the book is/// encrypted, decrypted into `scratch`. Only the trimmed part is encrypted —/// the trailing entries are stored in the clear.fn plainRecord(self: MOBI, index: usize, scratch: []u8) ?[]const u8 { const raw = self.record(index) orelse return null; const trimmed = self.trimTrailingEntries(raw); const key = self.drm_key orelse return trimmed; pc1.decrypt(scratch[0..trimmed.len], trimmed, key); return scratch[0..trimmed.len];}
pub fn readText(self: MOBI, gpa: std.mem.Allocator) Error![]u8 { switch (self.palmdoc_header.encryption) { .none => {}, .legacy, .drm => if (self.drm_key == null) return error.Locked, _ => return error.UnsupportedEncryption, }
var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa);
const count = self.textRecordCount();
// Decryption is not in place, so encrypted books need somewhere to put the // plaintext of the record being worked on. var scratch: []u8 = &.{}; defer gpa.free(scratch); if (self.drm_key != null) { var longest: usize = 0; for (1..count + 1) |i| { const raw = self.record(i) orelse continue; longest = @max(longest, raw.len); } scratch = try gpa.alloc(u8, longest); }
switch (self.palmdoc_header.compression) { .none => for (1..count + 1) |i| { try out.appendSlice(gpa, self.plainRecord(i, scratch) orelse continue); }, .palmdoc => for (1..count + 1) |i| { try palmdoc.decompressAppend(gpa, &out, self.plainRecord(i, scratch) orelse continue); }, .huff_cdic => { var decoder = try self.huffDecoder(gpa); defer decoder.deinit(); for (1..count + 1) |i| { try decoder.decompressAppend(&out, self.plainRecord(i, scratch) orelse continue); } }, _ => return error.UnsupportedCompression, }
// text_length is authoritative; records are padded out to a fixed size. const declared = self.palmdoc_header.text_length; if (declared < out.items.len) out.shrinkRetainingCapacity(declared); return out.toOwnedSlice(gpa);}
/// Like `readText`, but transcoded to UTF-8.pub fn readTextUtf8(self: MOBI, gpa: std.mem.Allocator) Error![]u8 { const raw = try self.readText(gpa); defer gpa.free(raw); return encoding.toUtf8(gpa, self.textEncoding(), raw);}
fn huffDecoder(self: MOBI, gpa: std.mem.Allocator) Error!huffcdic.Decoder { const first = self.mobi_header.first_huff_record; const n = self.mobi_header.huff_record_count; if (n == 0 or first == 0 or first + n > self.records.len) return error.MissingHuffRecords;
const huff = self.record(first) orelse return error.MissingHuffRecords; const cdics = try gpa.alloc([]const u8, n - 1); defer gpa.free(cdics); for (cdics, 0..) |*slot, i| { slot.* = self.record(first + 1 + i) orelse return error.MissingHuffRecords; } return huffcdic.Decoder.init(gpa, huff, cdics);}
// -- Decryption ------------------------------------------------------------
/// The file version, following the same rules libmobi uses. This selects where/// the legacy scheme keeps its key, so it is more than cosmetic.pub fn fileVersion(self: MOBI) u32 { if (!std.mem.eql(u8, &self.header.type, "BOOK")) return 1; if (!std.mem.eql(u8, &self.header.creator, "MOBI")) return 1; if (self.mobi_header.header_length < 0x18) return 2; if (self.mobi_header.file_version > 1) return self.mobi_header.file_version; return 1;}
pub fn isEncrypted(self: MOBI) bool { return self.palmdoc_header.encryption != .none;}
/// Recover the book key without any device-specific material. This works for/// legacy-encrypted files, whose key is embedded in record zero, and for DRM/// files whose voucher is sealed with the internal reader key rather than bound/// to a particular device. Returns `error.KeyNotFound` when a device key is/// required.pub fn unlock(self: *MOBI) Error!void { return self.unlockInner(null);}
/// Recover the book key using the serial number of the device the book is tied/// to. Serials are either 16 or 40 characters.pub fn unlockWithSerial(self: *MOBI, serial: []const u8) Error!void { return self.unlockWithPid(try drm.pidFromSerial(serial));}
pub fn unlockWithPid(self: *MOBI, pid: drm.Pid) Error!void { return self.unlockInner(drm.pidKey(pid));}
/// Install a book key directly, for callers that already have one. No/// derivation and no validation: a wrong key yields garbage text, not an error.pub fn unlockWithKey(self: *MOBI, key: pc1.Key) void { self.drm_key = key; self.drm_validity = null;}
fn unlockInner(self: *MOBI, device_key: ?pc1.Key) Error!void { switch (self.palmdoc_header.encryption) { .none => return, .legacy => { self.drm_key = try self.legacyKey(); return; }, .drm => {}, _ => return error.UnsupportedEncryption, }
const vouchers = try self.voucherBytes(); var i: usize = 0; while (i + drm.voucher_size <= vouchers.len) : (i += drm.voucher_size) { const voucher = drm.Voucher.parse(vouchers[i..][0..drm.voucher_size]);
// A device key, when supplied, takes precedence over the internal one. if (device_key) |key| { if (drm.openVoucher(voucher, key, drm.key_type_device)) |opened| { self.drm_key = opened.key; self.drm_validity = opened; return; } } if (drm.openVoucher(voucher, drm.internal_reader_key, drm.key_type_internal)) |opened| { self.drm_key = opened.key; self.drm_validity = opened; return; } } return error.KeyNotFound;}
/// The legacy scheme stores the book key in record zero, obfuscated with a/// fixed vector. Where exactly depends on the file version.////// The offsets below follow libmobi. They could not be checked against a real/// encrypted file here, so treat them as the least certain part of this module.fn legacyKey(self: MOBI) Error!pc1.Key { const r0 = self.record(0) orelse return error.BadDrmHeader; const version = self.fileVersion(); const offset: usize = if (version <= 1) 14 else 16 + self.mobi_header.header_length + @as(usize, if (version > 2) 12 else 0);
if (offset > r0.len or r0.len - offset < pc1.key_size) return error.BadDrmHeader; var key: pc1.Key = undefined; pc1.decrypt(&key, r0[offset..][0..pc1.key_size], drm.internal_reader_key_v1); return key;}
/// The DRM section of record zero, holding `drm_count` vouchers.fn voucherBytes(self: MOBI) Error![]const u8 { const offset = self.mobi_header.drm_offset; const count = self.mobi_header.drm_count; if (offset == 0xffff_ffff or count == 0) return error.KeyNotFound;
const r0 = self.record(0) orelse return error.BadDrmHeader; if (offset > r0.len) return error.BadDrmHeader;
const available = (r0.len - offset) / drm.voucher_size; const wanted = @min(count, available); if (wanted == 0) return error.BadDrmHeader; return r0[offset..][0 .. wanted * drm.voucher_size];}
// -- Images ----------------------------------------------------------------
pub const ImageFormat = enum { jpeg, png, gif, bmp,
pub fn extension(self: ImageFormat) []const u8 { return switch (self) { .jpeg => "jpg", .png => "png", .gif => "gif", .bmp => "bmp", }; }};
pub fn detectImage(data: []const u8) ?ImageFormat { if (data.len >= 3 and std.mem.eql(u8, data[0..3], "\xff\xd8\xff")) return .jpeg; if (data.len >= 8 and std.mem.eql(u8, data[0..8], "\x89PNG\r\n\x1a\n")) return .png; if (data.len >= 6 and (std.mem.eql(u8, data[0..6], "GIF87a") or std.mem.eql(u8, data[0..6], "GIF89a"))) return .gif; if (data.len >= 2 and std.mem.eql(u8, data[0..2], "BM")) return .bmp; return null;}
pub const Image = struct { /// Record number the image was found in. index: usize, format: ImageFormat, data: []const u8,};
pub const ImageIterator = struct { mobi: *const MOBI, index: usize, end: usize,
pub fn next(self: *ImageIterator) ?Image { while (self.index < self.end) { const i = self.index; self.index += 1; const data = self.mobi.record(i) orelse continue; const format = detectImage(data) orelse continue; return .{ .index = i, .format = format, .data = data }; } return null; }};
/// Walk the resource records, yielding those that hold a recognizable image./// Non-image resources (FLIS, FCIS, SRCS, fonts, the EOF marker) are skipped.pub fn images(self: *const MOBI) ImageIterator { const first = self.mobi_header.first_image_index; const start: usize = if (first == 0 or first == 0xffff_ffff) self.textRecordCount() + 1 else first; const boundary = self.kf8Boundary(); const end: usize = if (boundary) |b| @min(b, self.records.len) else self.records.len; return .{ .mobi = self, .index = @min(start, end), .end = end };}
// -- Tests -----------------------------------------------------------------
const testing = std.testing;/// Fetched by the build rather than committed; see `build.zig`.const test_book = @embedFile("testdata");
test "parses headers" { var b: MOBI = try .parse(testing.allocator, test_book); defer b.deinit(testing.allocator);
try testing.expectEqualStrings("BOOK", &b.header.type); try testing.expectEqualStrings("MOBI", &b.header.creator); try testing.expectEqualStrings("MOBI", &b.mobi_header.identifier); try testing.expectEqual(Compression.palmdoc, b.palmdoc_header.compression); try testing.expect(!b.has_drm); try testing.expect(!b.isEncrypted()); try testing.expect(std.mem.find(u8, b.full_name, "Origin of Species") != null);
// Record counts, the text encoding and the trailing entry flags all depend // on how Gutenberg generated this particular build of the book, so assert // the relationships between them rather than the values themselves. try testing.expectEqual(@as(usize, b.header.number_of_records), b.recordCount()); try testing.expect(b.recordCount() > 1); try testing.expect(b.textRecordCount() > 0); try testing.expect(b.textRecordCount() < b.recordCount()); switch (b.textEncoding()) { .cp1252, .utf8 => {}, else => return error.TestUnexpectedEncoding, }}
test "reads exth metadata" { var b: MOBI = try .parse(testing.allocator, test_book); defer b.deinit(testing.allocator);
const exth = b.exth orelse return error.TestExpectedExth; try testing.expectEqualStrings("Charles Darwin", b.author().?); try testing.expect(std.mem.startsWith(u8, exth.get(.publishing_date).?, "1999-12-01")); try testing.expect(std.mem.find(u8, exth.get(.rights).?, "Public domain") != null); try testing.expect(std.mem.find(u8, b.title(), "Origin of Species") != null); try testing.expectEqual(@as(?u32, null), b.kf8Boundary());
// Several values can share one key, which is why they are stored as lists. const subjects = exth.getAll(.subject); try testing.expect(subjects.len >= 2); var found_subject = false; for (subjects) |s| { if (std.mem.eql(u8, s, "Natural selection")) found_subject = true; } try testing.expect(found_subject);
// A key that is not present must come back empty rather than failing. try testing.expectEqual(@as(usize, 0), exth.getAll(.dictname).len); try testing.expectEqual(@as(?[]const u8, null), exth.get(.dictname));}
test "decompresses the full text" { const gpa = testing.allocator; var b: MOBI = try .parse(gpa, test_book); defer b.deinit(gpa);
const text = try b.readText(gpa); defer gpa.free(text);
// Landing exactly on text_length is a strong check: it only happens when // every record was trimmed of its trailing entries and decompressed // correctly, since an error in either shifts the total. try testing.expectEqual(@as(usize, b.palmdoc_header.text_length), text.len); try testing.expect(std.mem.startsWith(u8, text, "<html")); try testing.expect(std.mem.find(u8, text, "Charles Darwin") != null); try testing.expect(std.mem.find(u8, text, "natural selection") != null);}
test "trailing data entries must be stripped" { const gpa = testing.allocator; var b: MOBI = try .parse(gpa, test_book); defer b.deinit(gpa); // Nothing to strip in a build of the book that carries no trailing entries. if (b.extra_data_flags == 0) return;
const good = try b.readText(gpa); defer gpa.free(good);
// Claiming there are no trailing entries feeds their bytes to the // decompressor, so the result must not come back intact. b.extra_data_flags = 0; if (b.readText(gpa)) |bad| { defer gpa.free(bad); try testing.expect(!std.mem.eql(u8, good, bad)); } else |_| {}}
test "transcodes text to utf-8" { const gpa = testing.allocator; var b: MOBI = try .parse(gpa, test_book); defer b.deinit(gpa);
const text = try b.readTextUtf8(gpa); defer gpa.free(text); try testing.expect(std.unicode.utf8ValidateSlice(text));}
test "finds embedded images" { var b: MOBI = try .parse(testing.allocator, test_book); defer b.deinit(testing.allocator);
var count: usize = 0; var it = b.images(); while (it.next()) |img| { try testing.expect(img.data.len > 0); try testing.expect(img.index >= b.mobi_header.first_image_index); count += 1; } try testing.expect(count > 0);}
test "rejects a file that is not a mobi" { const gpa = testing.allocator; try testing.expectError(error.EndOfStream, MOBI.parse(gpa, "too short"));
// A valid PDB header whose record zero does not start with "MOBI". var buf: [512]u8 = @splat(0); @memcpy(buf[60..64], "BOOK"); @memcpy(buf[64..68], "MOBI"); std.mem.writeInt(u16, buf[76..78], 1, .big); // one record std.mem.writeInt(u32, buf[78..82], 96, .big); // record zero offset try testing.expectError(error.NotAMobiFile, MOBI.parse(gpa, &buf));}
// -- Decryption tests ------------------------------------------------------//// There is no DRM-protected sample to test against, so these build one: the// sample book's text records are re-encrypted with a known key and the headers// rewritten to match. That exercises the whole path — trim, decrypt, decompress// — and the voucher handling, but it cannot confirm the header offsets a real// Mobipocket file uses. `legacyKey` in particular is unverified.
/// Free padding inside record zero of the sample book, past the EXTH header and/// the full name, where a synthetic DRM section can go.const test_voucher_offset = 1024;
/// Offsets of the DRM fields within the MOBI header.const drm_offset_field = 152;const drm_count_field = 156;const drm_size_field = 160;
fn encryptedCopy( gpa: std.mem.Allocator, scheme: Encryption, key: pc1.Key, voucher: ?[drm.voucher_size]u8,) ![]u8 { var plain: MOBI = try .parse(gpa, test_book); defer plain.deinit(gpa);
const bytes = try gpa.dupe(u8, test_book); errdefer gpa.free(bytes);
const r0 = plain.records[0].offset; // The voucher goes in record zero's padding. Make sure it really is // padding in this build of the book before writing over it. const name_end = plain.mobi_header.full_name_offset + plain.mobi_header.full_name_length; try testing.expect(test_voucher_offset >= name_end); try testing.expect(plain.record(0).?.len >= test_voucher_offset + drm.voucher_size);
// The encryption type sits at offset 12 of the PalmDOC header. std.mem.writeInt(u16, bytes[r0 + 12 ..][0..2], @backingInt(scheme), .big);
if (voucher) |v| { const mobi_start = r0 + 16; std.mem.writeInt(u32, bytes[mobi_start + drm_offset_field ..][0..4], test_voucher_offset, .big); std.mem.writeInt(u32, bytes[mobi_start + drm_count_field ..][0..4], 1, .big); std.mem.writeInt(u32, bytes[mobi_start + drm_size_field ..][0..4], drm.voucher_size, .big); @memcpy(bytes[r0 + test_voucher_offset ..][0..drm.voucher_size], &v); }
// Only the part left after trailing entries are removed is encrypted. for (1..plain.textRecordCount() + 1) |i| { const start = plain.records[i].offset; const end = if (i + 1 < plain.records.len) plain.records[i + 1].offset else bytes.len; const len = plain.trimTrailingEntries(bytes[start..end]).len; pc1.encrypt(bytes[start..][0..len], bytes[start..][0..len], key); } return bytes;}
fn expectMatchesPlaintext(gpa: std.mem.Allocator, book: *const MOBI) !void { const text = try book.readText(gpa); defer gpa.free(text);
var plain: MOBI = try .parse(gpa, test_book); defer plain.deinit(gpa); const expected = try plain.readText(gpa); defer gpa.free(expected);
try testing.expectEqualStrings(expected, text);}
test "an encrypted book reports itself as locked" { const gpa = testing.allocator; const bytes = try encryptedCopy(gpa, .legacy, "sixteen byte key".*, null); defer gpa.free(bytes);
var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
try testing.expect(book.isEncrypted()); try testing.expectEqual(Encryption.legacy, book.palmdoc_header.encryption); try testing.expectError(error.Locked, book.readText(gpa)); // Metadata is outside the encrypted records, so it stays readable. try testing.expectEqualStrings("Charles Darwin", book.author().?);}
test "decrypts text once a key is installed" { const gpa = testing.allocator; const key: pc1.Key = "sixteen byte key".*; const bytes = try encryptedCopy(gpa, .legacy, key, null); defer gpa.free(bytes);
var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
book.unlockWithKey(key); try expectMatchesPlaintext(gpa, &book);}
test "the wrong key does not silently produce the right text" { const gpa = testing.allocator; const bytes = try encryptedCopy(gpa, .legacy, "sixteen byte key".*, null); defer gpa.free(bytes);
var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
// A wrong key yields garbage, which usually fails to decompress; either way // it must not come back as the original text. book.unlockWithKey("wrong key here!!".*); if (book.readText(gpa)) |text| { defer gpa.free(text); try testing.expect(!std.mem.startsWith(u8, text, "<html>")); } else |_| {}}
test "opens a drm voucher sealed with the internal key" { const gpa = testing.allocator; const key: pc1.Key = "another book key".*; const voucher = drm.sealVoucher(key, drm.internal_reader_key, drm.key_type_internal, 0xabad_1dea); const bytes = try encryptedCopy(gpa, .drm, key, voucher); defer gpa.free(bytes);
var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
try testing.expectEqual(Encryption.drm, book.palmdoc_header.encryption); try testing.expect(book.has_drm); try testing.expectError(error.Locked, book.readText(gpa));
// No device material needed for an internally sealed voucher. try book.unlock(); try testing.expectEqualSlices(u8, &key, &book.drm_key.?); try expectMatchesPlaintext(gpa, &book);}
test "opens a drm voucher bound to a device serial" { const gpa = testing.allocator; const serial = "B0011T2ZQA123456"; const key: pc1.Key = "another book key".*; const device_key = drm.pidKey(try drm.pidFromSerial(serial)); const voucher = drm.sealVoucher(key, device_key, drm.key_type_device, 0x0bad_f00d); const bytes = try encryptedCopy(gpa, .drm, key, voucher); defer gpa.free(bytes);
var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
// A device-bound voucher does not open without the device. try testing.expectError(error.KeyNotFound, book.unlock()); try testing.expectError(error.KeyNotFound, book.unlockWithSerial("9876543210ABCDEF")); try testing.expect(book.drm_key == null);
try book.unlockWithSerial(serial); try testing.expectEqualSlices(u8, &key, &book.drm_key.?); try expectMatchesPlaintext(gpa, &book);
// The voucher was sealed without an expiry. try testing.expectEqual(@as(?u32, null), book.drm_validity.?.expiresAt());}
test "rejects malformed serials and missing drm sections" { const gpa = testing.allocator; const key: pc1.Key = "another book key".*;
const bytes = try encryptedCopy(gpa, .drm, key, null); defer gpa.free(bytes); var book: MOBI = try .parse(gpa, bytes); defer book.deinit(gpa);
try testing.expectError(error.InvalidSerial, book.unlockWithSerial("short")); // The sample book has no DRM section, so there is nothing to open. try testing.expectError(error.KeyNotFound, book.unlock());}
test "unlocking a book that is not encrypted is a no-op" { const gpa = testing.allocator; var book: MOBI = try .parse(gpa, test_book); defer book.deinit(gpa);
try book.unlock(); try testing.expect(book.drm_key == null); try expectMatchesPlaintext(gpa, &book);}