use std::path::Path;
use super::schema::{ChangeFragmentFile, FragmentDoc};
pub(crate) type FragmentLoadResult = (Vec<ChangeFragmentFile>, Vec<(String, String)>);
fn validate_file_name(path: &Path) -> Result<String, String> {
let path_str = path.to_string_lossy();
if path_str.contains("..") || path.is_absolute() {
return Err(format!(
"fragment file path must not traverse or be absolute: {path_str}"
));
}
if path_str.contains('/') || path_str.contains('\\') {
return Err(format!(
"fragment file path must not contain subdirectories: {path_str}"
));
}
let name = path
.file_name()
.ok_or_else(|| format!("path has no file name: {}", path.display()))?
.to_string_lossy()
.into_owned();
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(format!(
"fragment file name must be ASCII alphanumeric/hyphen/underscore/dot only: {name}"
));
}
if !name.ends_with(".toml") {
return Err(format!("fragment file must end with .toml: {name}"));
}
Ok(name)
}
pub(crate) fn parse_fragment(path: &Path, content: &str) -> Result<ChangeFragmentFile, String> {
let file_name = validate_file_name(path)?;
let doc: FragmentDoc =
toml::from_str(content).map_err(|e| format!("malformed fragment {file_name}: {e}"))?;
Ok(ChangeFragmentFile {
file_name,
entries: doc.entries,
})
}
pub(crate) fn load_fragments_files(dir: &Path) -> std::io::Result<FragmentLoadResult> {
let mut fragments = Vec::new();
let mut errors = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok((Vec::new(), Vec::new()));
}
Err(error) => return Err(error),
};
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
.collect();
paths.sort();
for path in paths {
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(error) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
errors.push((name, format!("cannot read file: {error}")));
continue;
}
};
let file_name_path = Path::new(path.file_name().unwrap_or_default().to_str().unwrap_or(""));
match parse_fragment(file_name_path, &content) {
Ok(fragment) => fragments.push(fragment),
Err(error) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
errors.push((name, error));
}
}
}
Ok((fragments, errors))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::change::schema::ChangeKind;
#[test]
fn validates_simple_file_name() {
let path = Path::new("71-add-refresh-session.toml");
assert_eq!(
validate_file_name(path).unwrap(),
"71-add-refresh-session.toml"
);
}
#[test]
fn rejects_path_traversal() {
assert!(validate_file_name(Path::new("../etc/passwd.toml")).is_err());
}
#[test]
fn rejects_subdirectory() {
assert!(validate_file_name(Path::new("sub/file.toml")).is_err());
assert!(validate_file_name(Path::new("sub\\file.toml")).is_err());
}
#[test]
fn rejects_non_toml_extension() {
assert!(validate_file_name(Path::new("fragment.txt")).is_err());
}
#[test]
fn rejects_non_ascii() {
assert!(validate_file_name(Path::new("café.toml")).is_err());
}
#[test]
fn parses_valid_fragment() {
let path = Path::new("71-auth-fix.toml");
let content = "\
[[change]]
unit = \"arcature-auth\"
kind = \"compatible\"
summary = \"Fix session refresh edge case\"
";
let fragment = parse_fragment(path, content).expect("parses");
assert_eq!(fragment.file_name, "71-auth-fix.toml");
assert_eq!(fragment.entries.len(), 1);
assert_eq!(fragment.entries[0].kind, ChangeKind::Compatible);
}
#[test]
fn rejects_malformed_toml() {
let path = Path::new("bad.toml");
let content = "not valid toml {{{";
assert!(parse_fragment(path, content).is_err());
}
#[test]
fn rejects_empty_unit() {
let path = Path::new("empty-unit.toml");
let content = "\
[[change]]
unit = \"\"
kind = \"compatible\"
summary = \"x\"
";
let fragment = parse_fragment(path, content).expect("parses (validator catches empty)");
assert_eq!(fragment.entries[0].unit, "");
}
}