#![cfg(feature = "std")]
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::fs;
use std::path::{Path, PathBuf};
use super::ConfigError;
use super::glob::HostPattern;
use super::parser::{ParsedLine, tokenize};
pub const MAX_INCLUDE_DEPTH: usize = 16;
pub fn tokenize_file_with_includes(
path: &Path,
depth: usize,
) -> Result<Vec<ParsedLine>, ConfigError> {
if depth >= MAX_INCLUDE_DEPTH {
return Err(ConfigError::Syntax {
line: 0,
msg: "Include: max depth exceeded".into(),
});
}
let src = fs::read_to_string(path).map_err(|e| ConfigError::Syntax {
line: 0,
msg: alloc::format!("Include: cannot read {}: {e}", path.display()),
})?;
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
expand_includes(tokenize(&src)?, &base_dir, depth)
}
fn tokenize_included_file(path: &Path, depth: usize) -> Result<Vec<ParsedLine>, ConfigError> {
if depth >= MAX_INCLUDE_DEPTH {
return Err(ConfigError::Syntax {
line: 0,
msg: "Include: max depth exceeded".into(),
});
}
let src = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Ok(Vec::new()), };
let base_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
expand_includes(tokenize(&src)?, &base_dir, depth)
}
pub fn expand_includes(
lines: Vec<ParsedLine>,
base_dir: &Path,
depth: usize,
) -> Result<Vec<ParsedLine>, ConfigError> {
let mut out = Vec::with_capacity(lines.len());
for line in lines {
if line.keyword != "include" {
out.push(line);
continue;
}
if line.args.is_empty() {
return Err(ConfigError::BadValue {
line: line.line_no,
keyword: "include".to_string(),
msg: "Include requires at least one path".into(),
});
}
for raw_path in &line.args {
let expanded = expand_tilde(raw_path);
let resolved = resolve_relative(&expanded, base_dir);
let matches = match expand_glob(&resolved) {
Ok(v) => v,
Err(_) => continue, };
for matched in matches {
if depth + 1 >= MAX_INCLUDE_DEPTH {
return Err(ConfigError::Syntax {
line: line.line_no,
msg: "Include: max depth exceeded".into(),
});
}
let child_lines = tokenize_included_file(&matched, depth + 1)?;
out.extend(child_lines);
}
}
}
Ok(out)
}
fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = home_dir() {
return home;
}
} else if let Some(rest) = path.strip_prefix("~/")
&& let Some(mut home) = home_dir()
{
home.push(rest);
return home;
}
PathBuf::from(path)
}
fn home_dir() -> Option<PathBuf> {
if let Some(h) = std::env::var_os("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
#[cfg(windows)]
{
if let Some(h) = std::env::var_os("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
}
None
}
fn resolve_relative(path: &Path, base_dir: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn expand_glob(path: &Path) -> std::io::Result<Vec<PathBuf>> {
let segments: Vec<String> = path
.iter()
.map(|s| s.to_string_lossy().into_owned())
.collect();
if segments.is_empty() {
return Ok(Vec::new());
}
let mut frontier: Vec<PathBuf> = Vec::new();
let mut start_idx = 0;
if path.is_absolute() {
frontier.push(PathBuf::from(&segments[0]));
start_idx = 1;
} else {
frontier.push(PathBuf::from("."));
}
for seg in &segments[start_idx..] {
let mut next = Vec::new();
if contains_glob_meta(seg) {
for parent in &frontier {
let read_target = if parent.as_os_str().is_empty() {
PathBuf::from(".")
} else {
parent.clone()
};
let entries = match fs::read_dir(&read_target) {
Ok(it) => it,
Err(_) => continue, };
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if glob_segment_matches(seg, &name_str) {
next.push(parent.join(&*name_str));
}
}
}
} else {
for parent in &frontier {
next.push(parent.join(seg));
}
}
frontier = next;
if frontier.is_empty() {
break;
}
}
if !path.is_absolute() {
frontier = frontier
.into_iter()
.map(|p| match p.strip_prefix(".") {
Ok(suffix) => suffix.to_path_buf(),
Err(_) => p,
})
.collect();
}
frontier.sort();
Ok(frontier)
}
fn contains_glob_meta(s: &str) -> bool {
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'['))
}
fn glob_segment_matches(pattern: &str, name: &str) -> bool {
let pats = [HostPattern::parse(pattern)];
super::glob::host_matches(&pats, name)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(prefix: &str) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path =
std::env::temp_dir().join(alloc::format!("puressh-cfg-{prefix}-{pid}-{nanos}"));
std::fs::create_dir_all(&path).expect("create tempdir");
Self { path }
}
fn write(&self, name: &str, body: &str) -> PathBuf {
let p = self.path.join(name);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
let mut f = std::fs::File::create(&p).expect("create file");
f.write_all(body.as_bytes()).expect("write file");
p
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn contains_glob_meta_works() {
assert!(contains_glob_meta("foo*"));
assert!(contains_glob_meta("foo?"));
assert!(contains_glob_meta("foo[a-z]"));
assert!(!contains_glob_meta("foo.bar"));
}
#[test]
fn expand_glob_no_meta_passes_through() {
let dir = TempDir::new("noglob");
let f = dir.write("plain.cfg", "");
let v = expand_glob(&f).unwrap();
assert_eq!(v, vec![f]);
}
#[test]
fn expand_glob_star_matches_directory_children() {
let dir = TempDir::new("star");
let f1 = dir.write("config.d/a.cfg", "");
let f2 = dir.write("config.d/b.cfg", "");
dir.write("config.d/other.txt", ""); let pattern = dir.path.join("config.d/*.cfg");
let mut v = expand_glob(&pattern).unwrap();
v.sort();
let mut want = vec![f1, f2];
want.sort();
assert_eq!(v, want);
}
#[test]
fn expand_tilde_replaces_leading_tilde() {
unsafe { std::env::set_var("HOME", "/tmp/fake-home") };
let p = expand_tilde("~/foo");
assert_eq!(p, PathBuf::from("/tmp/fake-home/foo"));
let bare = expand_tilde("~");
assert_eq!(bare, PathBuf::from("/tmp/fake-home"));
let unchanged = expand_tilde("nottilde");
assert_eq!(unchanged, PathBuf::from("nottilde"));
}
}