use std::path::{Component, Path, PathBuf};
use crate::error::ToolchainError;
const GLEAM_CONFIG_FILE: &str = "gleam.toml";
const WORKFLOW_CONFIG_FILE: &str = "workflow.toml";
#[derive(serde::Deserialize)]
struct EntryConfig {
#[serde(default)]
workflow: Vec<EntryWorkflow>,
}
#[derive(serde::Deserialize)]
struct EntryWorkflow {
entry_module: String,
}
pub fn validate_project_root(root: &Path) -> Result<(), ToolchainError> {
if !root.join(GLEAM_CONFIG_FILE).is_file() {
return Err(ToolchainError::InvalidProject {
message: format!(
"{} not found under the authoring project root `{}`; the root must be a built Gleam project",
GLEAM_CONFIG_FILE,
root.display()
),
});
}
if !root.join(WORKFLOW_CONFIG_FILE).is_file() {
return Err(ToolchainError::InvalidProject {
message: format!(
"{} not found under the authoring project root `{}`; the root must declare its workflow packaging descriptor",
WORKFLOW_CONFIG_FILE,
root.display()
),
});
}
Ok(())
}
pub fn single_entry_module(root: &Path) -> Result<String, ToolchainError> {
let descriptor = root.join(WORKFLOW_CONFIG_FILE);
let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
path: descriptor.clone(),
source,
})?;
let config: EntryConfig =
toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
message: format!("failed to parse {}: {source}", descriptor.display()),
})?;
match config.workflow.as_slice() {
[single] => Ok(single.entry_module.clone()),
[] => Err(ToolchainError::InvalidProject {
message: format!(
"{} declares no [[workflow]] entry; source submission requires exactly one",
descriptor.display()
),
}),
many => Err(ToolchainError::InvalidProject {
message: format!(
"{} declares {} [[workflow]] entries; source submission requires exactly one entry module to write the submitted source into",
descriptor.display(),
many.len()
),
}),
}
}
pub fn entry_module_source_path(
root: &Path,
entry_module: &str,
) -> Result<PathBuf, ToolchainError> {
if !is_safe_logical_name(entry_module) {
return Err(ToolchainError::InvalidProject {
message: format!(
"entry module `{entry_module}` is not a safe logical module name (no `$`, backslashes, leading separators, or empty/`.`/`..` components)"
),
});
}
let src_root = root.join("src");
let relative: PathBuf = entry_module.split('@').collect::<PathBuf>();
let mut candidate = src_root.join(relative);
candidate.set_extension("gleam");
if !is_confined(&src_root, &candidate) {
return Err(ToolchainError::InvalidProject {
message: format!(
"entry module `{entry_module}` resolves outside the project src directory `{}`",
src_root.display()
),
});
}
Ok(candidate)
}
pub fn write_entry_source(path: &Path, source: &str) -> Result<(), ToolchainError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|io| ToolchainError::Io {
path: parent.to_path_buf(),
source: io,
})?;
}
std::fs::write(path, source.as_bytes()).map_err(|io| ToolchainError::Io {
path: path.to_path_buf(),
source: io,
})
}
fn is_confined(base: &Path, candidate: &Path) -> bool {
let mut depth: i64 = 0;
let Ok(relative) = candidate.strip_prefix(base) else {
return false;
};
for component in relative.components() {
match component {
Component::CurDir => {}
Component::Normal(_) => depth += 1,
Component::ParentDir => {
depth -= 1;
if depth < 0 {
return false;
}
}
Component::RootDir | Component::Prefix(_) => return false,
}
}
depth >= 0
}
fn is_safe_logical_name(logical_name: &str) -> bool {
!logical_name.is_empty()
&& !logical_name.starts_with('/')
&& !logical_name.starts_with('\\')
&& !logical_name.contains('\\')
&& !logical_name.contains('$')
&& logical_name
.split(['/', '@'])
.all(|component| !component.is_empty() && component != "." && component != "..")
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{
entry_module_source_path, is_confined, is_safe_logical_name, single_entry_module,
validate_project_root,
};
use crate::error::ToolchainError;
fn temp_root(label: &str) -> Result<PathBuf, std::io::Error> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
let root = std::env::temp_dir().join(format!("aion-toolchain-{label}-{nanos}"));
std::fs::create_dir_all(&root)?;
Ok(root)
}
#[test]
fn safe_logical_names_accept_nested_modules_and_reject_traversal() {
assert!(is_safe_logical_name("hello_world"));
assert!(is_safe_logical_name("demo@nested"));
assert!(!is_safe_logical_name(""));
assert!(!is_safe_logical_name("../escape"));
assert!(!is_safe_logical_name("demo@.."));
assert!(!is_safe_logical_name("/abs"));
assert!(!is_safe_logical_name("demo$bad"));
assert!(!is_safe_logical_name("demo\\bad"));
}
#[test]
fn entry_module_path_maps_nested_modules_under_src() -> Result<(), Box<dyn std::error::Error>> {
let root = Path::new("/work");
let flat = entry_module_source_path(root, "hello_world")?;
assert_eq!(flat, PathBuf::from("/work/src/hello_world.gleam"));
let nested = entry_module_source_path(root, "demo@nested")?;
assert_eq!(nested, PathBuf::from("/work/src/demo/nested.gleam"));
Ok(())
}
#[test]
fn entry_module_path_rejects_traversal() {
let root = Path::new("/work");
let result = entry_module_source_path(root, "../../etc/passwd");
assert!(matches!(result, Err(ToolchainError::InvalidProject { .. })));
}
#[test]
fn confinement_folds_dotdot_lexically() {
let base = Path::new("/work/src");
assert!(is_confined(base, Path::new("/work/src/demo.gleam")));
assert!(is_confined(base, Path::new("/work/src/demo/nested.gleam")));
assert!(!is_confined(base, Path::new("/work/other.gleam")));
assert!(!is_confined(base, Path::new("/work/src/../secret.gleam")));
}
#[test]
fn validate_project_root_requires_both_manifests() -> Result<(), Box<dyn std::error::Error>> {
let root = temp_root("validate")?;
let cleanup = || {
let _ = std::fs::remove_dir_all(&root);
};
let missing_gleam = validate_project_root(&root);
assert!(matches!(
missing_gleam,
Err(ToolchainError::InvalidProject { .. })
));
std::fs::write(root.join("gleam.toml"), b"name = \"demo\"\n")?;
let missing_workflow = validate_project_root(&root);
assert!(matches!(
missing_workflow,
Err(ToolchainError::InvalidProject { .. })
));
std::fs::write(root.join("workflow.toml"), b"[[workflow]]\n")?;
let ok = validate_project_root(&root);
cleanup();
ok?;
Ok(())
}
#[test]
fn single_entry_module_reads_the_descriptor() -> Result<(), Box<dyn std::error::Error>> {
let root = temp_root("single-entry")?;
std::fs::write(
root.join("workflow.toml"),
b"[[workflow]]\nentry_module = \"hello_world\"\nentry_function = \"run\"\ntimeout_seconds = 30\ninput_schema = \"schemas/input.json\"\noutput_schema = \"schemas/output.json\"\nactivities = []\n",
)?;
let entry = single_entry_module(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(entry?, "hello_world");
Ok(())
}
#[test]
fn many_entry_modules_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
let root = temp_root("many-entry")?;
std::fs::write(
root.join("workflow.toml"),
b"[[workflow]]\nentry_module = \"a\"\n\n[[workflow]]\nentry_module = \"b\"\n",
)?;
let entry = single_entry_module(&root);
let _ = std::fs::remove_dir_all(&root);
assert!(matches!(entry, Err(ToolchainError::InvalidProject { .. })));
Ok(())
}
#[test]
fn zero_entry_modules_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
let root = temp_root("zero-entry")?;
std::fs::write(root.join("workflow.toml"), b"# no workflows declared\n")?;
let entry = single_entry_module(&root);
let _ = std::fs::remove_dir_all(&root);
assert!(matches!(entry, Err(ToolchainError::InvalidProject { .. })));
Ok(())
}
}