use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::thread::sleep;
use std::time::{Duration, Instant};
use thiserror::Error;
use windows_service::service::{
Service, ServiceAccess, ServiceAction, ServiceActionType, ServiceControlAccept,
ServiceErrorControl, ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo,
ServiceStartType, ServiceState, ServiceType,
};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use crate::config::Config;
use crate::platform::windows::event_log;
use crate::platform::windows::service::{
run_service_dispatcher, SERVICE_DISPLAY_NAME, SERVICE_NAME, SERVICE_RELOAD_CONTROL,
};
use crate::tls;
const DEFAULT_CONFIG: &str = r"C:\ProgramData\Alighieri\alighieri.conf";
const SERVICE_CONFIG_MARKER: &str = "service-config-path.txt";
const LOCAL_SERVICE_ACCOUNT: &str = r"NT AUTHORITY\LocalService";
const SERVICE_STOP_TIMEOUT: Duration = Duration::from_secs(30);
const SERVICE_STOP_POLL_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceCommand {
Install { config_path: PathBuf },
Uninstall,
Start,
Stop,
Reload,
Status,
Run { config_path: Option<PathBuf> },
Help,
}
#[derive(Debug, Error)]
pub enum ServiceCliError {
#[error("{0}")]
Usage(String),
#[error("configuration error: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Service(String),
}
pub type ServiceCliResult<T> = std::result::Result<T, ServiceCliError>;
pub trait ServiceController {
fn install(&self, options: &InstallOptions) -> ServiceCliResult<()>;
fn uninstall(&self) -> ServiceCliResult<()>;
fn start(&self) -> ServiceCliResult<()>;
fn stop(&self) -> ServiceCliResult<()>;
fn reload(&self) -> ServiceCliResult<()>;
fn status(&self) -> ServiceCliResult<String>;
fn persist_config_marker(&self, config_path: &Path) -> ServiceCliResult<()>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOptions {
pub executable_path: PathBuf,
pub config_path: PathBuf,
pub account_name: OsString,
}
pub fn handle_service_cli(args: Vec<String>) -> ServiceCliResult<String> {
let command = parse_service_command(args)?;
if let ServiceCommand::Run { config_path } = command {
return run_service_dispatcher(config_path).map_err(|e| {
ServiceCliError::Service(format!("failed to run as Windows Service: {e}"))
});
}
let controller = WindowsServiceController;
execute_service_command(&controller, command)
}
pub fn parse_service_command(args: Vec<String>) -> ServiceCliResult<ServiceCommand> {
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
return Ok(ServiceCommand::Help);
}
let Some(command) = args.first().map(String::as_str) else {
return Err(ServiceCliError::Usage(service_usage()));
};
match command {
"install" => {
let config_path = parse_config_arg(&args[1..])?.unwrap_or_else(default_config_path);
Ok(ServiceCommand::Install { config_path })
}
"uninstall" => Ok(ServiceCommand::Uninstall),
"start" => Ok(ServiceCommand::Start),
"stop" => Ok(ServiceCommand::Stop),
"reload" => Ok(ServiceCommand::Reload),
"status" => Ok(ServiceCommand::Status),
"run" => {
let config_path = parse_config_arg(&args[1..])?;
Ok(ServiceCommand::Run { config_path })
}
_ => Err(ServiceCliError::Usage(service_usage())),
}
}
pub fn execute_service_command<C: ServiceController>(
controller: &C,
command: ServiceCommand,
) -> ServiceCliResult<String> {
match command {
ServiceCommand::Install { config_path } => {
let config_path = absolute_config_path(&config_path)?;
prepare_service_directories(&config_path)?;
validate_config(&config_path)?;
let options = InstallOptions {
executable_path: std::env::current_exe()?,
config_path: config_path.clone(),
account_name: OsString::from(LOCAL_SERVICE_ACCOUNT),
};
controller.install(&options)?;
finalize_install(controller, &config_path)?;
Ok(format!(
"installed {SERVICE_NAME} using config '{}'",
config_path.display()
))
}
ServiceCommand::Uninstall => {
controller.uninstall()?;
Ok(format!("uninstalled {SERVICE_NAME}"))
}
ServiceCommand::Start => {
let config_path = installed_config_path()?;
validate_config(&config_path)?;
controller.start()?;
Ok(format!("started {SERVICE_NAME}"))
}
ServiceCommand::Stop => {
controller.stop()?;
Ok(format!("stopped {SERVICE_NAME}"))
}
ServiceCommand::Reload => {
let config_path = installed_config_path()?;
validate_config(&config_path)?;
controller.reload()?;
Ok(format!("requested reload of {SERVICE_NAME}"))
}
ServiceCommand::Status => controller.status(),
ServiceCommand::Run { .. } => Err(ServiceCliError::Usage(
"'service run' is reserved for the Windows Service Control Manager".into(),
)),
ServiceCommand::Help => Ok(service_usage()),
}
}
fn parse_config_arg(args: &[String]) -> ServiceCliResult<Option<PathBuf>> {
let mut config_path = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--config" => {
let Some(path) = iter.next() else {
return Err(ServiceCliError::Usage("--config requires a path".into()));
};
config_path = Some(PathBuf::from(path));
}
_ => return Err(ServiceCliError::Usage(service_usage())),
}
}
Ok(config_path)
}
pub fn default_base_dir() -> PathBuf {
std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
.join("Alighieri")
}
pub fn default_config_path() -> PathBuf {
std::env::var_os("ProgramData")
.map(PathBuf::from)
.map(|base| base.join("Alighieri").join("alighieri.conf"))
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG))
}
pub fn default_log_dir() -> PathBuf {
default_base_dir().join("logs")
}
fn config_marker_path() -> PathBuf {
default_base_dir().join(SERVICE_CONFIG_MARKER)
}
fn absolute_config_path(config_path: &Path) -> ServiceCliResult<PathBuf> {
Ok(std::path::absolute(config_path)?)
}
fn installed_config_path() -> ServiceCliResult<PathBuf> {
read_installed_config_path(&config_marker_path())
}
fn read_installed_config_path(marker: &Path) -> ServiceCliResult<PathBuf> {
use std::io::Read;
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
let mut file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(marker)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let default = default_config_path();
eprintln!(
"alighieri: warning: no service config marker at {}; validating the default \
config {}. If the service was installed with a custom --config, reinstall to \
restore the marker.",
marker.display(),
default.display()
);
return Ok(default);
}
Err(e) => {
return Err(ServiceCliError::Service(format!(
"cannot read the service config marker {}: {}",
marker.display(),
explain_io_error(&e)
)))
}
};
let metadata = file.metadata().map_err(|e| {
ServiceCliError::Service(format!(
"cannot inspect the service config marker {}: {}",
marker.display(),
explain_io_error(&e)
))
})?;
if !metadata.is_file() {
return Err(ServiceCliError::Service(format!(
"the service config marker {} is not a regular file; refusing to follow it",
marker.display()
)));
}
let mut contents = String::new();
file.read_to_string(&mut contents).map_err(|e| {
ServiceCliError::Service(format!(
"cannot read the service config marker {}: {}",
marker.display(),
explain_io_error(&e)
))
})?;
let trimmed = contents.trim();
if trimmed.is_empty() {
return Err(ServiceCliError::Service(format!(
"the service config marker {} is empty or corrupt; reinstall the service \
with 'alighieri service install --config <path>'",
marker.display()
)));
}
let path = PathBuf::from(trimmed);
if !path.is_absolute() {
return Err(ServiceCliError::Service(format!(
"the service config marker {} contains a relative path ({trimmed:?}); reinstall \
the service with 'alighieri service install --config <absolute path>'",
marker.display()
)));
}
Ok(path)
}
const HARDENED_DACL_SDDL: &str = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1301bf;;;LS)";
fn prepare_service_directories(config_path: &Path) -> ServiceCliResult<()> {
let base = default_base_dir();
fail_if_reparse_point(&base)?;
create_secure_base_dir(&base)?;
if let Err(e) = harden_directory_dacl(&base) {
if e.kind() == std::io::ErrorKind::InvalidData {
return Err(ServiceCliError::Service(format!(
"refusing to install into {}: a symlink or reparse point is present ({e}). \
Remove it and reinstall.",
base.display()
)));
}
return Err(ServiceCliError::Service(format!(
"refusing to install: could not secure {} ({e}). Its config and userlist would be \
writable by standard users (local privilege escalation). Resolve the permission \
problem — e.g. remove a data directory a standard user pre-created — and reinstall.",
base.display()
)));
}
let logs = default_log_dir();
fail_if_reparse_point(&logs)?;
std::fs::create_dir_all(&logs)?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
fn fail_if_reparse_point(path: &Path) -> ServiceCliResult<()> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => {
Err(ServiceCliError::Service(format!(
"refusing to install into {}: it is a symlink or reparse point. Remove it and \
reinstall.",
path.display()
)))
}
Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e.into()),
}
}
fn harden_directory_dacl(base: &Path) -> std::io::Result<()> {
secure_path_acl(base)?;
secure_existing_children(base, 0)?;
Ok(())
}
const MAX_RESECURE_DEPTH: usize = 64;
fn secure_existing_children(dir: &Path, depth: usize) -> std::io::Result<()> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if depth >= MAX_RESECURE_DEPTH {
return Err(std::io::Error::other(format!(
"service data directory nests deeper than {MAX_RESECURE_DEPTH} levels at {}; \
refusing to continue",
dir.display()
)));
}
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"unexpected reparse point under the service data directory: {}",
path.display()
),
));
}
if let Err(e) = secure_path_acl(&path) {
if e.kind() == std::io::ErrorKind::InvalidData {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"unexpected reparse point under the service data directory: {}",
path.display()
),
));
}
if e.kind() == std::io::ErrorKind::NotFound {
continue;
}
return Err(e);
}
if meta.is_dir() {
match secure_existing_children(&path, depth + 1) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
}
Ok(())
}
fn create_secure_base_dir(base: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{LocalFree, ERROR_ALREADY_EXISTS};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
if let Some(parent) = base.parent() {
std::fs::create_dir_all(parent)?;
}
let wide: Vec<u16> = base
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let sddl_w: Vec<u16> = HARDENED_DACL_SDDL
.encode_utf16()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut psd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl_w.as_ptr(),
SDDL_REVISION_1,
&mut psd,
std::ptr::null_mut(),
) == 0
{
return Err(std::io::Error::last_os_error());
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd,
bInheritHandle: 0,
};
let created = CreateDirectoryW(wide.as_ptr(), &sa);
let err = std::io::Error::last_os_error();
LocalFree(psd);
if created == 0 {
if err.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) {
return Err(err);
}
if !std::fs::symlink_metadata(base)?.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("{} already exists and is not a directory", base.display()),
));
}
}
}
Ok(())
}
fn secure_path_acl(path: &Path) -> std::io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SetSecurityInfo, SDDL_REVISION_1,
SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{
GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, ACL, DACL_SECURITY_INFORMATION,
OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
PSID,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
};
const READ_CONTROL: u32 = 0x0002_0000;
const WRITE_DAC: u32 = 0x0004_0000;
const WRITE_OWNER: u32 = 0x0008_0000;
const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080;
let sddl = format!("O:BA{HARDENED_DACL_SDDL}");
let open = |access: u32| {
std::fs::OpenOptions::new()
.access_mode(access)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
};
let (handle, with_owner) =
match open(READ_CONTROL | WRITE_DAC | WRITE_OWNER | FILE_READ_ATTRIBUTES) {
Ok(handle) => (handle, true),
Err(_) => (
open(READ_CONTROL | WRITE_DAC | FILE_READ_ATTRIBUTES)?,
false,
),
};
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if handle.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"refusing to set permissions on a symlink/reparse point",
));
}
let raw = handle.as_raw_handle();
let sddl_w: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
let mut psd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl_w.as_ptr(),
SDDL_REVISION_1,
&mut psd,
std::ptr::null_mut(),
) == 0
{
return Err(std::io::Error::last_os_error());
}
let mut present = 0;
let mut pdacl: *mut ACL = std::ptr::null_mut();
let mut defaulted = 0;
let mut powner: PSID = std::ptr::null_mut();
let mut owner_defaulted = 0;
if GetSecurityDescriptorDacl(psd, &mut present, &mut pdacl, &mut defaulted) == 0
|| present == 0
|| GetSecurityDescriptorOwner(psd, &mut powner, &mut owner_defaulted) == 0
{
let err = std::io::Error::last_os_error();
LocalFree(psd);
return Err(err);
}
let dacl_only = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION;
let mut rc = if with_owner {
SetSecurityInfo(
raw as _,
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | dacl_only,
powner,
std::ptr::null_mut(),
pdacl,
std::ptr::null_mut(),
)
} else {
1 };
if rc != 0 {
rc = SetSecurityInfo(
raw as _,
SE_FILE_OBJECT,
dacl_only,
std::ptr::null_mut(),
std::ptr::null_mut(),
pdacl,
std::ptr::null_mut(),
);
}
LocalFree(psd);
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc as i32));
}
}
Ok(())
}
fn write_config_marker(config_path: &Path) -> ServiceCliResult<()> {
write_marker_atomically(&config_marker_path(), &config_path.display().to_string())?;
Ok(())
}
fn write_marker_atomically(marker: &Path, contents: &str) -> std::io::Result<()> {
use std::io::Write;
let (temp, mut file) = create_marker_temp(marker)?;
let result = file
.write_all(contents.as_bytes())
.and_then(|()| file.sync_all());
drop(file);
if let Err(e) = result.and_then(|()| std::fs::rename(&temp, marker)) {
let _ = std::fs::remove_file(&temp);
return Err(e);
}
Ok(())
}
fn create_marker_temp(marker: &Path) -> std::io::Result<(PathBuf, std::fs::File)> {
use std::ffi::{OsStr, OsString};
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let parent = marker
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let file_name = marker
.file_name()
.unwrap_or_else(|| OsStr::new(SERVICE_CONFIG_MARKER));
for _ in 0..100 {
let nonce = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut temp_name = OsString::from(".");
temp_name.push(file_name);
temp_name.push(format!(".tmp-{}-{nonce}", std::process::id()));
let temp = parent.join(temp_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
{
Ok(file) => return Ok((temp, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"failed to create unique temporary marker path",
))
}
fn finalize_install<C: ServiceController>(
controller: &C,
config_path: &Path,
) -> ServiceCliResult<()> {
let Err(persist_err) = controller.persist_config_marker(config_path) else {
return Ok(());
};
match controller.uninstall() {
Ok(()) => Err(persist_err),
Err(uninstall_err) => Err(ServiceCliError::Service(format!(
"failed to record the installed config ({persist_err}); rolling the install back \
also failed ({uninstall_err}), so the {SERVICE_NAME} service may still be installed \
- run 'alighieri service uninstall' to remove it"
))),
}
}
fn validate_config(config_path: &Path) -> ServiceCliResult<()> {
Config::load(config_path)
.and_then(|config| {
config.validate_startup()?;
tls::validate_config(&config)?;
Ok(())
})
.map_err(|e| ServiceCliError::Config(format!("{} ({})", config_path.display(), e)))
}
fn service_usage() -> String {
"usage: alighieri service install --config CONFIG | uninstall | start | stop | reload | status"
.into()
}
pub fn explain_service_error(err: &windows_service::Error) -> String {
let base = err.to_string();
if matches!(err, windows_service::Error::Winapi(io) if io.raw_os_error() == Some(5)) {
return format!("{base}; run this command from an elevated Administrator shell");
}
let lower = base.to_ascii_lowercase();
if lower.contains("access is denied") || lower.contains("os error 5") {
format!("{base}; run this command from an elevated Administrator shell")
} else {
base
}
}
fn explain_io_error(err: &std::io::Error) -> String {
let base = err.to_string();
if err.raw_os_error() == Some(5) || base.to_ascii_lowercase().contains("access is denied") {
format!("I/O error: {base}; run this command from an elevated Administrator shell")
} else {
format!("I/O error: {base}")
}
}
fn ensure_service_stopped(service: &Service) -> ServiceCliResult<()> {
let status = service
.query_status()
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
if status.current_state == ServiceState::Stopped {
return Ok(());
}
if should_request_stop(status.current_state, status.controls_accepted) {
if let Err(err) = service.stop() {
if wait_for_service_stopped(service, SERVICE_STOP_TIMEOUT).is_ok() {
return Ok(());
}
return Err(ServiceCliError::Service(explain_service_error(&err)));
}
}
wait_for_service_stopped(service, SERVICE_STOP_TIMEOUT)
}
fn should_request_stop(
current_state: ServiceState,
controls_accepted: ServiceControlAccept,
) -> bool {
current_state != ServiceState::Stopped
&& current_state != ServiceState::StopPending
&& controls_accepted.contains(ServiceControlAccept::STOP)
}
fn wait_for_service_stopped(service: &Service, timeout: Duration) -> ServiceCliResult<()> {
let start = Instant::now();
while start.elapsed() < timeout {
let status = service
.query_status()
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
if status.current_state == ServiceState::Stopped {
return Ok(());
}
sleep(SERVICE_STOP_POLL_INTERVAL);
}
Err(ServiceCliError::Service(format!(
"timed out waiting for {SERVICE_NAME} to stop before uninstalling"
)))
}
pub struct WindowsServiceController;
struct InstallFailure {
error: ServiceCliError,
service_remains: bool,
}
impl From<ServiceCliError> for InstallFailure {
fn from(error: ServiceCliError) -> Self {
InstallFailure {
error,
service_remains: false,
}
}
}
fn configure_rollback_error(configure_err: &str, delete: ServiceCliResult<()>) -> InstallFailure {
match delete {
Ok(()) => InstallFailure {
error: ServiceCliError::Service(configure_err.to_string()),
service_remains: false,
},
Err(delete_err) => InstallFailure {
error: ServiceCliError::Service(format!(
"{configure_err}; rolling back the partially configured service also failed \
({delete_err}), so the {SERVICE_NAME} service may still be installed - run \
'alighieri service uninstall' to remove it"
)),
service_remains: true,
},
}
}
const ERROR_SERVICE_EXISTS: i32 = windows_sys::Win32::Foundation::ERROR_SERVICE_EXISTS as i32;
fn create_failure(e: windows_service::Error) -> InstallFailure {
let service_remains = matches!(
&e,
windows_service::Error::Winapi(io) if io.raw_os_error() == Some(ERROR_SERVICE_EXISTS)
);
InstallFailure {
error: ServiceCliError::Service(explain_service_error(&e)),
service_remains,
}
}
impl ServiceController for WindowsServiceController {
fn install(&self, options: &InstallOptions) -> ServiceCliResult<()> {
event_log::register_source().map_err(|e| ServiceCliError::Service(explain_io_error(&e)))?;
let install_result = || -> Result<(), InstallFailure> {
let manager_access =
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let manager = ServiceManager::local_computer(None::<&str>, manager_access)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: options.executable_path.clone(),
launch_arguments: vec![
OsString::from("service"),
OsString::from("run"),
OsString::from("--config"),
options.config_path.clone().into_os_string(),
],
dependencies: vec![],
account_name: Some(options.account_name.clone()),
account_password: None,
};
let service_access = ServiceAccess::QUERY_STATUS
| ServiceAccess::QUERY_CONFIG
| ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::DELETE;
let service = manager
.create_service(&service_info, service_access)
.map_err(create_failure)?;
let configure = service
.set_description(SERVICE_DISPLAY_NAME)
.and_then(|()| {
service.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(
60 * 60,
)),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(30),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(60),
},
]),
})
});
if let Err(e) = configure {
let configure_err = explain_service_error(&e);
let delete = service
.delete()
.map_err(|de| ServiceCliError::Service(explain_service_error(&de)));
return Err(configure_rollback_error(&configure_err, delete));
}
Ok(())
};
match install_result() {
Ok(()) => {}
Err(InstallFailure {
error,
service_remains,
}) => {
if !service_remains {
let _ = event_log::unregister_source();
}
return Err(error);
}
}
Ok(())
}
fn uninstall(&self) -> ServiceCliResult<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service = manager
.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
ensure_service_stopped(&service)?;
service
.delete()
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let _ = event_log::unregister_source();
Ok(())
}
fn start(&self) -> ServiceCliResult<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::START)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
service
.start::<&str>(&[])
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))
}
fn stop(&self) -> ServiceCliResult<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::STOP)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
service
.stop()
.map(|_| ())
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))
}
fn reload(&self) -> ServiceCliResult<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::USER_DEFINED_CONTROL)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
service
.notify(SERVICE_RELOAD_CONTROL)
.map(|_| ())
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))
}
fn status(&self) -> ServiceCliResult<String> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
let status = service
.query_status()
.map_err(|e| ServiceCliError::Service(explain_service_error(&e)))?;
Ok(format!("{SERVICE_NAME}: {:?}", status.current_state))
}
fn persist_config_marker(&self, config_path: &Path) -> ServiceCliResult<()> {
write_config_marker(config_path)?;
event_log::report(
event_log::EventLevel::Info,
event_log::EVENT_SERVICE_INSTALLED,
format!("{SERVICE_DISPLAY_NAME} was installed"),
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_install_with_config() {
let command = parse_service_command(vec![
"install".into(),
"--config".into(),
r"C:\ProgramData\Alighieri\alighieri.conf".into(),
])
.unwrap();
assert_eq!(
command,
ServiceCommand::Install {
config_path: PathBuf::from(r"C:\ProgramData\Alighieri\alighieri.conf")
}
);
}
#[test]
fn parses_lifecycle_commands() {
assert_eq!(
parse_service_command(vec!["uninstall".into()]).unwrap(),
ServiceCommand::Uninstall
);
assert_eq!(
parse_service_command(vec!["start".into()]).unwrap(),
ServiceCommand::Start
);
assert_eq!(
parse_service_command(vec!["stop".into()]).unwrap(),
ServiceCommand::Stop
);
assert_eq!(
parse_service_command(vec!["reload".into()]).unwrap(),
ServiceCommand::Reload
);
assert_eq!(
parse_service_command(vec!["status".into()]).unwrap(),
ServiceCommand::Status
);
}
#[test]
fn validate_config_rejects_public_metrics_without_allowpublic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alighieri.conf");
std::fs::write(
&path,
"internal: 127.0.0.1 port = 1080\nmetrics.listen: 0.0.0.0:9090\nsocks pass { from: 0.0.0.0/0 to: 0.0.0.0/0 }",
)
.unwrap();
let Err(err) = validate_config(&path) else {
panic!("service validation should refuse public metrics without metrics.allowpublic");
};
assert!(err.to_string().contains("metrics.allowpublic"), "{err}");
}
#[test]
fn parses_service_help() {
assert_eq!(
parse_service_command(vec!["install".into(), "--help".into()]).unwrap(),
ServiceCommand::Help
);
}
#[test]
fn default_paths_use_program_data() {
let config = default_config_path();
assert!(config.ends_with(Path::new("Alighieri").join("alighieri.conf")));
let logs = default_log_dir();
assert!(logs.ends_with(Path::new("Alighieri").join("logs")));
}
#[test]
fn absolute_config_path_makes_a_relative_path_absolute() {
let abs = absolute_config_path(Path::new("alighieri.conf")).unwrap();
assert!(abs.is_absolute(), "not absolute: {}", abs.display());
assert!(abs.ends_with("alighieri.conf"), "{}", abs.display());
assert_ne!(abs, PathBuf::from("alighieri.conf"));
assert!(
absolute_config_path(Path::new(r"C:\configs\alighieri.conf"))
.unwrap()
.is_absolute()
);
}
#[test]
fn read_installed_config_path_reads_and_trims_the_marker() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("service-config-path.txt");
std::fs::write(&marker, " C:\\configs\\alighieri.conf \r\n").unwrap();
assert_eq!(
read_installed_config_path(&marker).unwrap(),
PathBuf::from(r"C:\configs\alighieri.conf")
);
}
#[test]
fn read_installed_config_path_falls_back_to_default_when_absent() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("does-not-exist.txt");
assert_eq!(
read_installed_config_path(&marker).unwrap(),
default_config_path()
);
}
#[test]
fn read_installed_config_path_rejects_an_empty_marker() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("service-config-path.txt");
std::fs::write(&marker, " \r\n").unwrap();
let err = read_installed_config_path(&marker).unwrap_err();
assert!(err.to_string().contains("empty or corrupt"), "{err}");
}
#[test]
fn read_installed_config_path_rejects_a_relative_marker() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("service-config-path.txt");
std::fs::write(&marker, "alighieri.conf\r\n").unwrap();
let err = read_installed_config_path(&marker).unwrap_err();
assert!(err.to_string().contains("relative path"), "{err}");
}
#[test]
fn read_installed_config_path_rejects_a_symlinked_marker() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.txt");
std::fs::write(&target, r"C:\evil\redirected.conf").unwrap();
let marker = dir.path().join("service-config-path.txt");
if std::os::windows::fs::symlink_file(&target, &marker).is_err() {
eprintln!("skipping symlink test: cannot create symlinks in this environment");
return;
}
let result = read_installed_config_path(&marker);
assert!(
matches!(&result, Err(ServiceCliError::Service(msg)) if msg.contains("not a regular file")),
"a symlinked marker must be rejected without following it, got {result:?}"
);
}
#[test]
fn permission_error_mentions_elevation() {
let err = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(5));
let message = explain_service_error(&err);
assert!(message.contains("Administrator"));
}
#[test]
fn event_log_permission_error_mentions_elevation() {
let err = std::io::Error::from_raw_os_error(5);
let message = explain_io_error(&err);
assert!(message.contains("I/O error"));
assert!(message.contains("Administrator"));
}
#[test]
fn stop_pending_service_is_waited_without_second_stop_request() {
assert!(!should_request_stop(
ServiceState::StopPending,
ServiceControlAccept::STOP
));
}
#[test]
fn running_service_requests_stop_only_when_control_is_accepted() {
assert!(should_request_stop(
ServiceState::Running,
ServiceControlAccept::STOP
));
assert!(!should_request_stop(
ServiceState::Running,
ServiceControlAccept::empty()
));
}
#[derive(Default)]
struct FakeController {
persist_should_fail: bool,
uninstall_should_fail: bool,
uninstalled: std::cell::Cell<bool>,
}
impl ServiceController for FakeController {
fn install(&self, _options: &InstallOptions) -> ServiceCliResult<()> {
Ok(())
}
fn uninstall(&self) -> ServiceCliResult<()> {
self.uninstalled.set(true);
if self.uninstall_should_fail {
Err(ServiceCliError::Service(
"simulated uninstall failure".into(),
))
} else {
Ok(())
}
}
fn start(&self) -> ServiceCliResult<()> {
Ok(())
}
fn stop(&self) -> ServiceCliResult<()> {
Ok(())
}
fn reload(&self) -> ServiceCliResult<()> {
Ok(())
}
fn status(&self) -> ServiceCliResult<String> {
Ok("Alighieri: Running".into())
}
fn persist_config_marker(&self, _config_path: &Path) -> ServiceCliResult<()> {
if self.persist_should_fail {
Err(ServiceCliError::Io(std::io::Error::other(
"simulated marker write failure",
)))
} else {
Ok(())
}
}
}
#[test]
fn command_layer_dispatches_status() {
let message =
execute_service_command(&FakeController::default(), ServiceCommand::Status).unwrap();
assert_eq!(message, "Alighieri: Running");
}
#[test]
fn finalize_install_rolls_back_when_config_marker_write_fails() {
let controller = FakeController {
persist_should_fail: true,
..FakeController::default()
};
let err = finalize_install(
&controller,
Path::new(r"C:\ProgramData\Alighieri\alighieri.conf"),
)
.unwrap_err();
assert!(matches!(err, ServiceCliError::Io(_)), "{err}");
assert!(
controller.uninstalled.get(),
"a failed marker write must roll back (uninstall) the service"
);
}
#[test]
fn finalize_install_surfaces_a_failed_rollback() {
let controller = FakeController {
persist_should_fail: true,
uninstall_should_fail: true,
..FakeController::default()
};
let err = finalize_install(
&controller,
Path::new(r"C:\ProgramData\Alighieri\alighieri.conf"),
)
.unwrap_err();
assert!(
controller.uninstalled.get(),
"rollback uninstall must be attempted"
);
let msg = err.to_string();
assert!(msg.contains("may still be installed"), "{msg}");
assert!(msg.contains("simulated marker write failure"), "{msg}");
assert!(msg.contains("simulated uninstall failure"), "{msg}");
}
#[test]
fn finalize_install_succeeds_and_keeps_the_service_when_marker_writes() {
let controller = FakeController::default();
finalize_install(
&controller,
Path::new(r"C:\ProgramData\Alighieri\alighieri.conf"),
)
.unwrap();
assert!(
!controller.uninstalled.get(),
"a successful install must not be rolled back"
);
}
#[test]
fn configure_rollback_error_reports_only_config_error_when_rollback_succeeds() {
let failure = configure_rollback_error("set_description failed", Ok(()));
assert!(!failure.service_remains);
let msg = failure.error.to_string();
assert!(msg.contains("set_description failed"), "{msg}");
assert!(!msg.contains("may still be installed"), "{msg}");
}
#[test]
fn configure_rollback_error_surfaces_both_failures_when_rollback_fails() {
let failure = configure_rollback_error(
"set_description failed",
Err(ServiceCliError::Service("delete access denied".into())),
);
assert!(failure.service_remains);
let msg = failure.error.to_string();
assert!(msg.contains("set_description failed"), "{msg}");
assert!(msg.contains("delete access denied"), "{msg}");
assert!(msg.contains("may still be installed"), "{msg}");
}
#[test]
fn create_failure_keeps_the_source_when_the_service_already_exists() {
let err =
windows_service::Error::Winapi(std::io::Error::from_raw_os_error(ERROR_SERVICE_EXISTS));
assert!(create_failure(err).service_remains);
}
#[test]
fn create_failure_drops_the_source_for_other_failures() {
let err = windows_service::Error::Winapi(std::io::Error::from_raw_os_error(5));
assert!(!create_failure(err).service_remains);
}
#[test]
fn write_marker_atomically_replaces_existing_without_leaving_temp() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("service-config-path.txt");
std::fs::write(&marker, "old-path").unwrap();
write_marker_atomically(&marker, r"C:\new\alighieri.conf").unwrap();
assert_eq!(
std::fs::read_to_string(&marker).unwrap(),
r"C:\new\alighieri.conf"
);
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(
names,
vec![std::ffi::OsString::from("service-config-path.txt")]
);
}
fn read_dacl_sddl(dir: &Path) -> Option<String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW,
SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR};
let path_w: Vec<u16> = dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut psd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let rc = GetNamedSecurityInfoW(
path_w.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut psd,
);
const ERROR_ACCESS_DENIED: u32 = 5;
if rc == ERROR_ACCESS_DENIED {
LocalFree(psd);
return None;
}
assert_eq!(rc, 0, "GetNamedSecurityInfoW failed (code {rc})");
let mut sddl_ptr: *mut u16 = std::ptr::null_mut();
let mut len = 0u32;
let ok = ConvertSecurityDescriptorToStringSecurityDescriptorW(
psd,
SDDL_REVISION_1,
DACL_SECURITY_INFORMATION,
&mut sddl_ptr,
&mut len,
);
assert_ne!(ok, 0, "converting the descriptor to SDDL failed");
let chars = (len as usize).saturating_sub(1);
let sddl = String::from_utf16_lossy(std::slice::from_raw_parts(sddl_ptr, chars));
LocalFree(sddl_ptr.cast());
LocalFree(psd);
Some(sddl)
}
}
fn reset_dacl_for_cleanup(dir: &Path) {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Security::Authorization::{SetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
DACL_SECURITY_INFORMATION, UNPROTECTED_DACL_SECURITY_INFORMATION,
};
let mut path_w: Vec<u16> = dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let rc = unsafe {
SetNamedSecurityInfoW(
path_w.as_mut_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(), std::ptr::null_mut(),
)
};
if rc != 0 {
eprintln!("note: could not reset the test DACL (code {rc}); temp dir may linger");
}
}
fn skip_dacl_test_or_panic(reason: &str) {
if std::env::var_os("ALIGHIERI_REQUIRE_DACL_TESTS").is_some_and(|v| !v.is_empty()) {
panic!("a DACL test would skip but ALIGHIERI_REQUIRE_DACL_TESTS is set: {reason}");
}
eprintln!("skipping DACL test: {reason}");
}
#[test]
fn harden_directory_dacl_locks_out_standard_users() {
let parent = tempfile::tempdir().unwrap();
let dir = parent.path().join("svc-data");
std::fs::create_dir(&dir).unwrap();
if let Err(e) = harden_directory_dacl(&dir) {
if e.kind() == std::io::ErrorKind::PermissionDenied {
skip_dacl_test_or_panic(&format!("this account cannot apply the DACL ({e})"));
reset_dacl_for_cleanup(&dir);
return;
}
panic!("hardening an owned directory must succeed: {e}");
}
let sddl = read_dacl_sddl(&dir);
reset_dacl_for_cleanup(&dir);
let Some(sddl) = sddl else {
skip_dacl_test_or_panic("the hardened DACL is not readable by this account");
return;
};
assert!(sddl.starts_with("D:P"), "DACL must be protected: {sddl}");
let aces: Vec<&str> = sddl
.split(['(', ')'])
.filter(|chunk| chunk.contains(';'))
.collect();
assert_eq!(aces.len(), 3, "DACL must have exactly three ACEs: {sddl}");
assert!(
aces.iter().all(|ace| ace.starts_with("A;")),
"every ACE must be an allow ACE, never a deny: {sddl}"
);
let trustees: std::collections::BTreeSet<&str> = aces
.iter()
.filter_map(|ace| ace.rsplit(';').next())
.collect();
assert_eq!(
trustees,
std::collections::BTreeSet::from(["SY", "BA", "LS"]),
"DACL trustees must be exactly SYSTEM/Administrators/LocalService: {sddl}"
);
}
#[test]
fn create_secure_base_dir_is_born_protected() {
let parent = tempfile::tempdir().unwrap();
let base = parent.path().join("born-secure");
if let Err(e) = create_secure_base_dir(&base) {
if e.kind() == std::io::ErrorKind::PermissionDenied {
skip_dacl_test_or_panic(&format!(
"this account cannot create the secured directory ({e})"
));
return;
}
panic!("creating the secured base must succeed: {e}");
}
let sddl = read_dacl_sddl(&base);
reset_dacl_for_cleanup(&base);
let Some(sddl) = sddl else {
skip_dacl_test_or_panic("the born-secure DACL is not readable by this account");
return;
};
assert!(
base.is_dir(),
"the base directory must exist: {}",
base.display()
);
create_secure_base_dir(&base).expect("an existing base must be a no-op");
assert!(
sddl.starts_with("D:P"),
"born-secure DACL must be protected: {sddl}"
);
let trustees: std::collections::BTreeSet<&str> = sddl
.split(['(', ')'])
.filter(|chunk| chunk.contains(';'))
.filter_map(|ace| ace.rsplit(';').next())
.collect();
assert_eq!(
trustees,
std::collections::BTreeSet::from(["SY", "BA", "LS"]),
"born-secure DACL trustees must be exactly SYSTEM/Administrators/LocalService: {sddl}"
);
}
#[test]
fn create_secure_base_dir_rejects_a_file_at_the_path() {
let parent = tempfile::tempdir().unwrap();
let base = parent.path().join("not-a-dir");
std::fs::write(&base, b"x").unwrap();
let err =
create_secure_base_dir(&base).expect_err("a file at the base path must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
}
#[test]
fn secure_existing_children_bounds_recursion_depth() {
let parent = tempfile::tempdir().unwrap();
let dir = parent.path().join("d");
std::fs::create_dir(&dir).unwrap();
let err = secure_existing_children(&dir, MAX_RESECURE_DEPTH)
.expect_err("hitting the depth cap must fail");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
}
#[test]
fn harden_directory_dacl_refuses_a_symlinked_base() {
let parent = tempfile::tempdir().unwrap();
let real = parent.path().join("real");
std::fs::create_dir(&real).unwrap();
let link = parent.path().join("link");
if std::os::windows::fs::symlink_dir(&real, &link).is_err() {
eprintln!(
"skipping harden_directory_dacl_refuses_a_symlinked_base: cannot create symlinks"
);
return;
}
let err = harden_directory_dacl(&link).expect_err("a symlinked base must be refused");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn harden_directory_dacl_refuses_a_planted_reparse_child() {
let parent = tempfile::tempdir().unwrap();
let base = parent.path().join("base");
std::fs::create_dir(&base).unwrap();
let target = parent.path().join("target");
std::fs::create_dir(&target).unwrap();
let link = base.join("evil");
if std::os::windows::fs::symlink_dir(&target, &link).is_err() {
eprintln!(
"skipping harden_directory_dacl_refuses_a_planted_reparse_child: cannot create \
symlinks"
);
return;
}
let err =
harden_directory_dacl(&base).expect_err("a planted reparse child must be refused");
reset_dacl_for_cleanup(&base);
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
err.to_string().contains("evil"),
"error should name the offending child: {err}"
);
}
#[test]
fn fail_if_reparse_point_rejects_a_symlink() {
let parent = tempfile::tempdir().unwrap();
let real = parent.path().join("real");
std::fs::create_dir(&real).unwrap();
fail_if_reparse_point(&real).expect("a regular directory is allowed");
fail_if_reparse_point(&parent.path().join("missing")).expect("a missing path is allowed");
let link = parent.path().join("link");
if std::os::windows::fs::symlink_dir(&real, &link).is_err() {
eprintln!("skipping fail_if_reparse_point_rejects_a_symlink: cannot create symlinks");
return;
}
assert!(matches!(
fail_if_reparse_point(&link),
Err(ServiceCliError::Service(_))
));
}
}