lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Mcrates/runtime_paths/src/error.rs | 3+++
Mcrates/runtime_paths/src/roots.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/runtime_paths/src/service.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
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(),