use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedWikilink {
pub source: String,
pub value: String,
pub display_text: Option<String>,
}
impl ParsedWikilink {
pub fn new(source: impl Into<String>, value: impl Into<String>) -> Self {
Self {
source: source.into(),
value: value.into(),
display_text: None,
}
}
pub fn with_display(
source: impl Into<String>,
value: impl Into<String>,
display: impl Into<String>,
) -> Self {
Self {
source: source.into(),
value: value.into(),
display_text: Some(display.into()),
}
}
pub fn url_source(&self) -> String {
self.source.to_lowercase()
}
pub fn url_value(&self) -> String {
normalize_tag_value(&self.value)
}
pub fn url_path(&self) -> String {
format!("/{}/{}/", self.url_source(), self.url_value())
}
pub fn display(&self) -> &str {
self.display_text.as_deref().unwrap_or(&self.value)
}
pub fn to_markdown_link(&self) -> String {
format!("[{}]({})", self.display(), self.url_path())
}
}
pub fn sanitize_path_component(value: &str) -> String {
value
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.split(['/', '\\'])
.filter(|seg| !seg.is_empty() && *seg != ".." && *seg != "." && !has_drive_prefix(seg))
.collect::<Vec<_>>()
.join("/")
}
fn has_drive_prefix(segment: &str) -> bool {
let bytes = segment.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
pub fn normalize_tag_value(value: &str) -> String {
sanitize_path_component(&value.trim().to_lowercase().replace(' ', "_"))
}
const URL_SCHEMES: &[&str] = &[
"http",
"https",
"mailto",
"tel",
"ftp",
"ftps",
"file",
"data",
"javascript",
"ssh",
"git",
"svn",
"magnet",
];
fn is_url_scheme(source: &str) -> bool {
URL_SCHEMES
.iter()
.any(|scheme| source.eq_ignore_ascii_case(scheme))
}
pub fn transform_wikilinks(input: &str, valid_sources: &HashSet<String>) -> String {
if !input.contains("[[") {
return input.to_string();
}
let mut result = String::with_capacity(input.len());
let mut blocks = BlockScanner::new();
let mut text_start = 0;
let mut pos = 0;
while pos < input.len() {
let line_len = input[pos..]
.find('\n')
.map_or(input.len() - pos, |idx| idx + 1);
let line_end = pos + line_len;
if blocks.classify(&input[pos..line_end]) == LineKind::Code {
push_transformed(&mut result, &input[text_start..pos], valid_sources);
result.push_str(&input[pos..line_end]);
text_start = line_end;
}
pos = line_end;
}
push_transformed(&mut result, &input[text_start..], valid_sources);
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineKind {
Text,
Code,
}
struct BlockScanner {
fence: Option<(u8, usize)>,
after_blank: bool,
indented: bool,
in_list: bool,
}
impl BlockScanner {
const fn new() -> Self {
Self {
fence: None,
after_blank: true,
indented: false,
in_list: false,
}
}
fn classify(&mut self, line: &str) -> LineKind {
let content = line.trim_end_matches(['\n', '\r']);
if let Some((marker, len)) = self.fence {
if is_fence_close(content, marker, len) {
self.fence = None;
}
return LineKind::Code;
}
if content.trim().is_empty() {
self.after_blank = true;
self.indented = false;
return LineKind::Text;
}
let indent = indent_width(content);
if indent >= 4 && !self.in_list && (self.after_blank || self.indented) {
self.indented = true;
self.after_blank = false;
return LineKind::Code;
}
self.indented = false;
self.after_blank = false;
let bare = strip_block_markers(content);
if indent == 0 {
self.in_list = starts_list_item(bare);
}
match fence_open(bare) {
Some(fence) => {
self.fence = Some(fence);
LineKind::Code
}
None => LineKind::Text,
}
}
}
fn indent_width(line: &str) -> usize {
line.bytes()
.take_while(|b| *b == b' ' || *b == b'\t')
.map(|b| if b == b'\t' { 4 } else { 1 })
.sum()
}
fn strip_block_markers(line: &str) -> &str {
let mut rest = line.trim_start_matches([' ', '\t']);
while let Some(after) = rest.strip_prefix('>') {
rest = after.trim_start_matches([' ', '\t']);
}
rest
}
fn marker_run(text: &str, marker: u8) -> usize {
text.bytes().take_while(|b| *b == marker).count()
}
fn fence_open(line: &str) -> Option<(u8, usize)> {
let marker = *line.as_bytes().first()?;
if marker != b'`' && marker != b'~' {
return None;
}
let len = marker_run(line, marker);
if len < 3 || (marker == b'`' && line[len..].contains('`')) {
return None;
}
Some((marker, len))
}
fn is_fence_close(line: &str, marker: u8, len: usize) -> bool {
let bare = strip_block_markers(line);
let run = marker_run(bare, marker);
run >= len && bare[run..].trim().is_empty()
}
fn starts_list_item(line: &str) -> bool {
let bytes = line.as_bytes();
match bytes.first() {
Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ' | b'\t')),
Some(b'0'..=b'9') => {
let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
matches!(bytes.get(digits), Some(b'.' | b')'))
&& matches!(bytes.get(digits + 1), None | Some(b' ' | b'\t'))
}
_ => false,
}
}
fn push_transformed(result: &mut String, text: &str, valid_sources: &HashSet<String>) {
if !text.contains("[[") {
result.push_str(text);
return;
}
let bytes = text.as_bytes();
let mut copied = 0;
let mut pos = 0;
while pos < bytes.len() {
match bytes[pos] {
b'`' => {
let run = marker_run(&text[pos..], b'`');
pos += run;
if let Some(end) = code_span_end(&text[pos..], run) {
pos += end;
}
}
b'[' if bytes.get(pos + 1) == Some(&b'[') => {
let inner_start = pos + 2;
let Some(offset) = text[inner_start..].find("]]") else {
break;
};
let inner = &text[inner_start..inner_start + offset];
if let Some(wikilink) = parse_wikilink_inner(inner, valid_sources) {
result.push_str(&text[copied..pos]);
result.push_str(&wikilink.to_markdown_link());
copied = inner_start + offset + 2;
}
pos = inner_start + offset + 2;
}
_ => pos += 1,
}
}
result.push_str(&text[copied..]);
}
fn code_span_end(text: &str, open: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
match bytes[pos] {
b'`' => {
let run = marker_run(&text[pos..], b'`');
pos += run;
if run == open {
return Some(pos);
}
}
b'\n' => {
let next = pos + 1;
let blank = text[next..]
.bytes()
.take_while(|b| *b != b'\n')
.all(|b| b.is_ascii_whitespace());
if blank {
return None;
}
pos = next;
}
_ => pos += 1,
}
}
None
}
fn parse_wikilink_inner(inner: &str, valid_sources: &HashSet<String>) -> Option<ParsedWikilink> {
let colon_pos = inner.find(':')?;
let source = inner[..colon_pos].trim();
let value = inner[colon_pos + 1..].trim();
if source.is_empty() || value.is_empty() {
return None;
}
if is_url_scheme(source) {
return None;
}
let source_lower = source.to_lowercase();
if !valid_sources
.iter()
.any(|s| s.to_lowercase() == source_lower)
{
return None;
}
Some(ParsedWikilink::new(source, value))
}
pub fn parse_tag_link(dest: &str, valid_sources: &HashSet<String>) -> Option<ParsedWikilink> {
parse_wikilink_inner(dest, valid_sources)
}
pub fn transform_tag_link_dest(dest: &str, valid_sources: &HashSet<String>) -> Option<String> {
parse_tag_link(dest, valid_sources).map(|wl| wl.url_path())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_sources(sources: &[&str]) -> HashSet<String> {
sources.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_normalize_tag_value_basic() {
assert_eq!(normalize_tag_value("rust"), "rust");
assert_eq!(normalize_tag_value("Rust"), "rust");
assert_eq!(normalize_tag_value("RUST"), "rust");
}
#[test]
fn test_normalize_tag_value_spaces() {
assert_eq!(normalize_tag_value("Joshua Jay"), "joshua_jay");
assert_eq!(normalize_tag_value("hello world"), "hello_world");
assert_eq!(normalize_tag_value("a b c"), "a_b_c");
}
#[test]
fn test_normalize_tag_value_trims() {
assert_eq!(normalize_tag_value(" rust "), "rust");
assert_eq!(normalize_tag_value("\tspaced\t"), "spaced");
}
#[test]
fn test_is_url_scheme() {
assert!(is_url_scheme("http"));
assert!(is_url_scheme("HTTP"));
assert!(is_url_scheme("https"));
assert!(is_url_scheme("mailto"));
assert!(is_url_scheme("file"));
assert!(!is_url_scheme("tags"));
assert!(!is_url_scheme("performers"));
assert!(!is_url_scheme("category"));
}
#[test]
fn test_parsed_wikilink_url_path() {
let wl = ParsedWikilink::new("Tags", "Rust");
assert_eq!(wl.url_path(), "/tags/rust/");
let wl = ParsedWikilink::new("performers", "Joshua Jay");
assert_eq!(wl.url_path(), "/performers/joshua_jay/");
}
#[test]
fn test_parsed_wikilink_display() {
let wl = ParsedWikilink::new("tags", "rust");
assert_eq!(wl.display(), "rust");
let wl = ParsedWikilink::with_display("tags", "rust", "Rust Programming");
assert_eq!(wl.display(), "Rust Programming");
}
#[test]
fn test_parsed_wikilink_to_markdown() {
let wl = ParsedWikilink::new("Tags", "rust");
assert_eq!(wl.to_markdown_link(), "[rust](/tags/rust/)");
let wl = ParsedWikilink::new("performers", "Joshua Jay");
assert_eq!(
wl.to_markdown_link(),
"[Joshua Jay](/performers/joshua_jay/)"
);
}
#[test]
fn test_transform_wikilinks_basic() {
let sources = make_sources(&["tags"]);
let input = "See [[Tags:rust]] for more.";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "See [rust](/tags/rust/) for more.");
}
#[test]
fn test_transform_wikilinks_multiple() {
let sources = make_sources(&["tags"]);
let input = "[[Tags:rust]] and [[Tags:programming]] are great.";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"[rust](/tags/rust/) and [programming](/tags/programming/) are great."
);
}
#[test]
fn test_transform_wikilinks_with_spaces() {
let sources = make_sources(&["performers"]);
let input = "Watch [[performers:Joshua Jay]] perform!";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Watch [Joshua Jay](/performers/joshua_jay/) perform!"
);
}
#[test]
fn test_transform_wikilinks_case_insensitive_source() {
let sources = make_sources(&["tags"]);
let input1 = "[[Tags:rust]]";
let input2 = "[[TAGS:rust]]";
let input3 = "[[tags:rust]]";
assert_eq!(transform_wikilinks(input1, &sources), "[rust](/tags/rust/)");
assert_eq!(transform_wikilinks(input2, &sources), "[rust](/tags/rust/)");
assert_eq!(transform_wikilinks(input3, &sources), "[rust](/tags/rust/)");
}
#[test]
fn test_transform_wikilinks_unknown_source() {
let sources = make_sources(&["tags"]);
let input = "[[category:books]]"; let output = transform_wikilinks(input, &sources);
assert_eq!(output, "[[category:books]]"); }
#[test]
fn test_transform_wikilinks_url_scheme_not_matched() {
let sources = make_sources(&["tags", "http"]); let input = "[[http://example.com]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "[[http://example.com]]"); }
#[test]
fn test_transform_wikilinks_nested_source() {
let sources = make_sources(&["taxonomy.tags"]);
let input = "[[taxonomy.tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "[rust](/taxonomy.tags/rust/)");
}
#[test]
fn test_transform_wikilinks_no_closing() {
let sources = make_sources(&["tags"]);
let input = "[[Tags:rust is broken";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "[[Tags:rust is broken"); }
#[test]
fn test_transform_wikilinks_empty_value() {
let sources = make_sources(&["tags"]);
let input = "[[Tags:]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "[[Tags:]]"); }
#[test]
fn test_transform_wikilinks_skips_fenced_code_block() {
let sources = make_sources(&["tags"]);
let input =
"Before [[Tags:rust]].\n\n```markdown\n[[Tags:rust]]\n```\n\nAfter [[Tags:rust]].";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Before [rust](/tags/rust/).\n\n```markdown\n[[Tags:rust]]\n```\n\nAfter [rust](/tags/rust/)."
);
}
#[test]
fn test_transform_wikilinks_skips_tilde_fence() {
let sources = make_sources(&["tags"]);
let input = "~~~\n[[Tags:rust]]\n~~~\n[[Tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "~~~\n[[Tags:rust]]\n~~~\n[rust](/tags/rust/)");
}
#[test]
fn test_transform_wikilinks_skips_longer_fence_containing_shorter_one() {
let sources = make_sources(&["tags"]);
let input = "````\n```\n[[Tags:rust]]\n```\n````\n[[Tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"````\n```\n[[Tags:rust]]\n```\n````\n[rust](/tags/rust/)"
);
}
#[test]
fn test_transform_wikilinks_skips_fence_inside_blockquote() {
let sources = make_sources(&["tags"]);
let input = "> ```\n> [[Tags:rust]]\n> ```\n\n[[Tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"> ```\n> [[Tags:rust]]\n> ```\n\n[rust](/tags/rust/)"
);
}
#[test]
fn test_transform_wikilinks_unclosed_fence_runs_to_end_of_input() {
let sources = make_sources(&["tags"]);
let input = "[[Tags:rust]]\n\n```\n[[Tags:rust]]\nstill code\n[[Tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"[rust](/tags/rust/)\n\n```\n[[Tags:rust]]\nstill code\n[[Tags:rust]]"
);
}
#[test]
fn test_transform_wikilinks_skips_inline_code_span() {
let sources = make_sources(&["tags"]);
let input = "Write `[[Tags:rust]]` to link [[Tags:rust]].";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "Write `[[Tags:rust]]` to link [rust](/tags/rust/).");
}
#[test]
fn test_transform_wikilinks_skips_multi_backtick_span() {
let sources = make_sources(&["tags"]);
let input = "Use ``[[Tags:rust]] and `x` `` then [[Tags:rust]].";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Use ``[[Tags:rust]] and `x` `` then [rust](/tags/rust/)."
);
}
#[test]
fn test_transform_wikilinks_skips_span_wrapping_to_next_line() {
let sources = make_sources(&["tags"]);
let input = "Use `code\n[[Tags:rust]]` here, but [[Tags:rust]] links.";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Use `code\n[[Tags:rust]]` here, but [rust](/tags/rust/) links."
);
}
#[test]
fn test_transform_wikilinks_unmatched_backtick_is_literal() {
let sources = make_sources(&["tags"]);
let input = "A stray ` tick [[Tags:rust]].\n\n[[Tags:rust]] again.";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"A stray ` tick [rust](/tags/rust/).\n\n[rust](/tags/rust/) again."
);
}
#[test]
fn test_transform_wikilinks_skips_indented_code_block() {
let sources = make_sources(&["tags"]);
let input = "Example:\n\n [[Tags:rust]]\n more code\n\nBack to [[Tags:rust]].";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Example:\n\n [[Tags:rust]]\n more code\n\nBack to [rust](/tags/rust/)."
);
}
#[test]
fn test_transform_wikilinks_tab_indented_code_block() {
let sources = make_sources(&["tags"]);
let input = "Example:\n\n\t[[Tags:rust]]\n\nText [[Tags:rust]].";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"Example:\n\n\t[[Tags:rust]]\n\nText [rust](/tags/rust/)."
);
}
#[test]
fn test_transform_wikilinks_indented_paragraph_continuation_is_text() {
let sources = make_sources(&["tags"]);
let input = "A paragraph\n with [[Tags:rust]] wrapped.";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "A paragraph\n with [rust](/tags/rust/) wrapped.");
}
#[test]
fn test_transform_wikilinks_indented_list_continuation_is_text() {
let sources = make_sources(&["tags"]);
let input = "- item\n\n Continued [[Tags:rust]] text.";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "- item\n\n Continued [rust](/tags/rust/) text.");
}
#[test]
fn test_transform_wikilinks_indented_code_after_list_ends() {
let sources = make_sources(&["tags"]);
let input = "- item\n\ntext\n\n [[Tags:rust]]\n";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "- item\n\ntext\n\n [[Tags:rust]]\n");
}
#[test]
fn test_transform_wikilinks_indented_first_line_is_code() {
let sources = make_sources(&["tags"]);
let input = " [[Tags:rust]]\n";
assert_eq!(transform_wikilinks(input, &sources), input);
}
#[test]
fn test_transform_wikilinks_crlf_fence() {
let sources = make_sources(&["tags"]);
let input = "```\r\n[[Tags:rust]]\r\n```\r\n[[Tags:rust]]\r\n";
let output = transform_wikilinks(input, &sources);
assert_eq!(
output,
"```\r\n[[Tags:rust]]\r\n```\r\n[rust](/tags/rust/)\r\n"
);
}
#[test]
fn test_transform_wikilinks_inline_code_in_fence_info_is_not_a_fence() {
let sources = make_sources(&["tags"]);
let input = "``x`` starts a line\n[[Tags:rust]]";
let output = transform_wikilinks(input, &sources);
assert_eq!(output, "``x`` starts a line\n[rust](/tags/rust/)");
}
#[test]
fn test_parse_tag_link_valid() {
let sources = make_sources(&["tags", "performers"]);
let result = parse_tag_link("Tags:rust", &sources);
assert!(result.is_some());
let wl = result.unwrap();
assert_eq!(wl.source, "Tags");
assert_eq!(wl.value, "rust");
assert_eq!(wl.url_path(), "/tags/rust/");
}
#[test]
fn test_parse_tag_link_with_spaces() {
let sources = make_sources(&["performers"]);
let result = parse_tag_link("performers:Joshua Jay", &sources);
assert!(result.is_some());
let wl = result.unwrap();
assert_eq!(wl.value, "Joshua Jay");
assert_eq!(wl.url_path(), "/performers/joshua_jay/");
}
#[test]
fn test_parse_tag_link_url_scheme() {
let sources = make_sources(&["tags", "https"]);
assert!(parse_tag_link("https://example.com", &sources).is_none());
assert!(parse_tag_link("mailto:test@example.com", &sources).is_none());
assert!(parse_tag_link("file:///path/to/file", &sources).is_none());
}
#[test]
fn test_parse_tag_link_unknown_source() {
let sources = make_sources(&["tags"]);
assert!(parse_tag_link("category:books", &sources).is_none());
}
#[test]
fn test_parse_tag_link_no_colon() {
let sources = make_sources(&["tags"]);
assert!(parse_tag_link("just-a-path", &sources).is_none());
assert!(parse_tag_link("/absolute/path", &sources).is_none());
}
#[test]
fn test_transform_tag_link_dest() {
let sources = make_sources(&["tags"]);
assert_eq!(
transform_tag_link_dest("Tags:rust", &sources),
Some("/tags/rust/".to_string())
);
assert_eq!(
transform_tag_link_dest("https://example.com", &sources),
None
);
assert_eq!(transform_tag_link_dest("/regular/path/", &sources), None);
}
#[test]
fn test_sanitize_path_component_normal_values() {
assert_eq!(sanitize_path_component("rust"), "rust");
assert_eq!(sanitize_path_component("hello_world"), "hello_world");
assert_eq!(sanitize_path_component("foo/bar"), "foo/bar");
}
#[test]
fn test_sanitize_path_component_strips_leading_slash() {
assert_eq!(sanitize_path_component("/etc/passwd"), "etc/passwd");
assert_eq!(sanitize_path_component("//absolute"), "absolute");
assert_eq!(sanitize_path_component("///triple"), "triple");
}
#[test]
fn test_sanitize_path_component_removes_dotdot() {
assert_eq!(sanitize_path_component("../../secret"), "secret");
assert_eq!(sanitize_path_component("foo/../bar"), "foo/bar");
assert_eq!(sanitize_path_component("../.."), "");
assert_eq!(sanitize_path_component("a/../../b"), "a/b");
}
#[test]
fn test_sanitize_path_component_removes_single_dot() {
assert_eq!(sanitize_path_component("./foo"), "foo");
assert_eq!(sanitize_path_component("foo/./bar"), "foo/bar");
}
#[test]
fn test_sanitize_path_component_null_bytes() {
assert_eq!(sanitize_path_component("foo\0bar"), "foobar");
assert_eq!(sanitize_path_component("\0"), "");
}
#[test]
fn test_sanitize_path_component_control_chars() {
assert_eq!(sanitize_path_component("foo\x01bar"), "foobar");
assert_eq!(sanitize_path_component("hello\nworld"), "helloworld");
}
#[test]
fn test_sanitize_path_component_complex_attacks() {
assert_eq!(sanitize_path_component("/pol/_phenomena"), "pol/_phenomena");
assert_eq!(
sanitize_path_component("/../../../etc/shadow"),
"etc/shadow"
);
}
#[test]
fn test_sanitize_path_component_empty() {
assert_eq!(sanitize_path_component(""), "");
assert_eq!(sanitize_path_component("/"), "");
assert_eq!(sanitize_path_component("//"), "");
}
#[test]
fn test_sanitize_path_component_backslash_separators() {
assert_eq!(
sanitize_path_component(r"..\..\..\Users\victim\pwned"),
"Users/victim/pwned"
);
assert_eq!(sanitize_path_component(r"foo\bar"), "foo/bar");
assert_eq!(sanitize_path_component(r"\absolute"), "absolute");
assert_eq!(sanitize_path_component(r"a\..\b"), "a/b");
assert_eq!(
sanitize_path_component(r"mixed/back\slash"),
"mixed/back/slash"
);
assert_eq!(
sanitize_path_component(r"\\server\share\evil"),
"server/share/evil"
);
}
#[test]
fn test_sanitize_path_component_drops_drive_prefix() {
assert_eq!(sanitize_path_component(r"C:\Windows\evil"), "Windows/evil");
assert_eq!(sanitize_path_component("C:evil"), "");
assert_eq!(sanitize_path_component("z:"), "");
assert_eq!(sanitize_path_component("ab:cd"), "ab:cd");
assert_eq!(sanitize_path_component("9:30"), "9:30");
}
#[cfg(windows)]
#[test]
fn test_sanitize_path_component_join_stays_inside_output_dir() {
use std::path::{Component, Path};
let output = Path::new(r"C:\build");
for payload in [
r"..\..\..\Users\victim\pwned",
r"C:\Windows\System32\evil",
r"C:evil",
r"\\server\share\evil",
] {
let sanitized = sanitize_path_component(payload);
assert!(
!Path::new(&sanitized).components().any(|component| matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)),
"{payload} kept an escaping component: {sanitized}"
);
let joined = output.join(&sanitized);
assert!(
joined.starts_with(output),
"{payload} escaped --output: {joined:?}"
);
}
}
#[test]
fn test_normalize_tag_value_sanitizes_paths() {
assert_eq!(normalize_tag_value("/etc/passwd"), "etc/passwd");
assert_eq!(normalize_tag_value("../../secret"), "secret");
assert_eq!(normalize_tag_value("/pol/_phenomena"), "pol/_phenomena");
}
#[test]
fn test_normalize_tag_value_sanitizes_windows_paths() {
assert_eq!(
normalize_tag_value(r"..\..\..\Users\victim\pwned"),
"users/victim/pwned"
);
assert_eq!(normalize_tag_value(r"C:\Windows\Temp"), "windows/temp");
}
}