Files
readest/apps/readest-app/src-tauri/src/range_file.rs
T
Huang Xin 82bd90afc5 feat(reader): random-access file reads on Android via rangefile scheme (#4534)
* feat(reader): random-access file reads on Android via rangefile scheme

NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725).

Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard.

RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(rust): run cargo unit tests in rust_lint

The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless.

Also add a `test:rust` pnpm script and document it as verification done-condition #6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:37:46 +02:00

258 lines
9.0 KiB
Rust

// Custom `rangefile` URI scheme that serves byte ranges of local files to the
// WebView WITHOUT using a `Range` request header.
//
// Why this exists: on Android the WebView mishandles `Range` requests served
// through `shouldInterceptRequest` — it re-applies the range offset to the
// already-sliced intercepted body (skips `start` bytes a second time), so any
// non-zero-start range served by the asset protocol returns corrupt data or
// `net::ERR_FAILED` (Chromium 40739128; tauri-apps/tauri#12019, #3725). That
// makes `RemoteFile`'s random-access reads unusable through the asset protocol
// on Android.
//
// This scheme sidesteps the bug by encoding the range in the URL query
// (`?path=..&start=..&end=..`) instead of a `Range` header. With no `Range`
// header present the WebView performs no offset re-application and delivers the
// 200 body verbatim, while the bytes still stream through the WebView network
// stack (not the slow Tauri IPC bridge). Security mirrors the asset protocol:
// only paths allowed by `asset_protocol_scope` are served.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Component, Path, PathBuf};
use tauri::http::{Request, Response, StatusCode};
use tauri::{AppHandle, Manager, Runtime, UriSchemeContext, UriSchemeResponder};
/// Scheme name; the WebView reaches it at `http://rangefile.localhost/`.
pub const SCHEME: &str = "rangefile";
/// Upper bound on bytes returned for a single request. `RemoteFile` already
/// chunks its reads well below this; the cap just bounds a pathological range.
const MAX_RANGE_LEN: u64 = 8 * 1024 * 1024;
/// Parsed `?path=..&start=..&end=..` query. `end` is inclusive (matches
/// `RemoteFile.fetchRangePart`); omitted `end` means "to EOF".
struct RangeQuery {
path: PathBuf,
start: u64,
end: Option<u64>,
}
fn parse_query(uri_query: Option<&str>) -> Option<RangeQuery> {
let query = uri_query?;
let mut path: Option<PathBuf> = None;
let mut start: u64 = 0;
let mut end: Option<u64> = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
let key = it.next().unwrap_or("");
let val = it.next().unwrap_or("");
match key {
"path" => {
let decoded = percent_encoding::percent_decode_str(val)
.decode_utf8_lossy()
.into_owned();
if !decoded.is_empty() {
path = Some(PathBuf::from(decoded));
}
}
"start" => start = val.parse().unwrap_or(0),
"end" => end = val.parse().ok(),
_ => {}
}
}
Some(RangeQuery {
path: path?,
start,
end,
})
}
/// Defense-in-depth path guard, mirroring the asset protocol's `SafePathBuf`:
/// reject anything that isn't an absolute, traversal-free, NUL-free path BEFORE
/// the scope check. The scope's `is_allowed` already canonicalizes (resolving
/// `..`/symlinks) for existing files, so this is redundant for the security
/// outcome — but it fails closed and keeps the handler obviously-correct
/// instead of relying on that canonicalization subtlety.
fn is_safe_path(path: &Path) -> bool {
path.is_absolute()
&& !path.to_string_lossy().contains('\0')
&& !path.components().any(|c| matches!(c, Component::ParentDir))
}
pub fn handle<R: Runtime>(
ctx: UriSchemeContext<'_, R>,
request: Request<Vec<u8>>,
responder: UriSchemeResponder,
) {
// The handler runs off the UI thread (Android `shouldInterceptRequest` is
// called on a WebView worker thread), so blocking file I/O here is fine.
responder.respond(build_response(ctx.app_handle(), &request));
}
fn cors_origin(request: &Request<Vec<u8>>) -> String {
request
.headers()
.get("origin")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| "*".to_string())
}
fn error(origin: &str, status: StatusCode) -> Response<Vec<u8>> {
Response::builder()
.status(status)
.header("Access-Control-Allow-Origin", origin)
.header("Cache-Control", "no-store")
.body(Vec::new())
.unwrap()
}
fn build_response<R: Runtime>(app: &AppHandle<R>, request: &Request<Vec<u8>>) -> Response<Vec<u8>> {
let origin = cors_origin(request);
let query = match parse_query(request.uri().query()) {
Some(q) => q,
None => return error(&origin, StatusCode::BAD_REQUEST),
};
// Defense-in-depth: reject traversal/NUL/relative paths outright.
if !is_safe_path(&query.path) {
log::warn!("rangefile: rejected unsafe path: {:?}", query.path);
return error(&origin, StatusCode::FORBIDDEN);
}
// Security: identical boundary to the asset protocol — only paths the
// importer/picker has granted are readable.
if !app.asset_protocol_scope().is_allowed(&query.path) {
log::warn!(
"rangefile: path not allowed by asset scope: {:?}",
query.path
);
return error(&origin, StatusCode::FORBIDDEN);
}
let mut file = match File::open(&query.path) {
Ok(f) => f,
Err(e) => {
let status = match e.kind() {
std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
std::io::ErrorKind::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
return error(&origin, status);
}
};
let total = match file.metadata() {
Ok(m) => m.len(),
Err(_) => return error(&origin, StatusCode::INTERNAL_SERVER_ERROR),
};
let start = query.start.min(total);
let last = total.saturating_sub(1);
let end_inclusive = query.end.unwrap_or(last).min(last);
let nbytes = if total == 0 || start > end_inclusive {
0
} else {
(end_inclusive + 1 - start).min(MAX_RANGE_LEN)
};
let mut buf = vec![0u8; nbytes as usize];
if nbytes > 0 {
if file.seek(SeekFrom::Start(start)).is_err() {
return error(&origin, StatusCode::INTERNAL_SERVER_ERROR);
}
let mut filled = 0usize;
while filled < buf.len() {
match file.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(_) => return error(&origin, StatusCode::INTERNAL_SERVER_ERROR),
}
}
buf.truncate(filled);
}
// 200 (not 206) and NO `Content-Range`: the range was carried in the URL,
// not a `Range` header, so the WebView delivers this body verbatim.
Response::builder()
.status(StatusCode::OK)
.header("Access-Control-Allow-Origin", origin)
.header(
"Access-Control-Expose-Headers",
"X-Total-Size, Content-Length",
)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", buf.len().to_string())
.header("X-Total-Size", total.to_string())
.header("Cache-Control", "no-store")
.body(buf)
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_path_start_end() {
let q = parse_query(Some("path=%2Fbooks%2Fa.epub&start=1024&end=2047")).unwrap();
assert_eq!(q.path, PathBuf::from("/books/a.epub"));
assert_eq!(q.start, 1024);
assert_eq!(q.end, Some(2047));
}
#[test]
fn decodes_utf8_path() {
// encodeURIComponent("/书/堂吉诃德.mobi")
let q = parse_query(Some(
"path=%2F%E4%B9%A6%2F%E5%A0%82%E5%90%89%E8%AF%83%E5%BE%B7.mobi&start=0&end=0",
))
.unwrap();
assert_eq!(q.path, PathBuf::from("/书/堂吉诃德.mobi"));
}
#[test]
fn missing_path_is_none() {
assert!(parse_query(Some("start=0&end=10")).is_none());
assert!(parse_query(None).is_none());
}
#[test]
fn end_omitted_means_eof() {
let q = parse_query(Some("path=%2Fa&start=5")).unwrap();
assert_eq!(q.start, 5);
assert_eq!(q.end, None);
}
#[test]
fn ampersand_and_equals_in_path_are_percent_encoded() {
// encodeURIComponent("/a&b=c.epub") -> %2Fa%26b%3Dc.epub
let q = parse_query(Some("path=%2Fa%26b%3Dc.epub&start=0")).unwrap();
assert_eq!(q.path, PathBuf::from("/a&b=c.epub"));
}
#[test]
fn safe_path_accepts_absolute_traversal_free() {
assert!(is_safe_path(Path::new(
"/data/user/0/com.bilingify.readest/Readest/Books/a.epub"
)));
assert!(is_safe_path(Path::new("/书/堂吉诃德.mobi")));
}
#[test]
fn safe_path_rejects_parent_dir_traversal() {
assert!(!is_safe_path(Path::new(
"/data/user/0/com.bilingify.readest/Readest/../../../../etc/passwd"
)));
assert!(!is_safe_path(Path::new("/a/../b")));
}
#[test]
fn safe_path_rejects_relative_and_nul() {
assert!(!is_safe_path(Path::new("data/x/a.epub"))); // not absolute
assert!(!is_safe_path(Path::new("a.epub")));
assert!(!is_safe_path(Path::new("/data/a\0b.epub"))); // NUL byte
}
}