feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::collection::{
|
||||
Collection, CollectionPatch, CollectionSummary, NewCollection,
|
||||
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
|
||||
};
|
||||
use crate::domain::manga::Manga;
|
||||
use crate::domain::patch::Patch;
|
||||
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
|
||||
"/collections/:id/mangas/:manga_id",
|
||||
delete(remove_manga),
|
||||
)
|
||||
.route("/collections/:id/pages", get(list_pages).post(add_page))
|
||||
.route("/collections/:id/pages/:page_id", delete(remove_page))
|
||||
.route(
|
||||
"/mangas/:id/my-collections",
|
||||
get(list_my_collections_containing),
|
||||
)
|
||||
.route(
|
||||
"/pages/:id/my-collections",
|
||||
get(list_my_collections_containing_page),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
|
||||
pub manga_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPageBody {
|
||||
pub page_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MangaCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PageCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> AppResult<()> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
|
||||
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
async fn list_pages(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) =
|
||||
repo::collection::list_pages(&state.db, id, limit, offset).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn add_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<AddPageBody>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
// FK violation in `repo::collection::add_page` maps to NotFound, so
|
||||
// no separate `repo::page::exists` check is needed — the insert is
|
||||
// the existence check.
|
||||
let created = repo::collection::add_page(&state.db, id, body.page_id).await?;
|
||||
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||
}
|
||||
|
||||
async fn remove_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, collection_id).await?;
|
||||
repo::collection::remove_page(&state.db, collection_id, page_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_my_collections_containing_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> AppResult<Json<PageCollectionIds>> {
|
||||
// Mirrors `list_my_collections_containing` for pages: unknown page
|
||||
// returns an empty list, not 404 — keeps the endpoint side-effect-
|
||||
// free and skips a distinguishing-status oracle.
|
||||
let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id)
|
||||
.await?;
|
||||
Ok(Json(PageCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
/// Returns the row iff the caller owns it. Both "doesn't exist" and
|
||||
/// "exists but belongs to someone else" surface as `NotFound` so the
|
||||
/// API doesn't disclose collection existence to non-owners — the
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod genres;
|
||||
pub mod health;
|
||||
pub mod history;
|
||||
pub mod mangas;
|
||||
pub mod page_tags;
|
||||
pub mod pagination;
|
||||
pub mod tags;
|
||||
|
||||
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(tags::routes())
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
.merge(page_tags::routes())
|
||||
.merge(history::routes())
|
||||
.merge(admin::routes())
|
||||
}
|
||||
|
||||
443
backend/src/api/page_tags.rs
Normal file
443
backend/src/api/page_tags.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
//! Per-page tag endpoints. See `migration 0023` for the underlying
|
||||
//! schema and `repo::page_tag` for the query layer. All endpoints
|
||||
//! require `CurrentUser` — every byte of this data is owned by the
|
||||
//! caller.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::page_tag::{
|
||||
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||
TaggedPageItem,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::repo::page_tag::Order;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/pages/:id/tags", post(add))
|
||||
.route("/pages/:id/tags/:tag", delete(remove))
|
||||
// GET uses `/my-tags` to mirror the `mangas/:id/my-collections`
|
||||
// convention — the URL says whose tags we're reading even
|
||||
// though the cookie already implies it.
|
||||
.route("/pages/:id/my-tags", get(list_for_page))
|
||||
.route("/me/page-tags", get(list_mine))
|
||||
.route("/me/page-tags/distinct", get(list_distinct_mine))
|
||||
.route("/me/page-tags/chapters", get(list_chapters_for_tag))
|
||||
.route("/me/page-tags/mangas", get(list_mangas_for_tag))
|
||||
}
|
||||
|
||||
const MAX_TAG_LEN: usize = 64;
|
||||
/// Hard wire-level byte cap, applied before any allocation in
|
||||
/// `normalize_tag`. A 10MB tag in the JSON body would otherwise
|
||||
/// allocate twice in `to_lowercase()` + `split_whitespace().collect()`
|
||||
/// before the 64-char post-normalize cap rejected it. 4 KiB is a
|
||||
/// generous ~64x the post-normalize char cap and well below anything
|
||||
/// a user could fairly call a "tag".
|
||||
const MAX_TAG_BYTES: usize = 4096;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
const DEFAULT_DISTINCT_LIMIT: i64 = 200;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListMineParams {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Restrict to this exact tag (chip filter in the library tab).
|
||||
#[serde(default)]
|
||||
pub tag: Option<String>,
|
||||
/// Prefix filter (autocomplete-style).
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DistinctParams {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default = "default_distinct_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TagsResponse {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
DEFAULT_LIMIT
|
||||
}
|
||||
fn default_distinct_limit() -> i64 {
|
||||
DEFAULT_DISTINCT_LIMIT
|
||||
}
|
||||
|
||||
/// Normalize a user-supplied tag for storage. Lowercases, trims,
|
||||
/// collapses internal whitespace, rejects control chars and edge
|
||||
/// colons, caps at 64 chars. Single source of truth so the same input
|
||||
/// produces the same row regardless of which client sent it.
|
||||
/// True for Unicode format / invisible chars that would let two
|
||||
/// visually-identical tags coexist as distinct rows — `"funny"` and
|
||||
/// `"funny\u{200d}"` are different codepoints but render the same, and
|
||||
/// the autocomplete + chip cloud would split them. We hardcode the
|
||||
/// well-known offenders (a subset of the Unicode `Cf` general category
|
||||
/// plus the bidi-override block) so the file stays free of new deps.
|
||||
fn is_invisible_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
// Soft hyphen, Arabic letter mark, Mongolian vowel separator.
|
||||
'\u{00AD}' | '\u{061C}' | '\u{180E}'
|
||||
// ZWSP, ZWNJ, ZWJ, LRM, RLM.
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
// LRE / RLE / PDF / LRO / RLO.
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
// Word joiner, function-application markers, deprecated
|
||||
// formatting (U+206A..U+206F).
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206F}'
|
||||
// BOM / ZWNBSP.
|
||||
| '\u{FEFF}'
|
||||
// Plane-14 LANGUAGE TAG + TAG characters. These can tunnel
|
||||
// ASCII semantics invisibly (the same mechanism used in 2024's
|
||||
// LLM prompt-injection smuggling work). Storing them would
|
||||
// let two visually-identical tags coexist while carrying
|
||||
// distinct hidden payloads.
|
||||
| '\u{E0001}'
|
||||
| '\u{E0020}'..='\u{E007F}'
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_tag(input: &str) -> AppResult<String> {
|
||||
// Fast-fail on absurd input before allocating in lowercase /
|
||||
// whitespace passes. Cheap and bounds worst-case work.
|
||||
if input.len() > MAX_TAG_BYTES {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag too long".into(),
|
||||
details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }),
|
||||
});
|
||||
}
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag is required".into(),
|
||||
details: json!({ "tag": "required" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().any(|c| c.is_control()) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains control characters".into(),
|
||||
details: json!({ "tag": "control_chars" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().any(is_invisible_format) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains invisible / format characters".into(),
|
||||
details: json!({ "tag": "invisible_chars" }),
|
||||
});
|
||||
}
|
||||
// Reject characters that would break the DELETE URL path
|
||||
// (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after
|
||||
// routing, so a tag containing one of these would be silently
|
||||
// unreachable to the delete handler — store-only. Reject up front
|
||||
// so every stored tag is removable. `%` and `_` are also rejected
|
||||
// since they are LIKE wildcards in the `list_for_user` prefix
|
||||
// filter and would otherwise let a user accidentally search for
|
||||
// anything.
|
||||
if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag contains a forbidden character (/ \\ ? # % _)".into(),
|
||||
details: json!({ "tag": "forbidden_chars" }),
|
||||
});
|
||||
}
|
||||
// Lowercase + collapse internal whitespace runs (any kind: spaces,
|
||||
// tabs, fullwidth space, etc.) into a single ASCII space. This
|
||||
// keeps "Foo Bar" and "foo bar" identifying the same tag.
|
||||
let normalized: String = trimmed
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
// `namespace:value` is permitted but a bare leading/trailing colon
|
||||
// is nonsense and would break any future split.
|
||||
if normalized.starts_with(':') || normalized.ends_with(':') {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag must not start or end with ':'".into(),
|
||||
details: json!({ "tag": "edge_colon" }),
|
||||
});
|
||||
}
|
||||
if normalized.chars().count() > MAX_TAG_LEN {
|
||||
// "After normalization" because Unicode case-folding can
|
||||
// expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's
|
||||
// pass the wire-level 33-char input but trip this 64 cap).
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag too long after normalization".into(),
|
||||
details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }),
|
||||
});
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
async fn add(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
Json(input): Json<NewPageTag>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let tag = normalize_tag(&input.tag)?;
|
||||
let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?;
|
||||
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((page_id, tag)): Path<(Uuid, String)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
// Normalize the URL-decoded tag the same way add() did, so
|
||||
// DELETE /pages/.../tags/Funny removes the row stored as "funny".
|
||||
let normalized = normalize_tag(&tag)?;
|
||||
repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_for_page(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> AppResult<Json<TagsResponse>> {
|
||||
let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?;
|
||||
Ok(Json(TagsResponse { tags }))
|
||||
}
|
||||
|
||||
async fn list_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<ListMineParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
// Filters from the wire arrive raw — normalize them so a filter on
|
||||
// "Funny" matches rows stored as "funny".
|
||||
let tag_filter = params
|
||||
.tag
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let prefix_filter = params
|
||||
.q
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let (items, total) = repo::page_tag::list_for_user(
|
||||
&state.db,
|
||||
user.id,
|
||||
tag_filter.as_deref(),
|
||||
prefix_filter.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_distinct_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<DistinctParams>,
|
||||
) -> AppResult<Json<DistinctResponse>> {
|
||||
let limit = params.limit.clamp(1, 500);
|
||||
let prefix = params
|
||||
.q
|
||||
.as_deref()
|
||||
.map(normalize_tag)
|
||||
.transpose()?;
|
||||
let items = repo::page_tag::distinct_tags_for_user(
|
||||
&state.db,
|
||||
user.id,
|
||||
prefix.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DistinctResponse { items }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DistinctResponse {
|
||||
items: Vec<PageTagSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AggregateParams {
|
||||
/// Required. Exact tag to aggregate by. Empty / whitespace-only
|
||||
/// inputs are rejected with 422 via `normalize_tag`.
|
||||
pub tag: String,
|
||||
/// `desc` (default) or `asc`. Anything else → 422.
|
||||
#[serde(default)]
|
||||
pub order: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Reserved for the planned OCR text-search input. Accepted on
|
||||
/// the wire so adding OCR later won't break the API shape, but
|
||||
/// rejected with 501 `text_search_not_yet_supported` if non-empty
|
||||
/// until the backend supports it.
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_order(raw: Option<&str>) -> AppResult<Order> {
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") | Some("desc") => Ok(Order::Desc),
|
||||
Some("asc") => Ok(Order::Asc),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!("order must be 'desc' or 'asc' (got {other:?})"),
|
||||
details: json!({ "order": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_text_unsupported(text: Option<&str>) -> AppResult<()> {
|
||||
// Future OCR search will plug in here. Until then, return a
|
||||
// distinct code (`text_search_not_yet_supported`) so clients can
|
||||
// detect "feature pending" vs. a generic 4xx — the code is the
|
||||
// wire contract, not the message.
|
||||
if text.is_some_and(|s| !s.trim().is_empty()) {
|
||||
return Err(AppError::NotImplemented {
|
||||
code: "text_search_not_yet_supported",
|
||||
message: "text search is reserved for the planned OCR input but not yet supported",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_chapters_for_tag(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<AggregateParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
|
||||
ensure_text_unsupported(params.text.as_deref())?;
|
||||
let tag = normalize_tag(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_mangas_for_tag(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<AggregateParams>,
|
||||
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
|
||||
ensure_text_unsupported(params.text.as_deref())?;
|
||||
let tag = normalize_tag(¶ms.tag)?;
|
||||
let order = parse_order(params.order.as_deref())?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
|
||||
&state.db, user.id, &tag, order, limit, offset,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_tag;
|
||||
|
||||
#[test]
|
||||
fn lowercases_and_collapses_whitespace() {
|
||||
assert_eq!(normalize_tag("Funny").unwrap(), "funny");
|
||||
assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar");
|
||||
assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_blank_input() {
|
||||
assert!(normalize_tag("").is_err());
|
||||
assert!(normalize_tag(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars() {
|
||||
assert!(normalize_tag("bad\ntag").is_err());
|
||||
assert!(normalize_tag("a\tb").is_err());
|
||||
// NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
|
||||
// here so a future stdlib drift surfaces as a test fail rather
|
||||
// than a silent acceptance.
|
||||
assert!(normalize_tag("a\u{0085}b").is_err());
|
||||
// NULL byte.
|
||||
assert!(normalize_tag("a\u{0000}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_edge_colon() {
|
||||
assert!(normalize_tag(":foo").is_err());
|
||||
assert!(normalize_tag("foo:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invisible_format_chars() {
|
||||
// ZWJ — visually indistinguishable from no-zwj, would split
|
||||
// "funny" and "funny\u{200d}" into distinct rows.
|
||||
assert!(normalize_tag("funny\u{200d}").is_err());
|
||||
// RLO — bidi override could let a tag display backwards.
|
||||
assert!(normalize_tag("a\u{202e}b").is_err());
|
||||
// ZWNBSP / BOM at the boundary survives `trim()`.
|
||||
assert!(normalize_tag("a\u{feff}b").is_err());
|
||||
// ZWSP, RLM also rejected.
|
||||
assert!(normalize_tag("a\u{200b}b").is_err());
|
||||
assert!(normalize_tag("a\u{200f}b").is_err());
|
||||
// Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
|
||||
// ASCII invisibly via the supplementary tag mechanism.
|
||||
assert!(normalize_tag("a\u{E0001}b").is_err());
|
||||
assert!(normalize_tag("a\u{E0020}b").is_err());
|
||||
assert!(normalize_tag("a\u{E007F}b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_breaking_and_like_wildcards() {
|
||||
// `/` would survive %-encoding round-trip but axum decodes
|
||||
// %2F back to `/` after path routing, so the DELETE path
|
||||
// would never match. Same for `?`, `#`, `\`.
|
||||
assert!(normalize_tag("a/b").is_err());
|
||||
assert!(normalize_tag("a\\b").is_err());
|
||||
assert!(normalize_tag("a?b").is_err());
|
||||
assert!(normalize_tag("a#b").is_err());
|
||||
// `%` and `_` are SQL LIKE wildcards in the prefix filter.
|
||||
assert!(normalize_tag("a%b").is_err());
|
||||
assert!(normalize_tag("a_b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
let long = "a".repeat(65);
|
||||
assert!(normalize_tag(&long).is_err());
|
||||
let ok_len = "a".repeat(64);
|
||||
assert!(normalize_tag(&ok_len).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pathologically_long_input_before_allocating() {
|
||||
// 10 MiB of `a` — should fail-fast on the byte cap, not
|
||||
// allocate twice through the lowercase/whitespace passes.
|
||||
let huge = "a".repeat(10 * 1024 * 1024);
|
||||
assert!(normalize_tag(&huge).is_err());
|
||||
// Just under cap still goes through the rest of the pipeline
|
||||
// and fails on the char count.
|
||||
let near_cap = "a".repeat(super::MAX_TAG_BYTES);
|
||||
assert!(normalize_tag(&near_cap).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user