commit 50d1205908091438de40e5b13fa636e78191c458
parent 8cf291eb540d77ad79738fb0c87136b00f7ac7dd
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 07:26:07 +0000
runtime-paths: standardize repo local roots
- require one explicit absolute non-root base for repo-local selection
- reject missing, relative, root-only, and parent-traversal inputs
- derive every Myc and RHI instance path from the same project base
- preserve typed path-free failures across resolver and selection boundaries
Diffstat:
3 files changed, 130 insertions(+), 46 deletions(-)
diff --git a/crates/runtime_paths/src/error.rs b/crates/runtime_paths/src/error.rs
@@ -21,6 +21,9 @@ pub enum RadrootsRuntimePathsError {
#[error("repo_local requires an explicit repo-local base root")]
MissingRepoLocalRoot,
+ #[error("repo_local base root must be an absolute non-root path without parent traversal")]
+ InvalidRepoLocalRoot,
+
#[error("mobile_native requires explicit logical roots")]
MissingMobileRoots,
diff --git a/crates/runtime_paths/src/roots.rs b/crates/runtime_paths/src/roots.rs
@@ -1,4 +1,4 @@
-use std::path::{Path, PathBuf};
+use std::path::{Component, Path, PathBuf};
use crate::{
RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform, RadrootsRuntimeNamespace,
@@ -127,11 +127,14 @@ impl RadrootsPathResolver {
match profile {
RadrootsPathProfile::InteractiveUser => self.resolve_interactive_user(),
RadrootsPathProfile::ServiceHost => self.resolve_service_host(),
- RadrootsPathProfile::RepoLocal => overrides
- .repo_local_root
- .as_ref()
- .map(RadrootsPaths::from_base_root)
- .ok_or(RadrootsRuntimePathsError::MissingRepoLocalRoot),
+ RadrootsPathProfile::RepoLocal => {
+ let root = overrides
+ .repo_local_root
+ .as_deref()
+ .ok_or(RadrootsRuntimePathsError::MissingRepoLocalRoot)?;
+ validate_repo_local_root(root)?;
+ Ok(RadrootsPaths::from_base_root(root))
+ }
RadrootsPathProfile::MobileNative => match self.platform {
RadrootsPlatform::Android | RadrootsPlatform::Ios => overrides
.mobile_roots
@@ -278,6 +281,18 @@ impl RadrootsPathResolver {
}
}
+pub(crate) fn validate_repo_local_root(root: &Path) -> Result<(), RadrootsRuntimePathsError> {
+ if !root.is_absolute()
+ || root.parent().is_none()
+ || root
+ .components()
+ .any(|component| matches!(component, Component::ParentDir))
+ {
+ return Err(RadrootsRuntimePathsError::InvalidRepoLocalRoot);
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use std::path::PathBuf;
@@ -303,6 +318,42 @@ mod tests {
}
#[test]
+ fn repo_local_root_rejects_missing_relative_root_and_parent_traversal() {
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+
+ assert_eq!(
+ resolver
+ .resolve(
+ RadrootsPathProfile::RepoLocal,
+ &RadrootsPathOverrides::default(),
+ )
+ .expect_err("missing root"),
+ RadrootsRuntimePathsError::MissingRepoLocalRoot
+ );
+
+ for root in [
+ "",
+ ".",
+ "relative/root",
+ "../escape",
+ "/",
+ "/repo/../escape",
+ ] {
+ assert_eq!(
+ resolver
+ .resolve(
+ RadrootsPathProfile::RepoLocal,
+ &RadrootsPathOverrides::repo_local(root),
+ )
+ .expect_err("invalid repo-local root"),
+ RadrootsRuntimePathsError::InvalidRepoLocalRoot,
+ "accepted invalid repo-local root `{root}`"
+ );
+ }
+ }
+
+ #[test]
fn resolver_current_uses_process_platform_and_environment() {
let resolver = RadrootsPathResolver::current();
assert_eq!(resolver.platform(), RadrootsPlatform::current());
diff --git a/crates/runtime_paths/src/service.rs b/crates/runtime_paths/src/service.rs
@@ -6,6 +6,7 @@ use std::{
use serde::Serialize;
use thiserror::Error;
+use crate::roots::validate_repo_local_root;
use crate::{
InstanceId, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsPaths,
RadrootsRuntimeNamespace, RadrootsRuntimePathsError, RadrootsServiceInstanceNamespace,
@@ -329,6 +330,7 @@ impl RadrootsRuntimePathSelection {
repo_local_root_env: repo_local_root_label.to_owned(),
});
};
+ validate_repo_local_root(repo_local_root)?;
Ok(RadrootsPathOverrides::repo_local(repo_local_root))
}
_ => Ok(RadrootsPathOverrides::default()),
@@ -480,50 +482,52 @@ mod tests {
}
#[test]
- fn resolve_service_instance_paths_uses_one_repo_local_base() {
+ fn resolve_myc_and_rhi_instance_paths_use_one_repo_local_base() {
let selection = RadrootsRuntimePathSelection::caller(
RadrootsPathProfile::RepoLocal,
Some(PathBuf::from("/repo/.local/radroots")),
);
let resolver =
RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
- let service_id = ServiceId::new("rhi").expect("service id");
- let instance_id = InstanceId::new("west-01").expect("instance id");
-
- let paths = selection
- .resolve_service_instance_paths(
- &resolver,
- &service_id,
- &instance_id,
- "PROFILE_ENV",
- "ROOT_ENV",
- )
- .expect("service instance paths");
-
- assert_eq!(
- paths.config(),
- PathBuf::from("/repo/.local/radroots/config/services/rhi/west-01")
- );
- assert_eq!(
- paths.state(),
- PathBuf::from("/repo/.local/radroots/data/services/rhi/west-01")
- );
- assert_eq!(
- paths.cache(),
- PathBuf::from("/repo/.local/radroots/cache/services/rhi/west-01")
- );
- assert_eq!(
- paths.logs(),
- PathBuf::from("/repo/.local/radroots/logs/services/rhi/west-01")
- );
- assert_eq!(
- paths.run(),
- PathBuf::from("/repo/.local/radroots/run/services/rhi/west-01")
- );
- assert_eq!(
- paths.secrets(),
- PathBuf::from("/repo/.local/radroots/secrets/services/rhi/west-01")
- );
+ for (service, instance) in [("myc", "primary"), ("rhi", "west-01")] {
+ let service_id = ServiceId::new(service).expect("service id");
+ let instance_id = InstanceId::new(instance).expect("instance id");
+ let paths = selection
+ .resolve_service_instance_paths(
+ &resolver,
+ &service_id,
+ &instance_id,
+ "PROFILE_ENV",
+ "ROOT_ENV",
+ )
+ .expect("service instance paths");
+ let suffix = format!("services/{service}/{instance}");
+
+ assert_eq!(
+ paths.config(),
+ PathBuf::from("/repo/.local/radroots/config").join(&suffix)
+ );
+ assert_eq!(
+ paths.state(),
+ PathBuf::from("/repo/.local/radroots/data").join(&suffix)
+ );
+ assert_eq!(
+ paths.cache(),
+ PathBuf::from("/repo/.local/radroots/cache").join(&suffix)
+ );
+ assert_eq!(
+ paths.logs(),
+ PathBuf::from("/repo/.local/radroots/logs").join(&suffix)
+ );
+ assert_eq!(
+ paths.run(),
+ PathBuf::from("/repo/.local/radroots/run").join(&suffix)
+ );
+ assert_eq!(
+ paths.secrets(),
+ PathBuf::from("/repo/.local/radroots/secrets").join(&suffix)
+ );
+ }
}
#[test]
@@ -543,6 +547,32 @@ mod tests {
}
#[test]
+ fn repo_local_selection_rejects_relative_empty_and_escaping_roots() {
+ for root in [
+ "",
+ ".",
+ "relative/root",
+ "../escape",
+ "/",
+ "/repo/../escape",
+ ] {
+ let selection = RadrootsRuntimePathSelection::caller(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from(root)),
+ );
+ assert_eq!(
+ selection
+ .caller_overrides()
+ .expect_err("invalid caller root"),
+ RadrootsRuntimePathSelectionError::Paths(
+ crate::RadrootsRuntimePathsError::InvalidRepoLocalRoot
+ ),
+ "accepted invalid repo-local selection `{root}`"
+ );
+ }
+ }
+
+ #[test]
fn profile_value_selection_accepts_mobile_native() {
let selection = RadrootsRuntimePathSelection::from_profile_value("mobile_native", None)
.expect("mobile native profile");
@@ -639,7 +669,7 @@ mod tests {
)),
Some(RadrootsRuntimePathConfigEntry::new(
"RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
- ".local/radroots",
+ "/repo/.local/radroots",
"env_file:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
)),
RadrootsPathProfile::InteractiveUser,
@@ -653,7 +683,7 @@ mod tests {
);
assert_eq!(
selection.repo_local_root,
- Some(PathBuf::from(".local/radroots"))
+ Some(PathBuf::from("/repo/.local/radroots"))
);
assert_eq!(
selection.repo_local_root_source.as_deref(),