Proper metadata handling in format strings #34

Merged
maleszka merged 28 commits from feature/path-sanitization into main 2026-07-14 17:45:27 +02:00
Owner
No description provided.
PathBuf::with_extension replaces everything after a component's last `.`,
silently discarding substituted metadata that legitimately contains a period
(e.g. "(feat. Somebody)" -> "(feat.ogg"). append_extension preserves the
final component verbatim and only ever appends.
Path::components() classifies a "." or ".." value as CurDir/ParentDir, not
Normal, so the existing NAME_MAX guard (which only inspects Normal
components) let such a value pass straight through to be resolved by the OS
as a real traversal segment at create_dir_all/rename time. reject_dot_components
runs on each field's fully-sanitized value, immediately after sanitize() and
before splicing it into the template, so it also catches values that only
become "."/".."/empty because of a later sanitize() transform.
Threads a new opt-in portable: bool through sanitize()/substitute_tokens/
album_dir_path/track_dest_path so the seven NTFS/exFAT reserved characters
get replaced with their fullwidth Unicode counterpart only when
portable_filenames: true is set in collection.yml. Default stays false,
preserving byte-identical output_path for the common ext4-only case.
U+202A-U+202E and U+2066-U+2069 are legal UTF-8 format characters that pass
through both a C0/C1 control-character filter and today's sanitize()
untouched, enabling RTLO extension-spoofing (MITRE ATT&CK T1036.002). Strip
them unconditionally, regardless of portable_filenames, since this is
security-relevant rather than a portability nicety. reject_dot_components
now also has to catch the case where this strip reduces a raw value to
exactly "." or "..".
Adds the unicode-normalization crate (no std equivalent) and normalizes
each field to NFC as sanitize()'s first step, before any character-level
filtering. Already-NFC input (the common case) round-trips byte-identical.
Without this, two byte-distinct encodings of visually identical metadata
(e.g. Spotify returning NFD where a prior write used NFC) would produce
different output_path values for what a user considers the same track.
Adds apply_portable_rules(relative: &Path, portable: bool) -> PathBuf,
called from album_dir_path and track_dest_path immediately after
substitute_tokens assembles the full relative path and before
check_name_max measures it. Runs once per relative path over
Component::Normal parts, unlike sanitize() which only ever sees one
field's isolated value — needed because the default track_format
combines {track_number:02} and {title} into a single component, so a
title of "CON" must not trigger device-name suffixing on its own.
A no-op when portable is false.
Adds exists_normalized(path) -> bool, used by QueueEntry::for_track and
DownloadQueue::skip_existing in place of the direct .exists() call. ext4
is normalization-agnostic, so a file present under a differently-
normalized form of the same name (e.g. copied from an NFD-storing
source) was invisible to a byte-exact exists() check, causing a spurious
re-download of a track already on disk. The fallback only runs on a
direct miss, and never rewrites output_path — it only affects the
Skipped/not-Skipped decision.
A raw title/artist/album containing a control character (ESC, CR, etc.)
was written to the terminal verbatim via crossterm's Print, enabling
window-title spoofing, cursor manipulation, and OSC/reply injection.
fit_chars/trunc_chars budgeted .chars().count(), treating every character
as one display column; a CJK-heavy title consumed roughly twice its
budgeted columns and broke the renderers' fixed-width row alignment.
Merge the two identical functions into src/ui/text.rs as a single
unicode-width-based implementation shared by every renderer.
format!("{title:<width$}") pads by Unicode scalar count, so a truncated
CJK-heavy title (fewer characters but at its column budget) got padded
with extra spaces on top, overflowing its cell and shifting every column
printed after it. A wide enough overflow pushes the row past the
terminal width, causing the wrapped line to desync lines_drawn bookkeeping
and produce a waterfall of redrawn header lines on every tick.

Add pad_to_width (column-width-aware padding) to src/ui/text.rs and fold
it into display_title_artist, so both renderers print pre-padded,
column-exact cells instead of re-padding through Rust's format width.
UnsafePathComponent check, run on the raw substituted string before it becomes a Path

Fixes an over-rejection bug where an unreferenced field (e.g. artist under the
default album_format/track_format, which never mentions {artist}) could block
enqueueing a track, and closes a more severe gap where a field sanitizing to an
empty string next to a template-literal '/' produced a leading/interior/trailing
empty path segment invisible to Path::components()-based guards — a leading
empty segment in particular lets root.join(...) discard the collection root
entirely, escaping to the real filesystem root with no '..' required.

reject_unsafe_path_segments now runs once per template, against the fully
assembled substituted string via str::split('/'), rather than per-field before
splicing. UserError::UnsafeMetadata is removed in favor of UnsafePathComponent.
Claude-Session: https://claude.ai/code/session_018QUgLv6ZrEPyc3TUtoNwL2
CLAUDE.md Principle VI prohibits comments that reference specification
artifacts, since a spec file is rewritten far more often than the code
that cites it and a stale cross-reference is worse than none. Restates
each comment's substance self-contained instead.

Claude-Session: https://claude.ai/code/session_018QUgLv6ZrEPyc3TUtoNwL2
maleszka deleted branch feature/path-sanitization 2026-07-14 17:45:29 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
maleszka/librespot-dl!34
No description provided.