Skip to main content

running_process/broker/server/
service_def_loader.rs

1//! Service-definition file loading for the v1 broker.
2//!
3//! The loader intentionally re-reads from disk for each `lookup_or_reload`
4//! call. That gives Phase 4's Hello path reload-on-Hello semantics without
5//! coupling the validation rules to the later async accept loop.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use prost::Message;
12
13use crate::broker::lifecycle::names::{validate_service_name, validate_version, PipePathError};
14use crate::broker::protocol::{BrokerIsolation, ServiceDefinition};
15use crate::broker::secure_dir;
16
17/// Service-definition file extension.
18pub const SERVICE_DEF_EXTENSION: &str = "servicedef";
19
20/// Environment override for tests and development.
21pub const SERVICE_DEF_DIR_ENV: &str = "RUNNING_PROCESS_SERVICE_DEF_DIR";
22
23/// Loader rooted at one service-definition directory.
24#[derive(Clone, Debug)]
25pub struct ServiceDefinitionLoader {
26    root: PathBuf,
27}
28
29impl ServiceDefinitionLoader {
30    /// Create a loader for `root`.
31    pub fn new(root: impl Into<PathBuf>) -> Self {
32        Self { root: root.into() }
33    }
34
35    /// Create a loader for the platform default service-definition directory.
36    pub fn default_root() -> Self {
37        Self::new(service_definition_dir())
38    }
39
40    /// Directory this loader reads from.
41    pub fn root(&self) -> &Path {
42        &self.root
43    }
44
45    /// Load and validate one service definition from disk.
46    pub fn load(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
47        ensure_loadable_service_definition_dir(&self.root)?;
48        let path = service_definition_path(&self.root, service_name)?;
49        let bytes = fs::read(&path)?;
50        let definition = ServiceDefinition::decode(bytes.as_slice())?;
51        validate_service_definition_for_service(&definition, service_name)?;
52        Ok(definition)
53    }
54
55    /// Reload one service definition from disk.
56    pub fn reload(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
57        self.load(service_name)
58    }
59
60    /// Lookup that always re-reads the service-definition file.
61    pub fn lookup_or_reload(
62        &self,
63        service_name: &str,
64    ) -> Result<ServiceDefinition, ServiceDefinitionError> {
65        self.load(service_name)
66    }
67}
68
69/// Return the platform service-definition directory.
70pub fn service_definition_dir() -> PathBuf {
71    if let Some(path) = std::env::var_os(SERVICE_DEF_DIR_ENV) {
72        return PathBuf::from(path);
73    }
74
75    #[cfg(windows)]
76    {
77        dirs::config_dir()
78            .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
79            .join("running-process")
80            .join("services")
81    }
82    #[cfg(target_os = "macos")]
83    {
84        dirs::home_dir()
85            .unwrap_or_else(std::env::temp_dir)
86            .join("Library")
87            .join("Application Support")
88            .join("running-process")
89            .join("services")
90    }
91    #[cfg(all(unix, not(target_os = "macos")))]
92    {
93        if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
94            PathBuf::from(config_home)
95                .join("running-process")
96                .join("services")
97        } else {
98            dirs::home_dir()
99                .unwrap_or_else(std::env::temp_dir)
100                .join(".config")
101                .join("running-process")
102                .join("services")
103        }
104    }
105}
106
107/// Ensure a service-definition directory exists with private permissions.
108pub fn ensure_service_definition_dir(path: &Path) -> Result<(), ServiceDefinitionError> {
109    secure_dir::ensure_private_dir(path)?;
110    ensure_loadable_service_definition_dir(path)
111}
112
113/// Validate and write one `.servicedef` file into `root`.
114///
115/// Consumer installers and development tools should use this helper instead of
116/// duplicating protobuf serialization and service-definition path logic.
117pub fn write_service_definition(
118    root: &Path,
119    definition: &ServiceDefinition,
120) -> Result<PathBuf, ServiceDefinitionError> {
121    ensure_service_definition_dir(root)?;
122    validate_service_definition_for_service(definition, &definition.service_name)?;
123    let path = service_definition_path(root, &definition.service_name)?;
124    fs::write(&path, definition.encode_to_vec())?;
125    Ok(path)
126}
127
128/// Compute the file path for one service definition.
129pub fn service_definition_path(
130    root: &Path,
131    service_name: &str,
132) -> Result<PathBuf, ServiceDefinitionError> {
133    validate_service_name(service_name)?;
134    Ok(root.join(format!("{service_name}.{SERVICE_DEF_EXTENSION}")))
135}
136
137/// Validate one decoded service definition against the requested service.
138pub fn validate_service_definition_for_service(
139    definition: &ServiceDefinition,
140    expected_service: &str,
141) -> Result<(), ServiceDefinitionError> {
142    validate_service_name(expected_service)?;
143    validate_service_name(&definition.service_name)?;
144    if definition.service_name != expected_service {
145        return Err(ServiceDefinitionError::ServiceNameMismatch {
146            requested: expected_service.into(),
147            actual: definition.service_name.clone(),
148        });
149    }
150    validate_absolute_path("binary_path", &definition.binary_path)?;
151    if !definition.per_version_binary_dir.is_empty() {
152        validate_absolute_path("per_version_binary_dir", &definition.per_version_binary_dir)?;
153    }
154    if !definition.min_version.is_empty() {
155        validate_version(&definition.min_version)?;
156    }
157    for version in &definition.version_allow_list {
158        validate_version(version)?;
159    }
160
161    match BrokerIsolation::try_from(definition.isolation) {
162        Ok(BrokerIsolation::PrivateBroker) | Ok(BrokerIsolation::SharedBroker) => {
163            if !definition.explicit_instance.is_empty() {
164                return Err(ServiceDefinitionError::InvalidIsolation {
165                    reason: "explicit_instance must be empty unless isolation is EXPLICIT_INSTANCE",
166                });
167            }
168        }
169        Ok(BrokerIsolation::ExplicitInstance) => {
170            if definition.explicit_instance.is_empty() {
171                return Err(ServiceDefinitionError::InvalidIsolation {
172                    reason: "EXPLICIT_INSTANCE requires explicit_instance",
173                });
174            }
175            validate_service_name(&definition.explicit_instance)?;
176        }
177        Err(_) => {
178            return Err(ServiceDefinitionError::InvalidIsolation {
179                reason: "unknown BrokerIsolation value",
180            });
181        }
182    }
183
184    Ok(())
185}
186
187/// Errors returned while loading service-definition files.
188#[derive(Debug, thiserror::Error)]
189pub enum ServiceDefinitionError {
190    /// Filesystem operation failed.
191    #[error("service-definition I/O failed: {0}")]
192    Io(#[from] io::Error),
193    /// Protobuf decode failed.
194    #[error("service-definition protobuf decode failed: {0}")]
195    Decode(#[from] prost::DecodeError),
196    /// Name or version validation failed.
197    #[error(transparent)]
198    InvalidName(#[from] PipePathError),
199    /// Directory permissions are too broad.
200    #[error("service-definition directory has insecure permissions: {0}")]
201    InsecureDirectory(PathBuf),
202    /// File content did not match the requested service.
203    #[error("service-definition requested {requested:?} but file declares {actual:?}")]
204    ServiceNameMismatch {
205        /// Service name requested by the Hello path.
206        requested: String,
207        /// Service name decoded from disk.
208        actual: String,
209    },
210    /// A path field was empty or relative.
211    #[error("service-definition {field} is invalid: {path:?} ({reason})")]
212    InvalidPath {
213        /// Field name.
214        field: &'static str,
215        /// Field value.
216        path: String,
217        /// Why it failed validation.
218        reason: &'static str,
219    },
220    /// Isolation fields were inconsistent.
221    #[error("service-definition isolation is invalid: {reason}")]
222    InvalidIsolation {
223        /// Why it failed validation.
224        reason: &'static str,
225    },
226}
227
228fn ensure_loadable_service_definition_dir(path: &Path) -> Result<(), ServiceDefinitionError> {
229    if !secure_dir::private_dir_permissions_are_private(path)? {
230        return Err(ServiceDefinitionError::InsecureDirectory(
231            path.to_path_buf(),
232        ));
233    }
234    Ok(())
235}
236
237fn validate_absolute_path(field: &'static str, value: &str) -> Result<(), ServiceDefinitionError> {
238    if value.is_empty() {
239        return Err(ServiceDefinitionError::InvalidPath {
240            field,
241            path: value.into(),
242            reason: "must not be empty",
243        });
244    }
245    if !Path::new(value).is_absolute() {
246        return Err(ServiceDefinitionError::InvalidPath {
247            field,
248            path: value.into(),
249            reason: "must be absolute",
250        });
251    }
252    Ok(())
253}