use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::client::resources;
use crate::error::CoreError;
const MAX_ESCAPED_SEGMENT_BYTES: usize = 255;
fn is_safe_segment_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
}
const HEX: &[u8; 16] = b"0123456789ABCDEF";
pub fn segment_escape(segment: &str) -> Result<String, CoreError> {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace member segment is traversal-shaped or empty: \"{segment}\" — \
refusing rather than escaping around it"
),
});
}
if segment.bytes().any(|byte| byte == 0) {
return Err(CoreError::InvalidInput {
reason: format!("workspace member segment carries a NUL byte: \"{segment}\""),
});
}
let mut escaped = String::with_capacity(segment.len());
for byte in segment.bytes() {
if is_safe_segment_byte(byte) {
escaped.push(byte as char);
} else {
escaped.push('%');
escaped.push(char::from(HEX[usize::from(byte >> 4)]));
escaped.push(char::from(HEX[usize::from(byte & 0x0F)]));
}
}
if escaped.len() > MAX_ESCAPED_SEGMENT_BYTES {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace member segment escapes to {} bytes, over the {}-byte \
on-disk name cap: \"{segment}\"",
escaped.len(),
MAX_ESCAPED_SEGMENT_BYTES
),
});
}
Ok(escaped)
}
pub fn segment_unescape(segment: &str) -> Result<String, CoreError> {
let bytes = segment.as_bytes();
let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'%' {
decoded.push(bytes[index]);
index += 1;
continue;
}
let (high, low) = match bytes.get(index + 1..index + 3) {
Some(pair) => (
(pair[0] as char).to_digit(16),
(pair[1] as char).to_digit(16),
),
None => (None, None), };
let (Some(high), Some(low)) = (high, low) else {
return Err(CoreError::InvalidInput {
reason: format!("malformed percent escape in workspace segment: \"{segment}\""),
});
};
decoded.push((high * 16 + low) as u8);
index += 3;
}
if decoded.contains(&0) {
return Err(CoreError::InvalidInput {
reason: format!("decoded workspace segment carries NUL: \"{segment}\""),
});
}
if decoded.contains(&b'/') {
return Err(CoreError::InvalidInput {
reason: format!("decoded workspace segment carries a path separator: \"{segment}\""),
});
}
let decoded = String::from_utf8(decoded).map_err(|_| CoreError::InvalidInput {
reason: format!("decoded workspace segment is not valid UTF-8: \"{segment}\""),
})?;
if decoded.is_empty() || decoded == "." || decoded == ".." {
return Err(CoreError::InvalidInput {
reason: format!(
"decoded workspace segment is traversal-shaped or empty: \"{segment}\""
),
});
}
Ok(decoded)
}
pub fn local_path_for(member_user_path: &str) -> Result<PathBuf, CoreError> {
let mut local = PathBuf::new();
for segment in member_user_path.split('/') {
let escaped = segment_escape(segment).map_err(|err| CoreError::InvalidInput {
reason: format!("workspace member path \"{member_user_path}\" is not mappable: {err}"),
})?;
local.push(escaped);
}
Ok(local)
}
pub fn build_mapping(members: &[String]) -> Result<BTreeMap<String, PathBuf>, CoreError> {
let mut sorted: Vec<&String> = members.iter().collect();
sorted.sort();
sorted.dedup();
if sorted.len() != members.len() {
let duplicate = members
.iter()
.find(|member| members.iter().filter(|other| *other == *member).count() > 1)
.expect("length mismatch proves a duplicate exists");
return Err(CoreError::InvalidInput {
reason: format!(
"workspace member list carries the exact duplicate \"{duplicate}\" — \
refusing rather than absorbing it"
),
});
}
let mut mapping = BTreeMap::new();
let mut folded: BTreeMap<String, String> = BTreeMap::new();
for member in sorted {
let local = local_path_for(member)?;
let rendered = local.to_string_lossy().into_owned();
let key = rendered.to_ascii_lowercase();
if let Some(owner) = folded.get(&key) {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace members \"{owner}\" and \"{member}\" collide on the same \
case-insensitive local path \"{rendered}\" — one would silently \
overwrite the other at checkout"
),
});
}
folded.insert(key, member.clone());
mapping.insert(member.clone(), local);
}
Ok(mapping)
}
pub enum MemberSource {
Zip(Vec<u8>),
Tree {
root: PathBuf,
mapping: BTreeMap<String, PathBuf>,
},
}
impl MemberSource {
pub fn members(&self) -> Result<Vec<String>, CoreError> {
match self {
Self::Zip(bytes) => resources::resource_members(bytes),
Self::Tree { mapping, .. } => Ok(mapping.keys().cloned().collect()),
}
}
pub fn read(&self, user_path: &str) -> Result<Vec<u8>, CoreError> {
match self {
Self::Zip(bytes) => resources::read_member(bytes, user_path),
Self::Tree { root, mapping } => {
let local = mapping
.get(user_path)
.ok_or_else(|| CoreError::InvalidInput {
reason: format!(
"workspace member \"{user_path}\" is not in the recorded \
checkout mapping"
),
})?;
std::fs::read(root.join(local)).map_err(|err| CoreError::InvalidInput {
reason: format!(
"workspace member \"{user_path}\" cannot be read from the \
checkout tree: {err}"
),
})
}
}
}
pub fn member_hashes(&self) -> Result<BTreeMap<String, u64>, CoreError> {
match self {
Self::Zip(bytes) => resources::member_hashes(bytes),
Self::Tree { root, mapping } => {
let found = scan_regular_files(root)?;
for (member, local) in mapping {
if !found.contains(local) {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace member \"{member}\" is missing from the \
checkout tree (expected at \"{}\")",
local.to_string_lossy()
),
});
}
}
for file in &found {
if !mapping.values().any(|local| local == file) {
return Err(CoreError::InvalidInput {
reason: format!(
"checkout tree contains local file \"{}\" that is not \
in the recorded mapping — the tree is manifest-scoped",
file.to_string_lossy()
),
});
}
}
let mut hashes = BTreeMap::new();
for (member, local) in mapping {
let bytes =
std::fs::read(root.join(local)).map_err(|err| CoreError::InvalidInput {
reason: format!(
"workspace member \"{member}\" cannot be read from \
the checkout tree: {err}"
),
})?;
let is_descriptor = local.file_name()
== Some(std::ffi::OsStr::new(resources::FOLDER_DESCRIPTOR));
let content = if is_descriptor {
resources::normalize_descriptor(&bytes).unwrap_or(bytes)
} else {
bytes
};
hashes.insert(member.clone(), resources::fnv1a(&content));
}
Ok(hashes)
}
}
}
}
fn scan_regular_files(root: &std::path::Path) -> Result<BTreeSet<PathBuf>, CoreError> {
fn walk(
dir: &std::path::Path,
prefix: &std::path::Path,
found: &mut BTreeSet<PathBuf>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let relative = prefix.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
walk(&entry.path(), &relative, found)?;
} else if file_type.is_file() {
found.insert(relative);
}
}
Ok(())
}
let mut found = BTreeSet::new();
walk(root, std::path::Path::new(""), &mut found)
.map_err(|err| CoreError::Internal(format!("cannot walk checkout tree {root:?}: {err}")))?;
Ok(found)
}