use std::path::{Component, Path};
use super::InstallError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub name: String,
pub version: String,
pub entrypoint: String,
}
pub fn parse_manifest(toml_str: &str) -> Result<Manifest, InstallError> {
let value: toml::Value =
toml_str
.parse()
.map_err(|e: toml::de::Error| InstallError::ManifestParse {
reason: e.to_string(),
})?;
let package = value
.get("package")
.ok_or_else(|| InstallError::ManifestParse {
reason: "missing [package] table".to_owned(),
})?;
let name = read_required_string(package, "name")?;
let version = read_required_string(package, "version")?;
let entrypoint = read_required_string(package, "entrypoint")?;
if entrypoint.is_empty() {
return Err(InstallError::ManifestParse {
reason: "package.entrypoint is empty".to_owned(),
});
}
let bytes = entrypoint.as_bytes();
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
return Err(InstallError::ManifestParse {
reason: format!("package.entrypoint must be a relative path: {entrypoint}"),
});
}
if entrypoint.starts_with("\\\\") || entrypoint.starts_with("//") {
return Err(InstallError::ManifestParse {
reason: format!("package.entrypoint must be a relative path: {entrypoint}"),
});
}
for component in Path::new(&entrypoint).components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => {
return Err(InstallError::ManifestParse {
reason: format!(
"package.entrypoint may not contain `..` segments: {entrypoint}"
),
});
}
Component::RootDir | Component::Prefix(_) => {
return Err(InstallError::ManifestParse {
reason: format!("package.entrypoint must be a relative path: {entrypoint}"),
});
}
}
}
Ok(Manifest {
name,
version,
entrypoint,
})
}
fn read_required_string(table: &toml::Value, key: &str) -> Result<String, InstallError> {
let v = table.get(key).ok_or_else(|| InstallError::ManifestParse {
reason: format!("missing package.{key}"),
})?;
v.as_str()
.map(|s| s.to_owned())
.ok_or_else(|| InstallError::ManifestParse {
reason: format!("package.{key} must be a string"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_manifest() {
let src = r#"
[package]
name = "basic-resume"
version = "0.2.8"
entrypoint = "src/lib.typ"
"#;
let m = parse_manifest(src).expect("minimal manifest parses");
assert_eq!(m.name, "basic-resume");
assert_eq!(m.version, "0.2.8");
assert_eq!(m.entrypoint, "src/lib.typ");
}
#[test]
fn ignores_extra_fields() {
let src = r#"
[package]
name = "basic-resume"
version = "0.2.8"
entrypoint = "src/lib.typ"
authors = ["Some Person"]
license = "MIT"
description = "blah"
keywords = ["cv"]
exclude = [".github"]
[template]
path = "template"
entrypoint = "main.typ"
"#;
let m = parse_manifest(src).expect("manifest with extra fields parses");
assert_eq!(m.name, "basic-resume");
assert_eq!(m.entrypoint, "src/lib.typ");
}
#[test]
fn rejects_missing_entrypoint() {
let src = r#"
[package]
name = "x"
version = "1"
"#;
let err = parse_manifest(src).expect_err("missing entrypoint must fail");
assert!(matches!(err, InstallError::ManifestParse { .. }));
}
#[test]
fn rejects_absolute_entrypoint() {
let src = r#"
[package]
name = "x"
version = "1"
entrypoint = "/etc/passwd"
"#;
let err = parse_manifest(src).expect_err("absolute entrypoint must fail");
match err {
InstallError::ManifestParse { reason } => {
assert!(reason.contains("relative"));
}
other => panic!("expected ManifestParse, got {other:?}"),
}
}
#[test]
fn rejects_dotdot_entrypoint() {
let src = r#"
[package]
name = "x"
version = "1"
entrypoint = "../other/lib.typ"
"#;
let err = parse_manifest(src).expect_err("path-traversal entrypoint must fail");
assert!(matches!(err, InstallError::ManifestParse { .. }));
}
#[test]
fn rejects_windows_drive_letter_entrypoint() {
for src in [
r#"
[package]
name = "x"
version = "1"
entrypoint = "C:\\evil.typ"
"#,
r#"
[package]
name = "x"
version = "1"
entrypoint = "C:lib.typ"
"#,
] {
let err = parse_manifest(src).expect_err("drive-letter entrypoint must fail");
assert!(matches!(err, InstallError::ManifestParse { .. }));
}
}
#[test]
fn rejects_invalid_toml() {
let err = parse_manifest("not valid toml = = =").expect_err("malformed toml must fail");
assert!(matches!(err, InstallError::ManifestParse { .. }));
}
}