use std::path::Path;
use super::Scope;
pub const MANAGED_MARKER: &str = "<!-- Managed by 'all-smi service' -->";
pub const LABEL: &str = "com.lablup.all-smi";
pub const PLIST_TEMPLATE: &str = include_str!("../../packaging/launchd/com.lablup.all-smi.plist");
pub const USER_SCOPE_DROPPED_KEYS: &[&str] = &["UserName", "GroupName", "InitGroups"];
#[derive(Debug, Clone)]
pub struct RenderParams<'a> {
pub scope: Scope,
pub exec_path: &'a Path,
pub log_path: &'a Path,
pub service_user: Option<&'a str>,
}
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error(
"path `{0}` is not valid UTF-8; property lists are XML, so the binary and its log have to \
live at UTF-8 paths to be installed as a service"
)]
NonUtf8Path(String),
#[error(
"path `{0}` contains a control character that XML 1.0 cannot represent; move the file to a \
plainer path"
)]
UnsafePath(String),
#[error(
"account name `{0}` contains a character that is not allowed in a launchd UserName (only \
letters, digits, `_`, `-`, and `.`)"
)]
UnsafeAccount(String),
}
pub fn render_plist(params: &RenderParams<'_>) -> Result<String, RenderError> {
let exec = xml_text(params.exec_path)?;
let log = xml_text(params.log_path)?;
let user_scope = params.scope == Scope::User;
if let Some(account) = params.service_user
&& !user_scope
{
validate_account(account)?;
}
let lines: Vec<&str> = PLIST_TEMPLATE.lines().collect();
let mut out = String::with_capacity(PLIST_TEMPLATE.len() + 512);
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.starts_with("<!DOCTYPE") {
out.push_str(line);
out.push('\n');
out.push_str(MANAGED_MARKER);
out.push('\n');
out.push_str(PROVENANCE_COMMENT);
i += 1;
continue;
}
let Some(key) = parse_key(trimmed) else {
out.push_str(line);
out.push('\n');
i += 1;
continue;
};
let value_end = value_end_index(&lines, i + 1);
let indent = leading_whitespace(line);
match key {
"ProgramArguments" => {
out.push_str(line);
out.push('\n');
out.push_str(&format!("{indent}<array>\n"));
out.push_str(&format!("{indent}\t<string>{exec}</string>\n"));
out.push_str(&format!("{indent}\t<string>api</string>\n"));
out.push_str(&format!("{indent}</array>\n"));
}
"StandardOutPath" | "StandardErrorPath" => {
out.push_str(line);
out.push('\n');
out.push_str(&format!("{indent}<string>{log}</string>\n"));
}
"UserName" => {
if !user_scope {
out.push_str(line);
out.push('\n');
let account = params.service_user.unwrap_or("root");
out.push_str(&format!("{indent}<string>{account}</string>\n"));
}
}
"GroupName" => {
if !user_scope && params.service_user.is_none() {
for l in &lines[i..value_end] {
out.push_str(l);
out.push('\n');
}
}
}
k if user_scope && USER_SCOPE_DROPPED_KEYS.contains(&k) => {}
_ => {
for l in &lines[i..value_end] {
out.push_str(l);
out.push('\n');
}
}
}
i = value_end;
}
Ok(out)
}
pub fn is_managed(contents: &str) -> bool {
contents.lines().any(|l| l.trim() == MANAGED_MARKER)
}
const PROVENANCE_COMMENT: &str = "<!-- Written by `all-smi service install`. Manual edits are lost \
on the next install.\n Put runtime settings in the TOML \
config instead; run `all-smi config path`\n to print the \
active TOML path. -->\n";
fn parse_key(trimmed: &str) -> Option<&str> {
trimmed
.strip_prefix("<key>")
.and_then(|r| r.strip_suffix("</key>"))
}
fn value_end_index(lines: &[&str], start: usize) -> usize {
let mut i = start;
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
if i >= lines.len() {
return lines.len();
}
let opener = lines[i].trim();
let tag = if opener.starts_with("<array>") {
Some(("<array>", "</array>"))
} else if opener.starts_with("<dict>") {
Some(("<dict>", "</dict>"))
} else {
None
};
let Some((open, close)) = tag else {
return i + 1;
};
let mut depth = 0usize;
while i < lines.len() {
let t = lines[i].trim();
if t.starts_with(open) {
depth += 1;
}
if t.starts_with(close) || t.ends_with(close) {
depth = depth.saturating_sub(1);
if depth == 0 {
return i + 1;
}
}
i += 1;
}
lines.len()
}
fn leading_whitespace(line: &str) -> &str {
let end = line
.find(|c: char| !c.is_whitespace())
.unwrap_or(line.len());
&line[..end]
}
fn xml_text(path: &Path) -> Result<String, RenderError> {
let raw = path
.to_str()
.ok_or_else(|| RenderError::NonUtf8Path(path.display().to_string()))?;
if raw.chars().any(|c| c.is_control()) {
return Err(RenderError::UnsafePath(raw.escape_debug().to_string()));
}
Ok(xml_escape(raw))
}
fn xml_escape(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for c in raw.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn validate_account(name: &str) -> Result<(), RenderError> {
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if ok {
Ok(())
} else {
Err(RenderError::UnsafeAccount(name.escape_debug().to_string()))
}
}
#[cfg(test)]
#[path = "plist_tests.rs"]
mod tests;