Merge pull request 'bibfetch: a cover whole to its end marker, and relative redirects with an address in the query (#4, #5)' (#13) from issues-3-6 into main

This commit is contained in:
funkpower committed 2026-11-13 16:33:34 -05:00
commit 9d57fbad67
2 files changed
+80 -4

No files matched your search

+2 -1
View File
@@ -135,7 +135,8 @@ id is asked only when every form of the ISBN asked was answered with no cover; i
asking by ISBN failed, that failure is the result. asking by ISBN failed, that failure is the result.
What comes back is checked before it is written: a JPEG or PNG by its bytes What comes back is checked before it is written: a JPEG or PNG by its bytes
as well as by what the server says, no larger than ten megabytes, reached in at as well as by what the server says, whole to its end marker (`FF D9` for a
JPEG, `IEND` for a PNG), no larger than ten megabytes, reached in at
most three redirects, each over HTTPS to `covers.openlibrary.org`, most three redirects, each over HTTPS to `covers.openlibrary.org`,
`archive.org` or a host under `archive.org`, and never to an address by number. `archive.org` or a host under `archive.org`, and never to an address by number.
One request is made at a time, a `429` or `503` is waited out for as long as One request is made at a time, a `429` or `503` is waited out for as long as
+78 -3
View File
@@ -386,6 +386,23 @@ by ISBN every five minutes from one address"
} }
} }
/// Whether an image's bytes run to its end marker: `FF D9` for a JPEG, which
/// some encoders follow with zero padding, and the `IEND` chunk for a PNG,
/// which is its last twelve bytes.
fn ends_whole(media_type: &str, bytes: &[u8]) -> bool {
match media_type {
"image/jpeg" => {
let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |at| at + 1);
end >= 5 && bytes[..end].ends_with(&[0xFF, 0xD9])
}
_ => {
bytes.len() >= 8 + 12
&& bytes[bytes.len() - 12..].starts_with(&[0, 0, 0, 0])
&& &bytes[bytes.len() - 8..bytes.len() - 4] == b"IEND"
}
}
}
/// Read and check a body that is meant to be a cover. /// Read and check a body that is meant to be a cover.
fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> { fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> {
let declared = header(&response, "content-type") let declared = header(&response, "content-type")
@@ -438,6 +455,14 @@ fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result
"{address}: said {media_type} but is not one" "{address}: said {media_type} but is not one"
))); )));
} }
// The start of an image is not an image. A body sent with no length,
// or a stub, can begin right and stop short; what ends it says it is
// whole.
if !ends_whole(media_type, &bytes) {
return Err(Error::Shape(format!(
"{address}: a {media_type} that stops before its end"
)));
}
// No floor on the size. Open Library's stand-in for a missing cover is a // No floor on the size. Open Library's stand-in for a missing cover is a
// 43-byte, one-pixel GIF, and it serves that only when `default=false` // 43-byte, one-pixel GIF, and it serves that only when `default=false`
// is left off: with it, as every address here has it, a missing cover is // is left off: with it, as every address here has it, a missing cover is
@@ -512,7 +537,12 @@ fn origin(url: &str) -> Option<&str> {
/// Where a `Location` points, read against the address that sent it. /// Where a `Location` points, read against the address that sent it.
fn resolve(from: &str, location: &str) -> String { fn resolve(from: &str, location: &str) -> String {
if location.contains("://") { // Absolute only when the scheme comes first: a relative address can
// carry another address in its query.
if location
.find("://")
.is_some_and(|at| !location[..at].contains(['/', '?', '#']))
{
return location.to_string(); return location.to_string();
} }
if let Some(rest) = location.strip_prefix("//") { if let Some(rest) = location.strip_prefix("//") {
@@ -670,15 +700,19 @@ mod tests {
.agent() .agent()
} }
/// A JPEG's start, filler, and its end marker, `len` bytes in all.
fn jpeg(len: usize) -> Vec<u8> { fn jpeg(len: usize) -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0]; let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.resize(len, 0x11); bytes.resize(len - 2, 0x11);
bytes.extend([0xFF, 0xD9]);
bytes bytes
} }
/// A PNG's signature, filler, and its `IEND` chunk, `len` bytes in all.
fn png(len: usize) -> Vec<u8> { fn png(len: usize) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.resize(len, 0x22); bytes.resize(len - 12, 0x22);
bytes.extend(b"\0\0\0\0IEND\xAE\x42\x60\x82");
bytes bytes
} }
@@ -1170,6 +1204,38 @@ mod tests {
assert_eq!(stub.asked(), [ISBN13, ISBN10]); assert_eq!(stub.asked(), [ISBN13, ISBN10]);
} }
#[test]
fn a_magic_number_alone_is_not_a_cover() {
let mut cut_jpeg = jpeg(4096);
cut_jpeg.truncate(4000);
let mut cut_png = png(4096);
cut_png.truncate(4000);
for (media_type, body) in [
("image/jpeg", vec![0xFF, 0xD8, 0xFF]),
("image/png", b"\x89PNG\r\n\x1a\n".to_vec()),
("image/jpeg", cut_jpeg),
("image/png", cut_png),
] {
let stub = Stub::start(move |_, _| Reply::image(media_type, body.clone()));
let found = stub
.covers()
.fetch(&agent(), "9780306406157", None, CoverSize::Large)
.map(|cover| cover.map(|cover| cover.bytes.len()));
assert!(
matches!(found, Err(Error::Shape(_))),
"{media_type}: {found:?}"
);
}
}
#[test]
fn a_jpeg_padded_after_its_end_marker_is_whole() {
let mut padded = jpeg(2048);
padded.extend([0, 0, 0]);
assert!(ends_whole("image/jpeg", &padded));
assert!(!ends_whole("image/jpeg", &[0xFF, 0xD8, 0xFF, 0, 0]));
}
#[test] #[test]
fn a_redirect_to_an_address_by_number_is_refused() { fn a_redirect_to_an_address_by_number_is_refused() {
// A listener standing in for a service on the LAN; it only counts. // A listener standing in for a service on the LAN; it only counts.
@@ -1217,6 +1283,15 @@ mod tests {
assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x"); assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x");
assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1"); assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1");
assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg"); assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg");
// An address in a relative one's query does not make it absolute.
assert_eq!(
resolve(from, "/view_archive.php?from=https://archive.org/x"),
"https://a.example/view_archive.php?from=https://archive.org/x"
);
assert_eq!(
resolve(from, "x?u=https://c.example/"),
"https://a.example/b/isbn/x?u=https://c.example/"
);
assert_eq!( assert_eq!(
origin("http://127.0.0.1:8080/a?b"), origin("http://127.0.0.1:8080/a?b"),
Some("http://127.0.0.1:8080") Some("http://127.0.0.1:8080")