use camino::Utf8PathBuf;
use metrics::{counter, describe_counter};
use ortho_config::{LanguageIdentifier, LocalizationArgs, Localizer};
use std::ffi::OsStr;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Once;
use super::{AccessibilityPolicy, ColourPolicy, EmojiPolicy, ProgressPolicy};
use crate::cli::PATH_VALIDATION_TOTAL;
use crate::host_pattern::HostPattern;
use crate::localization::keys;
pub(super) fn parse_jobs(localizer: &dyn Localizer, s: &str) -> Result<usize, String> {
let value: usize = s.parse().map_err(|_| {
let mut args = LocalizationArgs::default();
args.insert("value", s.to_owned().into());
super::parser::validation_message(
localizer,
keys::CLI_JOBS_INVALID_NUMBER,
Some(&args),
&format!("{s} is not a valid number"),
)
})?;
if (1..=super::validation::MAX_JOBS).contains(&value) {
Ok(value)
} else {
let mut args = LocalizationArgs::default();
args.insert("min", 1.to_string().into());
args.insert("max", super::validation::MAX_JOBS.to_string().into());
Err(super::parser::validation_message(
localizer,
keys::CLI_JOBS_OUT_OF_RANGE,
Some(&args),
&format!("jobs must be between 1 and {}", super::validation::MAX_JOBS),
))
}
}
pub(super) fn parse_utf8_path(
localizer: &dyn Localizer,
value: &OsStr,
key: &'static str,
fallback: &str,
) -> Result<Utf8PathBuf, String> {
Utf8PathBuf::from_path_buf(PathBuf::from(value)).map_err(|path| {
record_non_utf8_path_validation(key);
let mut args = LocalizationArgs::default();
args.insert("path", path.display().to_string().into());
super::parser::validation_message(localizer, key, Some(&args), fallback)
})
}
fn record_non_utf8_path_validation(key: &str) {
let source = match key {
keys::CLI_FILE_NON_UTF8 => "file",
keys::CLI_DIRECTORY_NON_UTF8 => "directory",
_ => return,
};
describe_path_validation_metric();
counter!(PATH_VALIDATION_TOTAL, "source" => source, "reason" => "non_utf8").increment(1);
}
fn describe_path_validation_metric() {
static DESCRIBE: Once = Once::new();
DESCRIBE.call_once(|| {
describe_counter!(
PATH_VALIDATION_TOTAL,
"Counts rejected UTF-8-only CLI path values by bounded source and reason."
);
});
}
pub(super) fn parse_scheme(localizer: &dyn Localizer, s: &str) -> Result<String, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(super::parser::validation_message(
localizer,
keys::CLI_SCHEME_EMPTY,
None,
"scheme must not be empty",
));
}
let mut chars = trimmed.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
let mut args = LocalizationArgs::default();
args.insert("scheme", s.to_owned().into());
return Err(super::parser::validation_message(
localizer,
keys::CLI_SCHEME_INVALID_START,
Some(&args),
&format!("scheme '{s}' must start with an ASCII letter"),
));
}
if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
let mut args = LocalizationArgs::default();
args.insert("scheme", s.to_owned().into());
return Err(super::parser::validation_message(
localizer,
keys::CLI_SCHEME_INVALID,
Some(&args),
&format!("invalid scheme '{s}'"),
));
}
Ok(trimmed.to_ascii_lowercase())
}
pub(super) fn parse_locale(localizer: &dyn Localizer, s: &str) -> Result<String, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(super::parser::validation_message(
localizer,
keys::CLI_LOCALE_EMPTY,
None,
"locale must not be empty",
));
}
LanguageIdentifier::from_str(trimmed)
.map(|lang| lang.to_string())
.map_err(|_| {
let mut args = LocalizationArgs::default();
args.insert("locale", trimmed.to_owned().into());
super::parser::validation_message(
localizer,
keys::CLI_LOCALE_INVALID,
Some(&args),
&format!("invalid locale '{trimmed}'"),
)
})
}
pub(super) fn parse_color_policy(
localizer: &dyn Localizer,
s: &str,
) -> Result<ColourPolicy, String> {
parse_value_enum(
localizer,
s,
ParseEnumSpec {
key: keys::CLI_COLOR_POLICY_INVALID,
arg_name: "value",
},
)
}
pub(super) fn parse_emoji_policy(
localizer: &dyn Localizer,
s: &str,
) -> Result<EmojiPolicy, String> {
parse_value_enum(
localizer,
s,
ParseEnumSpec {
key: keys::CLI_EMOJI_POLICY_INVALID,
arg_name: "value",
},
)
}
pub(super) fn parse_progress_policy(
localizer: &dyn Localizer,
s: &str,
) -> Result<ProgressPolicy, String> {
parse_value_enum(
localizer,
s,
ParseEnumSpec {
key: keys::CLI_PROGRESS_POLICY_INVALID,
arg_name: "value",
},
)
}
pub(super) fn parse_accessibility_policy(
localizer: &dyn Localizer,
s: &str,
) -> Result<AccessibilityPolicy, String> {
parse_value_enum(
localizer,
s,
ParseEnumSpec {
key: keys::CLI_ACCESSIBILITY_POLICY_INVALID,
arg_name: "value",
},
)
}
#[derive(Copy, Clone)]
struct ParseEnumSpec {
key: &'static str,
arg_name: &'static str,
}
fn parse_value_enum<T>(localizer: &dyn Localizer, s: &str, spec: ParseEnumSpec) -> Result<T, String>
where
T: FromStr,
{
s.parse::<T>().map_err(|_| {
let mut args = LocalizationArgs::default();
args.insert(spec.arg_name, s.to_owned().into());
super::parser::validation_message(
localizer,
spec.key,
Some(&args),
&format!("Invalid '{s}'"),
)
})
}
pub(super) fn parse_host_pattern(
_localizer: &dyn Localizer,
s: &str,
) -> Result<HostPattern, String> {
HostPattern::parse(s).map_err(|err| err.to_string())
}
#[cfg(all(test, unix))]
mod tests {
use super::parse_utf8_path;
use crate::cli::PATH_VALIDATION_TOTAL;
use crate::cli_localization::build_localizer;
use crate::localization::keys;
use camino::Utf8PathBuf;
use metrics_util::{
MetricKind,
debugging::{DebugValue, DebuggingRecorder},
};
use proptest::prelude::*;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
const PATH_ARGUMENTS: [(&str, &str, &str, &str); 2] = [
(
"--file",
keys::CLI_FILE_NON_UTF8,
"Manifest path is not valid UTF-8.",
"Manifest path",
),
(
"--directory",
keys::CLI_DIRECTORY_NON_UTF8,
"Working directory path is not valid UTF-8.",
"Working directory path",
),
];
#[rstest::rstest]
#[case::file(keys::CLI_FILE_NON_UTF8, "Manifest path is not valid UTF-8.", "file")]
#[case::directory(
keys::CLI_DIRECTORY_NON_UTF8,
"Working directory path is not valid UTF-8.",
"directory"
)]
fn rejected_utf8_path_records_bounded_metric(
#[case] key: &'static str,
#[case] fallback: &str,
#[case] source: &str,
) {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let localizer = build_localizer(None);
metrics::with_local_recorder(&recorder, || {
parse_utf8_path(
localizer.as_ref(),
&OsString::from_vec(b"manifest-\xff".to_vec()),
key,
fallback,
)
.expect_err("non-UTF-8 paths must be rejected");
});
let snapshot = snapshotter.snapshot().into_vec();
assert!(
snapshot.iter().any(|(metric, _, _, value)| {
metric.kind() == MetricKind::Counter
&& metric.key().name() == PATH_VALIDATION_TOTAL
&& metric.key().labels().count() == 2
&& metric
.key()
.labels()
.any(|label| label.key() == "source" && label.value() == source)
&& metric
.key()
.labels()
.any(|label| label.key() == "reason" && label.value() == "non_utf8")
&& matches!(value, DebugValue::Counter(1))
}),
"rejected path should record one bounded validation counter: {snapshot:?}"
);
}
proptest! {
#[test]
fn utf8_path_parser_preserves_valid_paths_and_rejects_invalid_bytes(
bytes in prop::collection::vec(any::<u8>(), 0..512)
) {
let localizer = build_localizer(None);
let value = OsString::from_vec(bytes.clone());
for (flag, key, fallback, diagnostic_subject) in PATH_ARGUMENTS {
match (
String::from_utf8(bytes.clone()),
parse_utf8_path(localizer.as_ref(), &value, key, fallback),
) {
(Ok(valid), Ok(path)) => prop_assert_eq!(path, Utf8PathBuf::from(valid)),
(Err(_), Err(error)) => prop_assert!(
error.contains(diagnostic_subject) && error.contains("not valid UTF-8"),
"{flag} should use its localized UTF-8 diagnostic, got: {error}"
),
(Ok(valid), Err(error)) => prop_assert!(
false,
"{flag} should accept valid UTF-8 path {valid:?}, got: {error}"
),
(Err(_), Ok(path)) => prop_assert!(
false,
"{flag} should reject invalid UTF-8 bytes, got: {path}"
),
}
}
}
}
}