Skip to main content

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