use crate::linter::{Diagnostic, LintResult, Severity, Span};
use std::collections::HashSet;
const VALID_TYPES: &[&str] = &[
"simple", "exec", "forking", "oneshot", "dbus", "notify", "idle",
];
const VALID_RESTART: &[&str] = &[
"no",
"on-success",
"on-failure",
"on-abnormal",
"on-watchdog",
"on-abort",
"always",
];
const VALID_TARGETS: &[&str] = &[
"multi-user.target",
"graphical.target",
"default.target",
"network.target",
"network-online.target",
"basic.target",
"sysinit.target",
"rescue.target",
"emergency.target",
"timers.target",
"sockets.target",
];
fn is_section_header(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with('[') && trimmed.ends_with(']')
}
fn extract_section_name(line: &str) -> Option<&str> {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
Some(&trimmed[1..trimmed.len() - 1])
} else {
None
}
}
fn parse_key_value(line: &str) -> Option<(&str, &str)> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
return None;
}
trimmed.split_once('=').map(|(k, v)| (k.trim(), v.trim()))
}
struct SystemdCheckState<'a> {
has_unit_section: bool,
has_service_section: bool,
has_exec_start: bool,
service_type: String,
has_restart: bool,
current_section: String,
required_sections: HashSet<&'a str>,
}
impl SystemdCheckState<'_> {
fn new() -> Self {
Self {
has_unit_section: false,
has_service_section: false,
has_exec_start: false,
service_type: String::new(),
has_restart: false,
current_section: String::new(),
required_sections: HashSet::new(),
}
}
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let mut state = SystemdCheckState::new();
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if is_section_header(trimmed) {
if let Some(section) = extract_section_name(trimmed) {
match section {
"Unit" => state.has_unit_section = true,
"Service" => state.has_service_section = true,
"Install" => {}
_ => {}
}
state.current_section = section.to_string();
}
continue;
}
if let Some((key, value)) = parse_key_value(trimmed) {
match state.current_section.as_str() {
"Unit" => check_unit_key(key, value, &mut state),
"Service" => {
check_service_key(key, value, line_num, trimmed, &mut state, &mut result);
}
"Install" => check_install_key(key, value, line_num, trimmed, &mut result),
_ => {}
}
}
}
check_post_conditions(&state, &mut result);
result
}
fn check_unit_key<'a>(key: &str, value: &'a str, state: &mut SystemdCheckState<'a>) {
if key == "After" || key == "Requires" || key == "Wants" {
for dep in value.split_whitespace() {
state.required_sections.insert(dep);
}
}
}
fn check_service_key(
key: &str,
value: &str,
line_num: usize,
trimmed: &str,
state: &mut SystemdCheckState<'_>,
result: &mut LintResult,
) {
match key {
"Type" => check_service_type(value, line_num, trimmed, state, result),
"ExecStart" => check_exec_start(value, line_num, trimmed, state, result),
"ExecReload" => check_exec_reload(value, line_num, trimmed, result),
"Restart" => check_restart(value, line_num, trimmed, state, result),
"RestartSec" => check_restart_sec(value, line_num, trimmed, result),
"EnvironmentFile" => check_environment_file(value, line_num, trimmed, result),
_ => {}
}
}
fn check_service_type(
value: &str,
line_num: usize,
trimmed: &str,
state: &mut SystemdCheckState<'_>,
result: &mut LintResult,
) {
state.service_type = value.to_string();
if !VALID_TYPES.contains(&value) {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Error,
format!(
"Invalid Type='{}' - must be one of: {} (F087)",
value,
VALID_TYPES.join(", ")
),
span,
));
}
}
fn check_exec_start(
value: &str,
line_num: usize,
trimmed: &str,
state: &mut SystemdCheckState<'_>,
result: &mut LintResult,
) {
state.has_exec_start = true;
let exec_path = value.trim_start_matches(['@', '-', ':', '+', '!']);
if !exec_path.starts_with('/') && !exec_path.is_empty() {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
format!("ExecStart='{}' should use absolute path (F088)", value),
span,
));
}
}
fn check_exec_reload(value: &str, line_num: usize, trimmed: &str, result: &mut LintResult) {
let exec_path = value.trim_start_matches(['@', '-', ':', '+', '!']);
if !exec_path.starts_with('/') && !exec_path.starts_with("kill") {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
"ExecReload should use absolute path or /bin/kill (F089)".to_string(),
span,
));
}
}
fn check_restart(
value: &str,
line_num: usize,
trimmed: &str,
state: &mut SystemdCheckState<'_>,
result: &mut LintResult,
) {
state.has_restart = true;
if !VALID_RESTART.contains(&value) {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Error,
format!(
"Invalid Restart='{}' - must be one of: {} (F090)",
value,
VALID_RESTART.join(", ")
),
span,
));
}
}
fn check_restart_sec(value: &str, line_num: usize, trimmed: &str, result: &mut LintResult) {
let numeric: String = value.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(0) = numeric.parse::<u32>() {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
"RestartSec=0 may cause restart loops - consider a backoff value (F091)".to_string(),
span,
));
}
}
fn check_environment_file(value: &str, line_num: usize, trimmed: &str, result: &mut LintResult) {
let path = value.trim_start_matches('-');
if !path.starts_with('/') {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
format!(
"EnvironmentFile='{}' should use absolute path (F095)",
value
),
span,
));
}
}
fn check_install_key(
key: &str,
value: &str,
line_num: usize,
trimmed: &str,
result: &mut LintResult,
) {
if key == "WantedBy" || key == "RequiredBy" {
for target in value.split_whitespace() {
if !VALID_TARGETS.contains(&target) && !target.ends_with(".target") {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
format!(
"Unusual target '{}' in {} - common targets: {} (F094)",
target,
key,
VALID_TARGETS[..3].join(", ")
),
span,
));
}
}
}
}
fn check_post_conditions(state: &SystemdCheckState<'_>, result: &mut LintResult) {
if !state.has_unit_section {
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Warning,
"Missing [Unit] section - recommended for documentation (F086)".to_string(),
Span::new(1, 1, 1, 1),
));
}
if !state.has_service_section {
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Error,
"Missing [Service] section - required for service units (F086)".to_string(),
Span::new(1, 1, 1, 1),
));
}
if state.has_service_section && !state.has_exec_start {
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Error,
"Missing ExecStart= - required for service units (F088)".to_string(),
Span::new(1, 1, 1, 1),
));
}
if state.has_service_section
&& !state.has_restart
&& (state.service_type.is_empty()
|| state.service_type == "simple"
|| state.service_type == "notify")
{
result.add(Diagnostic::new(
"SYSTEMD001",
Severity::Info,
"Consider adding Restart= policy for service reliability (F090)".to_string(),
Span::new(1, 1, 1, 1),
));
}
}
#[cfg(test)]
#[path = "systemd001_tests_f086_valid.rs"]
mod tests_extracted;