use crate::scan::Asset;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
#[derive(Default, Debug)]
pub struct Stats {
pub maps: usize,
pub unusable: usize,
pub no_content: usize,
pub skipped: usize,
pub written: usize,
pub node_modules: usize,
}
pub struct Plan { pub files: Vec<(String, String)>, pub stats: Stats }
fn is_map(a: &Asset) -> bool { a.name.as_deref().is_some_and(|n| n.ends_with(".map")) }
fn parse(bytes: &[u8]) -> Option<(Vec<(String, String)>, usize)> {
let mut v: Value = serde_json::from_slice(bytes).ok()?;
if v.get("sections").is_some() { return None; }
let root = v.get("sourceRoot").and_then(Value::as_str).filter(|r| !r.is_empty()).map(str::to_owned);
let mut contents = match v.get_mut("sourcesContent") { Some(Value::Array(a)) => std::mem::take(a), _ => Vec::new() };
let Some(Value::Array(sources)) = v.get("sources") else { return None };
let (mut out, mut none) = (Vec::new(), 0);
for (i, src) in sources.iter().enumerate() {
match (src.as_str(), contents.get_mut(i).map(Value::take)) {
(Some(src), Some(Value::String(text))) => {
let full = match &root {
Some(r) if !src.contains("://") && !src.starts_with('/') => format!("{}/{src}", r.trim_end_matches('/')),
_ => src.to_string(),
};
out.push((full, text));
}
_ => none += 1,
}
}
Some((out, none))
}
fn windows_device(c: &str) -> bool {
let stem = c.split('.').next().unwrap_or("").to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem.len() == 4 && (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.as_bytes()[3].is_ascii_digit())
}
pub fn safe_path(src: &str) -> Option<String> {
let mut s = src.split(['?', '#']).next()?.replace('\\', "/");
if let Some(i) = s.find("://") { s = s[i + 3..].to_string(); }
if let Some(rest) = s.strip_prefix("webpack:") { s = rest.to_string(); }
let b = s.as_bytes();
if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() { s = s[2..].to_string(); }
let mut parts: Vec<String> = Vec::new();
for c in s.split('/') {
match c.trim_end_matches(['.', ' ']) {
_ if c.trim_end_matches(' ') == ".." => { parts.pop(); }
"" => {}
t => {
let clean: String = t.chars().map(|ch| if ch.is_control() || r#":*?"<>|"#.contains(ch) { '_' } else { ch }).collect();
parts.push(if windows_device(&clean) { format!("_{clean}") } else { clean });
}
}
}
(!parts.is_empty()).then(|| parts.join("/"))
}
pub fn key(rel: &str) -> String { rel.to_lowercase() }
fn suffixed(base: &str, n: usize) -> String {
match base.rsplit_once('.') {
Some((stem, ext)) if !ext.contains('/') && !stem.ends_with('/') && !stem.is_empty() => format!("{stem}~{n}.{ext}"),
_ => format!("{base}~{n}"),
}
}
fn hash(text: &str) -> u64 { let mut h = DefaultHasher::new(); text.hash(&mut h); h.finish() }
pub fn plan<'a>(assets: impl IntoIterator<Item = &'a Asset>, reserved: &HashSet<String>) -> Plan {
let mut st = Stats::default();
let mut files = Vec::new();
let mut versions: HashMap<String, HashSet<u64>> = HashMap::new();
let mut taken = reserved.clone();
for a in assets.into_iter().filter(|a| is_map(a)) {
st.maps += 1;
let Some((entries, none)) = parse(&a.bytes) else { st.unusable += 1; continue };
st.no_content += none;
for (src, text) in entries {
let Some(base) = safe_path(&src) else { st.skipped += 1; continue };
let seen = versions.entry(key(&base)).or_default();
if !seen.insert(hash(&text)) { continue; }
let mut n = seen.len();
let rel = loop {
let rel = if n == 1 { base.clone() } else { suffixed(&base, n) };
if taken.insert(key(&rel)) { break rel; }
n += 1;
};
files.push((rel, text));
}
}
Plan { files, stats: st }
}
pub fn write(out_dir: &Path, plan: Plan) -> Stats {
let root = out_dir.join("sources");
let mut st = plan.stats;
for (rel, text) in plan.files {
let path = root.join(&rel);
let ok = path.parent().is_none_or(|p| std::fs::create_dir_all(p).is_ok()) && std::fs::write(&path, &text).is_ok();
if !ok { st.skipped += 1; continue; }
if rel.split('/').any(|c| c == "node_modules") { st.node_modules += 1; }
st.written += 1;
}
st
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_cannot_escape() {
let p = |s: &str| safe_path(s);
assert_eq!(p("../../../../packages/lib/x.ts").as_deref(), Some("packages/lib/x.ts"));
assert_eq!(p("webpack:///./src/App.tsx?4a1f").as_deref(), Some("src/App.tsx"));
assert_eq!(p("webpack://app/./src/a.js").as_deref(), Some("app/src/a.js"));
assert_eq!(p("/etc/passwd").as_deref(), Some("etc/passwd"));
assert_eq!(p("C:\\Users\\x\\src\\a.ts").as_deref(), Some("Users/x/src/a.ts"));
assert_eq!(p("src/../../../x.ts").as_deref(), Some("x.ts"));
assert_eq!(p("a/.. /.. /b.ts").as_deref(), Some("b.ts"), "`.. ` is `..` on Windows");
assert_eq!(p("a/trail. /b").as_deref(), Some("a/trail/b"));
assert_eq!(p("a/b:c*d.ts").as_deref(), Some("a/b_c_d.ts"));
assert_eq!(p("x/CON").as_deref(), Some("x/_CON"));
assert_eq!(p("nul.ts").as_deref(), Some("_nul.ts"));
assert_eq!(p("COM1.js").as_deref(), Some("_COM1.js"));
assert_eq!(p("console.ts").as_deref(), Some("console.ts"));
assert_eq!(p("../.."), None);
assert_eq!(p(""), None);
}
fn map_asset(json: &str) -> Asset {
Asset { offset: 0, compressed_len: 0, bytes: json.as_bytes().to_vec(), name: Some("a.js.map".into()), path: Some("/a.js.map".into()) }
}
#[test]
fn plans_conflicts_case_folding_and_unusable_maps() {
let a = map_asset(r#"{"version":3,"sources":["../src/a.ts","../src/b.ts",null,"../../x.ts","src/App.tsx","src/app.tsx"],"sourcesContent":["A",null,"C","X","P","Q"]}"#);
let b = map_asset(r#"{"version":3,"sourceRoot":"/r/","sources":["src/a.ts","../src/a.ts","../src/a.ts"],"sourcesContent":["R","A2","A"]}"#);
let bad = map_asset(r#"{"version":3,"sources":["ok.ts",5],"sourcesContent":["k","v"]}"#);
let index = map_asset(r#"{"version":3,"sections":[]}"#);
let reserved: HashSet<String> = [key("x.ts")].into();
let p = plan([&a, &b, &bad, &index], &reserved);
let got: HashMap<&str, &str> = p.files.iter().map(|(r, t)| (r.as_str(), t.as_str())).collect();
assert_eq!(got["src/a.ts"], "A");
assert_eq!(got["src/a~2.ts"], "A2", "different text under one path is kept");
assert_eq!(got["r/src/a.ts"], "R", "sourceRoot is applied");
assert_eq!(got["x~2.ts"], "X", "a reserved path is not overwritten");
assert_eq!(got["src/App.tsx"], "P");
assert_eq!(got["src/app~2.tsx"], "Q", "case-only difference must not overwrite on macOS/Windows");
assert_eq!(got["ok.ts"], "k", "one bad entry does not discard the map");
assert_eq!(p.files.len(), 7, "the repeated identical `A` is written once");
assert_eq!((p.stats.maps, p.stats.unusable, p.stats.no_content), (4, 1, 3));
}
#[test]
fn many_conflicts_stay_fast() {
let n = 20_000;
let sources = vec!["\"a.ts\""; n].join(",");
let contents: Vec<String> = (0..n).map(|i| format!("\"{i}\"")).collect();
let m = map_asset(&format!(r#"{{"sources":[{sources}],"sourcesContent":[{}]}}"#, contents.join(",")));
let t = std::time::Instant::now();
assert_eq!(plan([&m], &HashSet::new()).files.len(), n);
assert!(t.elapsed().as_secs() < 2, "conflict handling must not be quadratic");
}
#[test]
fn writes_the_plan_inside_sources() {
let dir = std::env::temp_dir().join(format!("tu-sources-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let m = map_asset(r#"{"sources":["../../../../etc/x.ts","node_modules/y/i.js"],"sourcesContent":["X","Y"]}"#);
let st = write(&dir, plan([&m], &HashSet::new()));
assert_eq!(std::fs::read_to_string(dir.join("sources/etc/x.ts")).unwrap(), "X");
assert_eq!((st.written, st.node_modules, st.skipped), (2, 1, 0));
let _ = std::fs::remove_dir_all(&dir);
}
}