From b3aae93f4194e087c1a91f922e9dcdf078609e17 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 20:14:21 +0100 Subject: [PATCH 1/3] Cache KOReader document hash --- lib/KOReaderSync/KOReaderDocumentId.cpp | 76 +++++++++++++++++++++++++ lib/KOReaderSync/KOReaderDocumentId.h | 11 ++++ 2 files changed, 87 insertions(+) diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index efb18d1b..434748c4 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -4,6 +4,8 @@ #include #include +#include + namespace { // Extract filename from path (everything after last '/') std::string getFilename(const std::string& path) { @@ -15,6 +17,69 @@ std::string getFilename(const std::string& path) { } } // namespace +std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { + // Mirror the Epub cache directory convention so the hash file shares the + // same per-book folder as other cached data. + return std::string("/.crosspoint/epub_") + + std::to_string(std::hash{}(filePath)) + + "/koreader_docid.txt"; +} + +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, + const size_t fileSize) { + if (!Storage.exists(cacheFilePath.c_str())) { + return ""; + } + + const String content = Storage.readFile(cacheFilePath.c_str()); + if (content.isEmpty()) { + return ""; + } + + // Format: "\n<32-char-hex-hash>" + const int newlinePos = content.indexOf('\n'); + if (newlinePos < 0) { + return ""; + } + + const size_t cachedSize = static_cast(content.substring(0, newlinePos).toInt()); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + std::string hash = content.substring(newlinePos + 1).c_str(); + // Trim any trailing whitespace / line endings + while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { + hash.pop_back(); + } + + if (hash.size() != 32) { + return ""; + } + + LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); + return hash; +} + +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, + const size_t fileSize, + const std::string& hash) { + // Ensure the book's cache directory exists before writing + const size_t lastSlash = cacheFilePath.rfind('/'); + if (lastSlash != std::string::npos) { + Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); + } + + String content(std::to_string(fileSize).c_str()); + content += '\n'; + content += hash.c_str(); + + if (!Storage.writeFile(cacheFilePath.c_str(), content)) { + LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str()); + } +} + std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { const std::string filename = getFilename(filePath); if (filename.empty()) { @@ -49,6 +114,15 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { } const size_t fileSize = file.fileSize(); + + // Return persisted hash if the file size hasn't changed since it was cached + const std::string cacheFilePath = getCacheFilePath(filePath); + const std::string cached = loadCachedHash(cacheFilePath, fileSize); + if (!cached.empty()) { + file.close(); + return cached; + } + LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize); // Initialize MD5 builder @@ -92,5 +166,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); + saveCachedHash(cacheFilePath, fileSize, result); + return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index 2b6189e2..a78c134f 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -42,4 +42,15 @@ class KOReaderDocumentId { // Calculate offset for index i: 1024 << (2*i) static size_t getOffset(int i); + + // Hash cache helpers + // Returns the path to the per-book cache file that stores the precomputed hash. + // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). + static std::string getCacheFilePath(const std::string& filePath); + + // Returns the cached hash if the file size matches, or empty string on miss/invalidation. + static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize); + + // Persists the computed hash alongside the file size used to compute it. + static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& hash); }; From 3ca525ef3a8e2a56cf3358614ac5d9349b84617a Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 21:06:51 +0100 Subject: [PATCH 2/3] Add fingerprint --- lib/KOReaderSync/KOReaderDocumentId.cpp | 106 ++++++++++++++++++++---- lib/KOReaderSync/KOReaderDocumentId.h | 21 ++++- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index 434748c4..0d5ea9b3 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -20,13 +20,11 @@ std::string getFilename(const std::string& path) { std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { // Mirror the Epub cache directory convention so the hash file shares the // same per-book folder as other cached data. - return std::string("/.crosspoint/epub_") + - std::to_string(std::hash{}(filePath)) + - "/koreader_docid.txt"; + return std::string("/.crosspoint/epub_") + std::to_string(std::hash{}(filePath)) + "/koreader_docid.txt"; } -std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, - const size_t fileSize) { +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& currentFingerprint) { if (!Storage.exists(cacheFilePath.c_str())) { return ""; } @@ -36,42 +34,105 @@ std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, return ""; } - // Format: "\n<32-char-hex-hash>" + // Format: ":\n<32-char-hex-hash>" const int newlinePos = content.indexOf('\n'); if (newlinePos < 0) { return ""; } - const size_t cachedSize = static_cast(content.substring(0, newlinePos).toInt()); - if (cachedSize != fileSize) { - LOG_DBG("KODoc", "Hash cache invalidated: file size changed (%zu -> %zu)", cachedSize, fileSize); + const String header = content.substring(0, newlinePos); + const int colonPos = header.indexOf(':'); + if (colonPos < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint"); return ""; } + const String sizeTok = header.substring(0, colonPos); + const String fpTok = header.substring(colonPos + 1); + + // Validate the filesize token – it must consist of ASCII digits and parse + // correctly to the expected size. + bool digitsOnly = true; + for (size_t i = 0; i < sizeTok.length(); ++i) { + const char ch = sizeTok[i]; + if (ch < '0' || ch > '9') { + digitsOnly = false; + break; + } + } + if (!digitsOnly) { + LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str()); + return ""; + } + + const long parsed = sizeTok.toInt(); + if (parsed < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str()); + return ""; + } + const size_t cachedSize = static_cast(parsed); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + // Validate stored fingerprint format (8 hex characters) + if (fpTok.length() != 8) { + LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length()); + return ""; + } + for (size_t i = 0; i < fpTok.length(); ++i) { + char c = fpTok[i]; + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c); + return ""; + } + } + + { + String currentFpStr(currentFingerprint.c_str()); + if (fpTok != currentFpStr) { + LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(), + currentFingerprint.c_str()); + return ""; + } + } + std::string hash = content.substring(newlinePos + 1).c_str(); // Trim any trailing whitespace / line endings while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { hash.pop_back(); } + // Hash must be exactly 32 hex characters. if (hash.size() != 32) { + LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size()); return ""; } + for (char c : hash) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c); + return ""; + } + } LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); return hash; } -void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, - const size_t fileSize, - const std::string& hash) { +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& fingerprint, const std::string& hash) { // Ensure the book's cache directory exists before writing const size_t lastSlash = cacheFilePath.rfind('/'); if (lastSlash != std::string::npos) { Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); } + // Format: ":\n" String content(std::to_string(fileSize).c_str()); + content += ':'; + content += fingerprint.c_str(); content += '\n'; content += hash.c_str(); @@ -115,9 +176,24 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { const size_t fileSize = file.fileSize(); - // Return persisted hash if the file size hasn't changed since it was cached + // Compute a lightweight fingerprint from the file's modification time. + // The underlying FsFile API provides getModifyDateTime which returns two + // packed 16-bit values (date and time). Concatenate these as eight hex + // digits to produce the token stored in the cache header. + uint16_t date = 0, time = 0; + if (!file.getModifyDateTime(&date, &time)) { + // If timestamp isn't available for some reason, fall back to a sentinel. + date = 0; + time = 0; + } + char fpBuf[9]; + // two 16-bit numbers => 4 hex digits each + sprintf(fpBuf, "%04x%04x", date, time); + const std::string fingerprintTok(fpBuf); + + // Return persisted hash if the file size and fingerprint haven't changed. const std::string cacheFilePath = getCacheFilePath(filePath); - const std::string cached = loadCachedHash(cacheFilePath, fileSize); + const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok); if (!cached.empty()) { file.close(); return cached; @@ -166,7 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); - saveCachedHash(cacheFilePath, fileSize, result); + saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result); return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index a78c134f..de487c23 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -48,9 +48,22 @@ class KOReaderDocumentId { // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). static std::string getCacheFilePath(const std::string& filePath); - // Returns the cached hash if the file size matches, or empty string on miss/invalidation. - static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize); + // Returns the cached hash if the file size and fingerprint match, or empty + // string on miss/invalidation. + // + // The fingerprint is derived from the file's modification timestamp. We + // call `FsFile::getModifyDateTime` to retrieve the packed date/time fields + // from the filesystem. These two 16‑bit values are concatenated as + // eight hex digits (YYYYYYTTTT? actually date and time bits) and used as a + // lightweight change signal; any change to the file's mtime will cause the + // fingerprint to differ and the cache to be invalidated. Since the full + // document hash is expensive to compute, using mtime gives us a quick way to + // detect modifications without reading file contents. + static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize, + const std::string& currentFingerprint); - // Persists the computed hash alongside the file size used to compute it. - static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& hash); + // Persists the computed hash alongside the file size and fingerprint (the + // modification-timestamp token) used to generate it. + static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint, + const std::string& hash); }; From 39eb75f1c9e11456fa5a268b08cd1be273a99321 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Wed, 25 Feb 2026 21:29:39 +0100 Subject: [PATCH 3/3] Nitpick comment --- lib/KOReaderSync/KOReaderDocumentId.h | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index de487c23..5f226eb5 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -52,13 +52,16 @@ class KOReaderDocumentId { // string on miss/invalidation. // // The fingerprint is derived from the file's modification timestamp. We - // call `FsFile::getModifyDateTime` to retrieve the packed date/time fields - // from the filesystem. These two 16‑bit values are concatenated as - // eight hex digits (YYYYYYTTTT? actually date and time bits) and used as a - // lightweight change signal; any change to the file's mtime will cause the - // fingerprint to differ and the cache to be invalidated. Since the full - // document hash is expensive to compute, using mtime gives us a quick way to - // detect modifications without reading file contents. + // call `FsFile::getModifyDateTime` to retrieve two 16‑bit packed values + // supplied by the filesystem: one for the date and one for the time. These + // are concatenated and represented as eight hexadecimal digits in the form + //