aster_forge_validation/
display.rs

1//! Public display text and asset URL validation helpers.
2//!
3//! Aster services often expose runtime branding or UI-shell configuration such as
4//! titles, descriptions, favicons, wordmarks, and provider icons. Product crates
5//! still own the concrete keys, defaults, and visibility rules. This module only
6//! centralizes the repeated mechanics for trimming bounded display text,
7//! rejecting control characters, and validating asset URL strings that are safe
8//! to place into generated frontend HTML.
9
10use crate::{Result, ValidationError};
11
12/// Normalizes a short display text value.
13///
14/// The value is trimmed, byte-length limited, and rejected when it contains
15/// control characters. Empty values are allowed so product configuration can use
16/// an empty string as a "reset to default" signal.
17///
18/// # Errors
19///
20/// Returns an error when the trimmed value exceeds `max_len` bytes or contains a Unicode control
21/// character.
22pub fn normalize_bounded_display_text(
23    field_name: &str,
24    value: &str,
25    max_len: usize,
26) -> Result<String> {
27    let normalized = value.trim();
28    if normalized.len() > max_len {
29        return Err(ValidationError::new(format!(
30            "{field_name} exceeds {max_len} characters"
31        )));
32    }
33    if strip_control_chars(normalized) != normalized {
34        return Err(ValidationError::new(format!(
35            "{field_name} cannot contain control characters"
36        )));
37    }
38    Ok(normalized.to_string())
39}
40
41/// Removes Unicode control characters from a display string.
42#[must_use]
43pub fn strip_control_chars(value: &str) -> String {
44    value.chars().filter(|ch| !ch.is_control()).collect()
45}
46
47/// Returns a normalized display string or a product default.
48///
49/// This helper is intended for runtime reads where invalid persisted values
50/// should not break public pages. It first strips control characters, then
51/// applies the same length and trimming rules as [`normalize_bounded_display_text`].
52#[must_use]
53pub fn display_text_or_default(
54    value: Option<String>,
55    default: &str,
56    field_name: &str,
57    max_len: usize,
58) -> String {
59    value
60        .map(|value| strip_control_chars(&value))
61        .and_then(|value| normalize_bounded_display_text(field_name, &value, max_len).ok())
62        .filter(|value| !value.is_empty())
63        .unwrap_or_else(|| default.to_string())
64}
65
66/// Normalizes a public frontend asset URL.
67///
68/// Empty values are allowed for optional branding assets. Non-empty values are
69/// trimmed, byte-length limited, rejected when they contain whitespace, and
70/// accepted when they are either a leading-slash path or an absolute `http(s)`
71/// URL. The predicate intentionally mirrors the historical Aster branding
72/// behavior so existing product configuration keeps the same storage semantics.
73///
74/// # Errors
75///
76/// Returns an error when the trimmed value exceeds `max_len` bytes, contains whitespace, or is
77/// neither a root-relative path nor an absolute HTTP(S) URL.
78pub fn normalize_public_asset_url(field_name: &str, value: &str, max_len: usize) -> Result<String> {
79    let normalized = value.trim();
80    if normalized.is_empty() {
81        return Ok(String::new());
82    }
83    if normalized.len() > max_len {
84        return Err(ValidationError::new(format!(
85            "{field_name} exceeds {max_len} characters"
86        )));
87    }
88    if normalized.chars().any(char::is_whitespace) {
89        return Err(ValidationError::new(format!(
90            "{field_name} cannot contain whitespace"
91        )));
92    }
93    if !is_public_asset_url(normalized) {
94        return Err(ValidationError::new(format!(
95            "{field_name} must be an absolute http(s) URL or a root-relative path"
96        )));
97    }
98    Ok(normalized.to_string())
99}
100
101/// Returns whether a value is accepted by [`normalize_public_asset_url`].
102#[must_use]
103pub fn is_public_asset_url(value: &str) -> bool {
104    value.starts_with('/') || value.starts_with("https://") || value.starts_with("http://")
105}
106
107/// Returns a public asset URL or a product default.
108#[must_use]
109pub fn public_asset_url_or_default(value: Option<String>, default: &str) -> String {
110    value
111        .map(|value| value.trim().to_string())
112        .filter(|value| !value.is_empty())
113        .filter(|value| is_public_asset_url(value))
114        .unwrap_or_else(|| default.to_string())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::{
120        display_text_or_default, is_public_asset_url, normalize_bounded_display_text,
121        normalize_public_asset_url, public_asset_url_or_default, strip_control_chars,
122    };
123
124    #[test]
125    fn display_text_trims_allows_empty_and_rejects_control_characters() {
126        assert_eq!(
127            normalize_bounded_display_text("title", "  Aster  ", 20).unwrap(),
128            "Aster"
129        );
130        assert_eq!(
131            normalize_bounded_display_text("title", "  ", 20).unwrap(),
132            ""
133        );
134        assert!(normalize_bounded_display_text("title", "abc", 2).is_err());
135        assert!(normalize_bounded_display_text("title", "hello\nworld", 20).is_err());
136    }
137
138    #[test]
139    fn display_text_default_reader_strips_control_characters_before_fallback() {
140        assert_eq!(strip_control_chars("A\u{0000}ster"), "Aster");
141        assert_eq!(
142            display_text_or_default(
143                Some("  Site\u{0000} Name  ".to_string()),
144                "Default",
145                "title",
146                20
147            ),
148            "Site Name"
149        );
150        assert_eq!(
151            display_text_or_default(Some("  ".to_string()), "Default", "title", 20),
152            "Default"
153        );
154    }
155
156    #[test]
157    fn public_asset_url_trims_allows_empty_and_rejects_invalid_values() {
158        assert_eq!(
159            normalize_public_asset_url("favicon", "  /assets/icon.svg?v=1  ", 2048).unwrap(),
160            "/assets/icon.svg?v=1"
161        );
162        assert_eq!(
163            normalize_public_asset_url("favicon", "  ", 2048).unwrap(),
164            ""
165        );
166        assert!(
167            normalize_public_asset_url("favicon", "https://cdn.example.com/icon 1.svg", 2048)
168                .is_err()
169        );
170        assert!(normalize_public_asset_url("favicon", "javascript:alert(1)", 2048).is_err());
171        assert!(normalize_public_asset_url("favicon", "icons/favicon.svg", 2048).is_err());
172    }
173
174    #[test]
175    fn public_asset_default_reader_accepts_same_url_predicate() {
176        assert!(is_public_asset_url("/favicon.svg"));
177        assert!(is_public_asset_url("https://cdn.example.com/favicon.svg"));
178        assert!(is_public_asset_url("http://cdn.example.com/favicon.svg"));
179        assert!(!is_public_asset_url("favicon.svg"));
180        assert_eq!(
181            public_asset_url_or_default(Some("/custom.svg".to_string()), "/favicon.svg"),
182            "/custom.svg"
183        );
184        assert_eq!(
185            public_asset_url_or_default(Some("bad url".to_string()), "/favicon.svg"),
186            "/favicon.svg"
187        );
188    }
189}